pg.ddx.io pgsql-hackers@postgresql.org mailing list archive
help / color / mirror / Atom feednbtree: opt-in leaf key prefix compression
12+ messages / 3 participants
[nested] [flat]
* nbtree: opt-in leaf key prefix compression
@ 2026-10-26 20:24 Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-11-03 21:48 ` Re: nbtree: opt-in leaf key prefix compression Peter Geoghegan <pg@bowt.ie>
0 siblings, 2 replies; 12+ messages in thread
From: Greg Burd @ 2026-10-26 20:24 UTC (permalink / raw)
To: pgsql-hackers@lists.postgresql.org; +Cc: Peter Geoghegan <pg@bowt.ie>
Hello Hackers,
This is a patch that adds leaf key prefix compression to nbtree, behind
a new per-index storage parameter, prefix_compression, off by default.
It's something I started a long time ago when primarily working on
JSONB-like data storage/indexing/etc. in Postgres when a colleague
pointed out that it didn't yet exist and his measurements showed that it
should. He wasn't wrong. It's a solid win for performance and overhead
IMO, but YMMV.
The motivation is simple: indexes on keys that share a long leading
prefix (URLs, reverse-DNS names, a leading JSONB path, a tenant-id
prefix) spend most of their bytes storing that prefix over and over.
Store it once per page and the index gets a few times smaller, which
means fewer pages to read and less WAL to write. All good things.
A URL index in my tests is about 3x smaller; an index with little shared
prefix is unchanged (the page is left alone when a prefix does not pay
off).
Overview:
When a leaf page is written as a whole (during an index build, as a
split half, or as the result of a deduplication pass) and its data items
share leading bytes in their first key column, the page is rebuilt in a
new BTP_PREFIX format: one PREFIX item after the high key holds the
shared bytes, and each data item is stored "coded", with its copy of the
shared prefix replaced by a one-byte length. Reads of a leaf data item
go through a single accessor, _bt_getitem(), that decodes on demand into
caller-supplied scratch; high keys and internal-page downlinks are never
coded and are read with the existing PageGetItem().
This is a per-page opt-in with no on-disk version change: BTREE_VERSION
and the catalog version are not touched. A page is converted only when
it is next written as a whole, never by an ordinary insert, VACUUM, or
key-range change. So an existing index "faults forward": old-format and
new-format pages coexist in the same index indefinitely. That is what
makes the feature safe across pg_upgrade, physical replication, and
standbys with no REINDEX: a replica that has not yet replayed a page's
conversion still reads the old format correctly, and turning the
parameter back off stops new conversions while existing coded pages stay
readable.
Performance:
Release builds (cassert=off), origin/master vs the patch, on a
c7i.4xlarge, pgbench, fsync and synchronous_commit off so the index
effects are not masked by WAL flush latency. "off" is the patch with
the parameter off (the default path), to show it is free when unused.
index size (pages), CREATE INDEX on ~500k rows:
shared prefix / key master patch=on
--------------------- ------ --------
URL-like, distinct 10531 7848 (-25%)
JSONB path, distinct 11636 5782 (-50%)
int (no shared prefix) (same) (same, page left uncompressed)
insert throughput (one row at a time into an existing index):
workload TPS master->on WAL/txn master->on
--------------- ------------------ ------------------
insert_random 143.7k -> 142.8k 292 B -> 226 B
insert_ascending 144.9k -> 139.4k 277 B -> 228 B
WAL per insert is slightly *below* master because the coded tuple logged
is full-page-image penalty. CREATE INDEX WAL is roughly half of
master's on these keys.
The "off" column tracks master within noise on every workload, so the
default path is not paying for a feature it is not using.
Deduplication composes with it:
Prefix compression and deduplication stack. A build deduplicates first
and then codes the finished page, so a duplicate-heavy prefix index is
no larger than a plain one: the posting lists collapse the duplicates,
and the prefix then front-codes the distinct keys that remain. Across a
duplication sweep the prefix index is <= plain everywhere, and smaller
once there is any shared prefix among the distinct keys. Without this
the earlier drafts were several times *larger* on duplicate-heavy data,
which is the trap to avoid if you try this yourself.
Bottom-up index deletion runs on coded pages unchanged, since it only
removes items and never rewrites survivors. A runtime deduplication
pass on a coded page decodes its items first, and because a coded page
can hold more logical data than fits plain, the pass aborts cleanly and
the page splits instead when the decoded set would overflow, it never
errors out.
WAL:
The part I most want eyes on. Replaying a coded page byte-for-byte is
the hard requirement (wal_consistency_checking has to pass on a
standby), and the obvious implementation (a forced full-page image on
every change to a coded page) costs a whole page of WAL per insert.
Instead redo reconstructs the coded bytes the way the primary did. The
catch is that redo has no relcache and cannot run the prefix-choosing
heuristic, so the primary logs the prefix it chose, and a single
Relation-free layout routine (_bt_split_leaf_layout) is shared by the
primary and redo so both emit identical bytes from the same inputs:
- an in-place insert logs the coded tuple; the standard insert redo adds
it verbatim;
- a split logs each half's chosen prefix plus the right half's plain
items, and redo lays each half out coded;
- a deduplication logs the chosen prefix plus the deduped plain items.
Each path keeps a bounded fallback to a forced image for a
pathologically compressible page whose plain payload would exceed one
block, so the logged payload can never exceed BLCKSZ.
What reviewers should know going in:
Things I already know are worth a hard look, so you don't have to find
them cold:
- A posting-list split on a coded page, and a deduplication pass whose
re-compression then declines, are the two fiddliest WAL paths. I
had bugs in both (a palloc inside a critical section on the first;
redo not decoding on the second) and have fixed them, but they are
where I would point a reviewer first. I could not turn the second
into a failing wal_consistency_checking case and would value a
second opinion on whether the path is actually reachable.
- Every read of a leaf *data* item must go through _bt_getitem(). I
audited the readers; the one I missed on the first pass was the
array-scan look-ahead in _bt_readpage(), now fixed. A reviewer's
instinct for "is this a data item or a pivot?" is exactly the check
I want applied to the rest.
- The split point is chosen on coded sizes, which breaks the usual
assumption that a leaf holds at most two pages' worth of data; there
is a guard against a degenerate split that would leave the left page
with no data items. nbtsplitloc.c is worth a read for that reason.
- Runtime deduplication on a coded page is the one place that can
decline and fall through to a split rather than reclaim space. That
is deliberate (the decoded set can exceed a page), but it means a
duplicate-heavy index populated by one-row-at-a-time inserts is less
compact than the same index built with CREATE INDEX. I think that
is an acceptable trade; it may warrant a doc note.
- amcheck decodes every leaf data item, including under heapallindexed.
Tested with wal_consistency_checking=all on a primary and a streaming
standby across insert, split, deduplication, delete and vacuum
workloads, through immediate-crash recovery, and across a pg_upgrade
that carries both page formats in one index. -Werror, --enable-cassert
and --enable-injection-points clean; make check, the isolation, amcheck,
pageinspect and pgstattuple suites, and 027_stream_regress all pass.
Open questions:
(a) Is a per-page opt-in with no version bump the right contract? I
chose it so the feature needs no REINDEX and is a no-op risk for
anyone who leaves it off, at the cost of old and new page formats
living in one index forever.
(b) Is the shared primary/redo layout routine the right factoring, or
would you rather the build, split and dedup paths share even more (a
common "code a leaf from plain items + prefix" helper)?
(c) The forced-image fallback threshold is "plain payload > one block".
It essentially never fires on real data; is that the right place to
draw the line?
The patch is one commit against master. I'd appreciate review of the
approach and the code, and I'm happy to work through whatever it turns
up.
best.
-greg
Attachments:
0001-nbtree-opt-in-leaf-key-prefix-compression.patch (text/plain, 153.5K) download
From b1764599e54e6b1a701b4be0c7efb1e395de17a0 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 15:25:58 -0400
Subject: [PATCH] nbtree: opt-in leaf key prefix compression
Add leaf key prefix compression to nbtree, controlled by a new
per-index storage parameter, prefix_compression, that is off by default.
When a leaf page is written as a whole -- during an index build, as a
split half, or as the result of a deduplication pass -- and its data
items share a leading byte-prefix of their first key column, the page is
rebuilt in the BTP_PREFIX format: a single PREFIX item after the high
key holds the shared bytes, and each data item is stored "coded" (its
copy of the shared prefix replaced by a one-byte length). Reads of a
leaf data item funnel through _bt_getitem(), which decodes on demand;
high keys and internal-page downlinks are never coded and are read with
the existing PageGetItem().
The feature is deliberately a per-page opt-in with no on-disk version
change: BTREE_VERSION and the catalog version are untouched. A page is
converted only when it is next written whole, never by an insert, VACUUM
or key-range change, so compression "faults forward" on an existing
index. Old-format and new-format pages coexist in the same index, which
keeps pg_upgrade, physical replication, and standbys working with no
REINDEX: a replica that has not yet seen a page's conversion still reads
it correctly, and turning the parameter off (ALTER INDEX ... SET) stops
new conversions while leaving existing coded pages readable.
Deduplication and prefix compression compose rather than compete. A
build deduplicates first and then codes the finished page, so a
duplicate-heavy prefix index is no larger than a plain one; distinct
keys with a shared prefix (URL or JSONB columns, the motivating case)
are typically a few times smaller. Bottom-up index deletion runs on
coded pages unchanged, since it only removes items. A runtime
deduplication pass on a coded page decodes first, and because a coded
page can hold more logical data than fits plain, the pass aborts cleanly
and the page splits instead when the decoded set would overflow.
WAL replay reproduces the coded bytes without resorting to full-page
images in the common paths. A Relation-free layout routine,
_bt_split_leaf_layout(), is shared by the primary and redo so both emit
identical bytes from the same logged inputs: redo has no relcache and
cannot run the prefix-choosing heuristic, so the primary logs the prefix
it chose. An in-place insert logs the coded tuple; a split logs each
half's chosen prefix plus the right half's plain items; a prefix
deduplication logs the chosen prefix plus the deduped plain items. Each
path keeps a bounded fallback to a forced image for a pathologically
compressible page whose plain payload would exceed one block.
amcheck decodes every leaf data item, including under heapallindexed.
The create_index documentation and the nbtree README describe the
parameter and the on-disk format. Tested with
wal_consistency_checking=all on a primary and a streaming standby across
insert, split, deduplication, delete and vacuum workloads, through crash
recovery, and across a pg_upgrade that carries both page formats.
---
contrib/amcheck/verify_nbtree.c | 72 +-
doc/src/sgml/ref/create_index.sgml | 35 +
src/backend/access/common/reloptions.c | 10 +
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 71 ++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 258 ++++++-
src/backend/access/nbtree/nbtinsert.c | 380 ++++++++--
src/backend/access/nbtree/nbtpage.c | 31 +-
src/backend/access/nbtree/nbtprefix.c | 814 ++++++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 23 +-
src/backend/access/nbtree/nbtree.c | 6 +-
src/backend/access/nbtree/nbtsearch.c | 3 +-
src/backend/access/nbtree/nbtsort.c | 54 +-
src/backend/access/nbtree/nbtsplitloc.c | 84 ++-
src/backend/access/nbtree/nbtutils.c | 4 +-
src/backend/access/nbtree/nbtxlog.c | 309 +++++++-
src/include/access/nbtree.h | 147 +++-
src/include/access/nbtxlog.h | 44 +-
src/test/regress/expected/btree_index.out | 256 +++++++
src/test/regress/sql/btree_index.sql | 167 +++++
src/tools/pgindent/typedefs.list | 2 +
22 files changed, 2655 insertions(+), 117 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c
index 97c519b47f5..69aaf6bbc5d 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -158,7 +158,12 @@ typedef struct BtreeLastVisibleEntry
OffsetNumber offset; /* Offset on index block */
int postingIndex; /* Number in the posting list (-1 for
* non-deduplicated tuples) */
- ItemPointer tid; /* Heap tid */
+ ItemPointer tid; /* Heap tid, or NULL; points at tiddata */
+ ItemPointerData tiddata; /* Own storage for *tid: a prefix-coded leaf
+ * tuple is decoded into a scratch buffer that
+ * is reused on the next loop iteration, so we
+ * must keep the heap TID by value, not point
+ * into the (transient) decoded tuple */
} BtreeLastVisibleEntry;
/*
@@ -935,7 +940,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
lVis->postingIndex = i;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
}
}
}
@@ -961,7 +967,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
lVis->postingIndex = -1;
}
}
@@ -1229,6 +1236,16 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber max;
BTPageOpaque topaque;
+ /*
+ * Scratch space for decoding prefix-coded leaf tuples (_bt_getitem).
+ * itupbuf backs the current loop item; crossbuf backs the right sibling's
+ * first item in the cross-page unique check, which must coexist with the
+ * current item's state. A pivot, high key or non-prefix leaf item is
+ * returned by _bt_getitem unchanged (buffer unused).
+ */
+ BTItemBuf itupbuf;
+ BTItemBuf crossbuf;
+
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1293,8 +1310,15 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, state->targetblock,
state->target, offset);
- itup = (IndexTuple) PageGetItem(state->target, itemid);
- tupsize = IndexTupleSize(itup);
+
+ /*
+ * lp_len and the tuple size limit are physical properties of the item
+ * as stored, so they are checked against the raw on-page tuple (coded,
+ * on a BTP_PREFIX leaf). Every key/content check below instead uses
+ * the decoded tuple returned by _bt_getitem.
+ */
+ tupsize = IndexTupleSize((IndexTuple) PageGetItem(state->target, itemid));
+ itup = _bt_getitem(state->target, offset, &itupbuf);
/*
* lp_len should match the IndexTuple reported length exactly, since
@@ -1606,7 +1630,7 @@ 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), &itupbuf);
tid = BTreeTupleGetPointsToTID(itup);
nhtid = psprintf("(%u,%u)",
ItemPointerGetBlockNumberNoCheck(tid),
@@ -1793,7 +1817,7 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, rightblock_number,
rightpage,
rightfirstoffset);
- itup = (IndexTuple) PageGetItem(rightpage, itemid);
+ itup = _bt_getitem(rightpage, rightfirstoffset, &crossbuf);
bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis);
@@ -1857,6 +1881,8 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
BlockNumber targetnext;
Page rightpage;
OffsetNumber nline;
+ OffsetNumber rightoffset;
+ BTItemBuf firstbuf;
/* Determine target's next block number */
opaque = BTPageGetOpaque(state->target);
@@ -2016,6 +2042,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/* Return first data item (if any) */
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
P_FIRSTDATAKEY(opaque));
+ rightoffset = P_FIRSTDATAKEY(opaque);
*rightfirstoffset = P_FIRSTDATAKEY(opaque);
}
else if (!P_ISLEAF(opaque) &&
@@ -2027,6 +2054,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
*/
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
OffsetNumberNext(P_FIRSTDATAKEY(opaque)));
+ rightoffset = OffsetNumberNext(P_FIRSTDATAKEY(opaque));
}
else
{
@@ -2046,9 +2074,19 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/*
* Return first real item scankey. Note that this relies on right page
* memory remaining allocated.
+ *
+ * On a BTP_PREFIX leaf the first data item is prefix-coded, so decode it;
+ * because the returned scankey (built by _bt_mkscankey) references the
+ * tuple's own storage, and _bt_getitem decodes into a buffer local to
+ * this function, copy the decoded tuple into the caller-reset memory
+ * context so it outlives the call. A pivot or non-prefix item is
+ * returned by _bt_getitem unchanged (it lives in the right page, which
+ * remains allocated), so no copy is needed then.
*/
- firstitup = (IndexTuple) PageGetItem(rightpage, rightitem);
- return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ firstitup = _bt_getitem(rightpage, rightoffset, &firstbuf);
+ if (firstitup == (IndexTuple) PageGetItem(rightpage, rightitem))
+ return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ return bt_mkscankey_pivotsearch(state->rel, CopyIndexTuple(firstitup));
}
/*
@@ -3095,14 +3133,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);
@@ -3122,11 +3159,12 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key,
{
BTPageOpaque topaque;
IndexTuple ritup;
+ BTItemBuf ritupbuf;
int uppnkeyatts;
ItemPointer rheaptid;
bool nonpivot;
- ritup = (IndexTuple) PageGetItem(state->target, itemid);
+ ritup = _bt_getitem(state->target, upperbound, &ritupbuf);
topaque = BTPageGetOpaque(state->target);
nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque);
@@ -3218,14 +3256,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 */
@@ -3236,12 +3273,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key,
if (cmp == 0)
{
IndexTuple child;
+ BTItemBuf childbuf;
int uppnkeyatts;
ItemPointer childheaptid;
BTPageOpaque copaque;
bool nonpivot;
- child = (IndexTuple) PageGetItem(nontarget, itemid);
+ child = _bt_getitem(nontarget, upperbound, &childbuf);
copaque = BTPageGetOpaque(nontarget);
nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque);
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..7e66765c3d3 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -477,6 +477,41 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
</note>
</listitem>
</varlistentry>
+
+ <varlistentry id="index-reloption-prefix-compression" xreflabel="prefix_compression">
+ <term><literal>prefix_compression</literal> (<type>boolean</type>)
+ <indexterm>
+ <primary><varname>prefix_compression</varname> storage parameter</primary>
+ </indexterm>
+ </term>
+ <listitem>
+ <para>
+ Controls leaf key prefix compression. When set to
+ <literal>ON</literal>, and the index's first key column is of a
+ variable-length type, a leaf page may store a shared prefix of that
+ column once and record only each tuple's unshared remainder, which can
+ substantially shrink indexes on keys that share long leading bytes
+ (for example URLs or <type>jsonb</type> keys with common paths). Set
+ to <literal>ON</literal> or <literal>OFF</literal> to enable or disable
+ the optimization. The default is <literal>OFF</literal>.
+ </para>
+
+ <note>
+ <para>
+ 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 adopts a prefix only when
+ it is written as a whole (during an index build, or either half of a
+ page split), so turning <literal>prefix_compression</literal> on or off
+ via <command>ALTER INDEX</command> does not rewrite the index: existing
+ pages are left as they are and are converted the next time they are
+ written whole. No <command>REINDEX</command> is required, and indexes
+ upgraded with <application>pg_upgrade</application> keep working
+ unchanged.
+ </para>
+ </note>
+ </listitem>
+ </varlistentry>
</variablelist>
<para>
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..8aa7f9e7620 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1081,3 +1081,74 @@ 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
+front-code the first key column of each of its data tuples against a per-page
+prefix, which shrinks indexes on keys that share long leading bytes (URLs,
+jsonb keys with common paths, zero-padded codes). Like deduplication, this
+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.
+
+A leaf that uses the technique carries the BTP_PREFIX page flag and one extra
+item, the PREFIX item, 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. The PREFIX item is an
+IndexTupleData header followed by 1..BTREE_PREFIX_MAX prefix bytes, shaped so
+that code which walks a page's items by IndexTupleSize (such as
+_bt_restore_page) steps over it. On such a page every non-pivot tuple (plain
+or posting) whose first key column is not NULL stores that column as a varlena
+whose payload begins with a one-byte count of how many leading bytes it shares
+with the page prefix, followed by the unshared remainder (or, for a value whose
+header is not the form index_form_tuple would reproduce, the whole value after
+a sentinel count). Trailing key columns, INCLUDE columns, a posting list and
+alignment padding follow, moved to preserve alignment; padding that is only the
+key's closing zeros is dropped and restored on decode. Pivots, NULL first
+columns and the high key are always stored uncompressed. Coding and decoding
+depend only on the page prefix and the tuple, never the catalog, so WAL redo
+reproduces them and decoding yields the original tuple byte for byte. All of
+nbtprefix.c implements this; _bt_getitem() is the one accessor through which
+readers obtain a decoded leaf data tuple.
+
+A page adopts a prefix only when it is written as a whole: a finished leaf
+during an index build, or either half of a leaf page split (built coded from
+the start by _bt_split_leaf_half, since a compressed leaf can hold more logical
+data than fits on a plain page, choosing each half's own prefix and falling
+back to a plain layout or the original page's prefix if its own does not fit).
+Crucially, no insert, VACUUM, deduplication or change of a page's key range
+ever rewrites a page to keep its prefix valid: a tuple that happens to share
+fewer bytes simply records a smaller shared count. 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 physical/logical replication keep
+working and existing indexes continue to work with no REINDEX. Turning the
+parameter on with ALTER INDEX does not rewrite anything; 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 (the flag is cleared
+only when a page is split, emptied or dedup-rewritten) and are still read back
+correctly with the parameter off, since every reader decodes based on the page
+flag, not on the current reloption. Scan-time workspace that must hold decoded
+tuples (the index-only-scan currTuples/markTuples buffers, see
+BTTupleStorageSize) is therefore sized for a decoded page on every btree index,
+not just those whose parameter is currently on.
+
+The left half of a prefix split (block 0) is logged by full-page image
+whenever the pre-split original page was prefix-compressed -- a half may end up
+plain yet still be FPI-logged because its items were coded against the
+original page's prefix -- and the right half (block 1) is FPI-logged whenever
+it ends up prefix-compressed. A deduplication rewrite of a prefix page is
+FPI-logged the same way. Full-page images are used rather than replaying
+individual tuples because the coded physical layout a prefix page ends up with
+would not be reconstructed by _bt_restore_page's item-by-item replay; redo
+restores the image. When VACUUM or page deletion empties a prefix leaf, the
+PREFIX item is removed and the flag cleared so that the invariant that an
+emptied leaf holds only its high key (relied on by page deletion) still holds.
+
+Deduplication and bottom-up index deletion are not currently applied to
+BTP_PREFIX pages (such a page splits instead): their in-place rewrite of a page
+cannot always be re-compressed within one page, and prefix compression already
+reclaims much of the space they target. Composing them with prefix
+compression is future work.
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..6dc7ebe25b4 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -69,7 +69,19 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Size pagesaving PG_USED_FOR_ASSERTS_ONLY = 0;
bool singlevalstrat = false;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
XLogRecPtr recptr;
+ IndexTuple *dedup_plain = NULL; /* deduped plain items, for prefix WAL */
+ int dedup_nplain = 0;
+ IndexTuple dedup_hikey = NULL;
+ char dedup_prefix[BTREE_PREFIX_MAX];
+ Size dedup_prefixlen = 0;
+ bool dedup_islogprefix = false; /* page ended up prefix-coded */
+ char *dedup_walbuf = NULL; /* packed prefix-dedup WAL payload */
+ Size dedup_walbuflen = 0;
+ bool dedup_rfpi = false; /* fall back to a forced full-page image */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -120,7 +132,16 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
newpage = PageGetTempPageCopySpecial(page);
PageSetLSN(newpage, PageGetLSN(page));
- /* Copy high key, if any */
+ /*
+ * The rebuilt page is laid out plain here (clear any inherited BTP_PREFIX;
+ * PageGetTempPageCopySpecial copies the special area but not the PREFIX
+ * item). If the page was prefix-compressed it is re-compressed as a whole
+ * below, after deduplication, honouring Strategy 1.
+ */
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
+ /* Copy high key, if any (a pivot, stored plain on any page) */
if (!P_RIGHTMOST(opaque))
{
ItemId hitemid = PageGetItemId(page, P_HIKEY);
@@ -140,12 +161,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Assert(!ItemIdIsDead(itemid));
+ /* Decode on a prefix page so keys/posting lists are the real ones. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/*
* No previous/base tuple for the data item -- use the data item
- * as base tuple of pending posting list
+ * as base tuple of pending posting list. On a prefix page the
+ * base must survive the whole merge run (it is kept as a pointer
+ * in state and read by _bt_dedup_finish_pending), so keep it in a
+ * dedicated buffer rather than the per-iteration curbuf.
*/
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (state->deduplicate &&
@@ -170,7 +201,20 @@ _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);
+ /*
+ * On a prefix page the deduped items are laid out plain here; the
+ * decoded set can exceed one page even when the coded form fit, so
+ * tolerate an overflow and abort the whole pass (the caller then
+ * splits the page instead of deduplicating it).
+ */
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state,
+ pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
if (singlevalstrat)
{
@@ -198,12 +242,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
}
/* itup starts new pending posting list */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page,
+ (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
/* Handle the last item */
- pagesaving += _bt_dedup_finish_pending(newpage, state);
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state, pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
/*
* If no items suitable for deduplication were found, newpage must be
@@ -238,6 +292,103 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
nopaque->btpo_flags &= ~BTP_HAS_GARBAGE;
}
+ /*
+ * Re-apply prefix compression to the rebuilt (plain) page as a whole
+ * (Strategy 1), before the critical section since it allocates a scratch
+ * page. First capture the deduped plain items and high key: if the page
+ * does become prefix-compressed, redo rebuilds its coded bytes from these
+ * plus the chosen prefix (via _bt_split_leaf_layout), the same way a prefix
+ * leaf split is replayed -- no full-page image needed.
+ */
+ if (pageprefix)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(newpage);
+ OffsetNumber nmin = P_FIRSTDATAKEY(nopq);
+ OffsetNumber nmax = PageGetMaxOffsetNumber(newpage);
+
+ dedup_nplain = (nmax >= nmin) ? (int) nmax - (int) nmin + 1 : 0;
+ if (dedup_nplain > 0)
+ {
+ dedup_plain = palloc(dedup_nplain * sizeof(IndexTuple));
+ for (int i = 0; i < dedup_nplain; i++)
+ dedup_plain[i] = CopyIndexTuple((IndexTuple)
+ PageGetItem(newpage,
+ PageGetItemId(newpage, nmin + i)));
+ }
+ if (!P_RIGHTMOST(nopq))
+ {
+ ItemId hii = PageGetItemId(newpage, P_HIKEY);
+
+ dedup_hikey = CopyIndexTuple((IndexTuple) PageGetItem(newpage, hii));
+ }
+
+ _bt_prefix_compress_page(rel, newpage);
+
+ /* Did it actually compress? (It gives up if a coded item won't fit.) */
+ if (P_HASPREFIX(BTPageGetOpaque(newpage)))
+ {
+ const char *pfx = _bt_page_get_prefix(newpage, &dedup_prefixlen);
+
+ if (dedup_prefixlen > 0)
+ memcpy(dedup_prefix, pfx, dedup_prefixlen);
+ dedup_islogprefix = true;
+ }
+ }
+
+ /*
+ * Pack the prefix-dedup WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the chosen prefix bytes, then the high
+ * key (unless rightmost), then the deduped plain items, each MAXALIGN'd,
+ * in one contiguous buffer logged with a single rdata slot.
+ */
+ if (dedup_islogprefix)
+ {
+ Size off = dedup_prefixlen;
+
+ if (dedup_hikey != NULL)
+ off += MAXALIGN(IndexTupleSize(dedup_hikey));
+ for (int i = 0; i < dedup_nplain; i++)
+ off += MAXALIGN(IndexTupleSize(dedup_plain[i]));
+
+ /*
+ * A pathologically compressible page can hold more plain data than
+ * fits in one page; if the payload would be that large, fall back to
+ * a forced full-page image of the coded page (bounded at BLCKSZ).
+ */
+ if (off > BLCKSZ)
+ dedup_rfpi = true;
+ else
+ {
+ dedup_walbuflen = off;
+ dedup_walbuf = palloc(Max(off, 1));
+
+ off = 0;
+ if (dedup_prefixlen > 0)
+ {
+ memcpy(dedup_walbuf, dedup_prefix, dedup_prefixlen);
+ off += dedup_prefixlen;
+ }
+ if (dedup_hikey != NULL)
+ {
+ Size hsz = IndexTupleSize(dedup_hikey);
+
+ memcpy(dedup_walbuf + off, dedup_hikey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(dedup_walbuf + off + hsz, 0, MAXALIGN(hsz) - hsz);
+ off += MAXALIGN(hsz);
+ }
+ for (int i = 0; i < dedup_nplain; i++)
+ {
+ Size sz = IndexTupleSize(dedup_plain[i]);
+
+ memcpy(dedup_walbuf + off, dedup_plain[i], sz);
+ if (MAXALIGN(sz) != sz)
+ memset(dedup_walbuf + off + sz, 0, MAXALIGN(sz) - sz);
+ off += MAXALIGN(sz);
+ }
+ }
+ }
+
START_CRIT_SECTION();
PageRestoreTempPage(newpage, page);
@@ -248,19 +399,38 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
{
xl_btree_dedup xlrec_dedup;
- xlrec_dedup.nintervals = state->nintervals;
+ xlrec_dedup.nintervals = dedup_islogprefix ? 0 : state->nintervals;
+ xlrec_dedup.prefixlen = (uint8) dedup_prefixlen;
+ xlrec_dedup.flags = (dedup_hikey != NULL) ? XLH_DEDUP_NOT_RIGHTMOST : 0;
+ if (dedup_rfpi)
+ xlrec_dedup.flags |= XLH_DEDUP_RFPI;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ (dedup_rfpi ? REGBUF_FORCE_IMAGE : 0));
XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
- /*
- * The intervals array is not in the buffer, but pretend that it is.
- * When XLogInsert stores the whole buffer, the array need not be
- * stored too.
- */
- XLogRegisterBufData(0, state->intervals,
- state->nintervals * sizeof(BTDedupInterval));
+ if (dedup_islogprefix)
+ {
+ /*
+ * Prefix-dedup: carry the chosen prefix and the deduped plain
+ * items so redo can lay out the coded page; no full-page image
+ * (unless the payload was too large, handled by the forced image).
+ */
+ if (!dedup_rfpi && dedup_walbuflen > 0)
+ XLogRegisterBufData(0, dedup_walbuf, dedup_walbuflen);
+ }
+ else
+ {
+ /*
+ * The intervals array is not in the buffer, but pretend that it
+ * is. When XLogInsert stores the whole buffer, the array need not
+ * be stored too.
+ */
+ XLogRegisterBufData(0, state->intervals,
+ state->nintervals * sizeof(BTDedupInterval));
+ }
recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DEDUP);
}
@@ -277,6 +447,20 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
/* cannot leak memory here */
pfree(state->htids);
pfree(state);
+ return;
+
+dedup_overflow:
+
+ /*
+ * A prefix page's deduped items did not fit when laid out plain. Leave
+ * the original page untouched and return without writing anything; the
+ * caller falls through to split the page. No critical section was
+ * entered and the buffer was not marked dirty, so the page is unchanged.
+ */
+ Assert(pageprefix);
+ pfree(newpage);
+ pfree(state->htids);
+ pfree(state);
}
/*
@@ -318,6 +502,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
TM_IndexDeleteOp delstate;
bool neverdedup;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -371,9 +558,24 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
Assert(!ItemIdIsDead(itemid));
+ /*
+ * On a prefix-compressed page decode the item so _bt_keep_natts_fast
+ * sees the real key. The base of a pending interval must survive the
+ * whole run (it is compared against each following item), so keep it
+ * in its own buffer; the current item uses a reused buffer. Heap TIDs
+ * and posting lists are preserved by coding, so _bt_dedup_start_pending
+ * and _bt_bottomupdel_finish_pending (which read only TIDs/sizes, by
+ * page offset) need no decode.
+ */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/* itup starts first pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
@@ -387,6 +589,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
_bt_bottomupdel_finish_pending(page, state, &delstate);
/* itup starts new pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
@@ -554,7 +759,7 @@ _bt_dedup_save_htid(BTDedupState state, IndexTuple itup)
* where no deduplication was possible.
*/
Size
-_bt_dedup_finish_pending(Page newpage, BTDedupState state)
+_bt_dedup_finish_pending(Page newpage, BTDedupState state, bool overflowok)
{
OffsetNumber tupoff;
Size tuplesz;
@@ -572,7 +777,11 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
spacesaving = 0;
}
@@ -591,13 +800,26 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(final)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ pfree(final);
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
pfree(final);
spacesaving = state->phystupsize - (tuplesz + sizeof(ItemIdData));
/* Increment nintervals, since we wrote a new posting list tuple */
state->nintervals++;
- Assert(spacesaving > 0 && spacesaving < BLCKSZ);
+
+ /*
+ * On an ordinary page the merged interval's tuples were all physically
+ * present on one page, so the saving is bounded by BLCKSZ. On a prefix
+ * page (overflowok) phystupsize is the DECODED size of those tuples,
+ * which can exceed a page (a coded page holds more logical data than
+ * fits plain), so only require the saving to be positive there.
+ */
+ Assert(spacesaving > 0 && (overflowok || spacesaving < BLCKSZ));
}
/* Reset state for next pending posting list */
@@ -783,16 +1005,22 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state,
OffsetNumber minoff, IndexTuple newitem)
{
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(BTPageGetOpaque(page));
ItemId itemid;
IndexTuple itup;
+ BTItemBuf buf;
itemid = PageGetItemId(page, minoff);
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &buf);
if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts)
{
itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page));
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &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..409455ac7f2 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -348,8 +348,8 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate)
P_ISLEAF(opaque) &&
!P_IGNORE(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
@@ -1135,6 +1135,11 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple codeditup; /* itup coded for the page, or itup */
+ Size codeditemsz; /* its MAXALIGNed size */
+ IndexTuple codednposting = NULL; /* nposting coded for the page */
+ BTItemBuf opostbuf; /* decode workspace for an existing posting */
+ bool pageprefix; /* target page is BTP_PREFIX */
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1143,6 +1148,7 @@ _bt_insertonpg(Relation rel,
isroot = P_ISROOT(opaque);
isrightmost = P_RIGHTMOST(opaque);
isonly = P_LEFTMOST(opaque) && P_RIGHTMOST(opaque);
+ pageprefix = P_HASPREFIX(opaque);
/* child buffer must be given iff inserting on an internal page */
Assert(isleaf == !BufferIsValid(cbuf));
@@ -1182,6 +1188,9 @@ _bt_insertonpg(Relation rel,
*/
Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage);
oposting = (IndexTuple) PageGetItem(page, itemid);
+ /* On a prefix page the existing posting is coded; decode for the swap. */
+ if (pageprefix)
+ oposting = _bt_prefix_decode(page, oposting, &opostbuf);
/*
* postingoff value comes from earlier call to _bt_binsrch_posting().
@@ -1213,19 +1222,50 @@ _bt_insertonpg(Relation rel,
newitemoff = OffsetNumberNext(newitemoff);
}
+ /*
+ * On a prefix-compressed page the new item is stored coded, which is at
+ * most MAXALIGN larger than plain and usually much smaller. Size the fit
+ * test and the placement by the coded size. (Split is handled on plain
+ * tuples; _bt_split re-compresses each half itself.)
+ */
+ if (pageprefix)
+ {
+ codeditup = _bt_prefix_encode(page, itup);
+ codeditemsz = MAXALIGN(IndexTupleSize(codeditup));
+ /*
+ * A posting-list split replaces the existing coded posting with a
+ * re-coded one; encode it here, before the critical section, since
+ * _bt_prefix_encode() allocates.
+ */
+ if (nposting != NULL)
+ codednposting = _bt_prefix_encode(page, nposting);
+ }
+ else
+ {
+ codeditup = itup;
+ codeditemsz = itemsz;
+ }
+
/*
* Do we need to split the page to fit the item on it?
*
* 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.
+ * only for the item and not for its line pointer. The coded item size is
+ * used; a posting-list split additionally changes the posting tuple's
+ * size, but that can only shrink or keep free space (the new posting has
+ * one fewer TID's worth of room reused by newitem), so the item fit test
+ * remains a safe upper bound.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < codeditemsz)
{
Buffer rbuf;
Assert(!split_only_page);
+ if (codeditup != itup)
+ pfree(codeditup);
+
/* split the buffer into left and right halves */
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
@@ -1295,9 +1335,25 @@ _bt_insertonpg(Relation rel,
START_CRIT_SECTION();
if (postingoff != 0)
- memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ {
+ if (pageprefix)
+ {
+ /*
+ * The existing posting is stored coded; replace its slot (at
+ * the offset just before newitemoff, since newitemoff was
+ * advanced past it above) with the coded new posting (encoded
+ * before the critical section), whose size may differ.
+ */
+ if (!PageIndexTupleOverwrite(page, OffsetNumberPrev(newitemoff),
+ codednposting, IndexTupleSize(codednposting)))
+ elog(PANIC, "failed to overwrite posting list in block %u in index \"%s\"",
+ BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+ }
+ else
+ memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ }
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, codeditup, codeditemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1381,8 +1437,30 @@ _bt_insertonpg(Relation rel,
}
}
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- if (postingoff == 0)
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ ((pageprefix && postingoff != 0) ? REGBUF_FORCE_IMAGE : 0));
+ if (pageprefix && postingoff != 0)
+ {
+ /*
+ * A posting-list split on a prefix page rewrites an existing
+ * coded posting and adds a coded item; rather than teach redo to
+ * re-code both, force a full-page image (this is a rare case).
+ * postingoff is still recorded for the record format.
+ */
+ upostingoff = postingoff;
+ XLogRegisterBufData(0, &upostingoff, sizeof(uint16));
+ }
+ else if (pageprefix)
+ {
+ /*
+ * Simple insert on a prefix page: log the CODED item bytes as
+ * they were placed on the page, so redo adds them verbatim at
+ * newitemoff -- no full-page image needed, and byte-identical.
+ */
+ XLogRegisterBufData(0, (char *) codeditup, IndexTupleSize(codeditup));
+ }
+ else if (postingoff == 0)
{
/* Just log itup from caller */
XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -1418,6 +1496,11 @@ _bt_insertonpg(Relation rel,
END_CRIT_SECTION();
+ if (codeditup != itup)
+ pfree(codeditup);
+ if (codednposting != NULL && codednposting != nposting)
+ pfree(codednposting);
+
/* Release subsidiary buffers */
if (BufferIsValid(metabuf))
_bt_relbuf(rel, metabuf);
@@ -1506,6 +1589,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
ItemId itemid;
IndexTuple firstright,
lefthighkey;
+ BTItemBuf frbuf; /* decode scratch for firstright */
+ BTItemBuf llbuf; /* decode scratch for lastleft */
+ IndexTuple *leftitems = NULL; /* leaf: plain items for the left half */
+ IndexTuple *rightitems = NULL; /* leaf: plain items for the right half */
+ int nleft = 0;
+ int nright = 0;
+ IndexTuple righthighkey = NULL; /* leaf right half's high key (NULL=rightmost) */
+ const char *origprefix = NULL;
+ Size origprefixlen = 0;
+ char leftprefix[BTREE_PREFIX_MAX]; /* chosen prefix of each leaf half */
+ char rightprefix[BTREE_PREFIX_MAX];
+ Size leftprefixlen = 0;
+ Size rightprefixlen = 0;
+ char *rightwalbuf = NULL; /* packed right-page WAL payload */
+ Size rightwalbuflen = 0;
OffsetNumber firstrightoff;
OffsetNumber afterleftoff,
afterrightoff,
@@ -1575,7 +1673,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
lopaque->btpo_flags = oopaque->btpo_flags;
- lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
/* set flag in leftpage indicating that rightpage has no downlink yet */
lopaque->btpo_flags |= BTP_INCOMPLETE_SPLIT;
lopaque->btpo_prev = oopaque->btpo_prev;
@@ -1650,6 +1748,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
itemid = PageGetItemId(origpage, firstrightoff);
itemsz = ItemIdGetLength(itemid);
firstright = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ firstright = _bt_prefix_decode(origpage, firstright, &frbuf);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
}
@@ -1673,6 +1773,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque));
itemid = PageGetItemId(origpage, lastleftoff);
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ lastleft = _bt_prefix_decode(origpage, lastleft, &llbuf);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
}
@@ -1712,7 +1814,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
/*
- * Add new high key to leftpage
+ * Add new high key to leftpage. For a leaf split the left half is built
+ * as a whole below (so it can be prefix-compressed from the start, which
+ * a plain incremental build cannot represent when the page holds more
+ * logical data than fits plain); the high key is passed to the builder
+ * then. Internal halves are built incrementally here as before.
*/
afterleftoff = P_HIKEY;
@@ -1720,11 +1826,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(BTreeTupleGetNAtts(lefthighkey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(itemsz == MAXALIGN(IndexTupleSize(lefthighkey)));
- if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
- elog(ERROR, "failed to add high key to the left sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
- afterleftoff = OffsetNumberNext(afterleftoff);
+ if (isleaf)
+ {
+ /* The builder needs a stable copy; lefthighkey points into workspace. */
+ lefthighkey = CopyIndexTuple(lefthighkey);
+ leftitems = palloc_array(IndexTuple, maxoff + 2);
+ rightitems = palloc_array(IndexTuple, maxoff + 2);
+ if (P_HASPREFIX(oopaque))
+ origprefix = _bt_page_get_prefix(origpage, &origprefixlen);
+ }
+ else
+ {
+ if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the left sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterleftoff = OffsetNumberNext(afterleftoff);
+ }
/*
* Acquire a new right page to split into, now that left page has a new
@@ -1765,7 +1883,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
ropaque->btpo_flags = oopaque->btpo_flags;
- ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = oopaque->btpo_next;
ropaque->btpo_level = oopaque->btpo_level;
@@ -1782,21 +1900,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (!isrightmost)
{
- IndexTuple righthighkey;
+ ItemId hiitemid = PageGetItemId(origpage, P_HIKEY);
+ Size hiitemsz = ItemIdGetLength(hiitemid);
+ IndexTuple hikey = (IndexTuple) PageGetItem(origpage, hiitemid);
- itemid = PageGetItemId(origpage, P_HIKEY);
- itemsz = ItemIdGetLength(itemid);
- righthighkey = (IndexTuple) PageGetItem(origpage, itemid);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) > 0);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) <=
+ Assert(BTreeTupleGetNAtts(hikey, rel) > 0);
+ Assert(BTreeTupleGetNAtts(hikey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
- if (PageAddItem(rightpage, righthighkey, itemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ if (isleaf)
+ righthighkey = CopyIndexTuple(hikey); /* stable: origpage is
+ * overwritten by the left
+ * half before WAL logging */
+ else
{
- elog(ERROR, "failed to add high key to the right sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
+ if (PageAddItem(rightpage, hikey, hiitemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the right sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterrightoff = OffsetNumberNext(afterrightoff);
}
- afterrightoff = OffsetNumberNext(afterrightoff);
}
/*
@@ -1817,11 +1939,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
{
IndexTuple dataitem;
+ BTItemBuf dibuf;
itemid = PageGetItemId(origpage, i);
itemsz = ItemIdGetLength(itemid);
dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ /*
+ * On a prefix-compressed origpage decode each data item to its plain
+ * form before placing it; the halves are built plain here and each is
+ * re-compressed as a whole below (Strategy 1). itemsz follows.
+ */
+ if (isleaf && P_HASPREFIX(oopaque))
+ {
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+ itemsz = MAXALIGN(IndexTupleSize(dataitem));
+ }
+
/* replace original item with nposting due to posting split? */
if (i == origpagepostingoff)
{
@@ -1836,25 +1970,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (newitemonleft)
{
Assert(newitemoff <= firstrightoff);
- if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
- false))
- {
+ if (isleaf)
+ leftitems[nleft++] = newitem;
+ else if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
+ false))
elog(ERROR, "failed to add new item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
Assert(newitemoff >= firstrightoff);
- if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ if (isleaf)
+ rightitems[nright++] = newitem;
+ else if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1862,23 +1996,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
/* decide which page to put it on */
if (i < firstrightoff)
{
- if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
- {
+ if (isleaf)
+ leftitems[nleft++] = CopyIndexTuple(dataitem);
+ else if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
elog(ERROR, "failed to add old item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
- if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ if (isleaf)
+ rightitems[nright++] = CopyIndexTuple(dataitem);
+ else if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add old item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1892,16 +2026,85 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* not be splitting the page).
*/
Assert(!newitemonleft && newitemoff == maxoff + 1);
- if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ if (isleaf)
+ rightitems[nright++] = newitem;
+ else if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
+ /*
+ * Leaf halves are built as whole pages now, so they can be prefix-
+ * compressed from the start (Strategy 1: a page is faulted into the
+ * BTP_PREFIX format only when it is written whole). Both halves of a
+ * prefix split are restored from full-page images in redo (their coded
+ * physical layout would not be reproduced by item replay): the left half
+ * (block 0) is FPI-logged whenever the original page was prefix-compressed
+ * and redo detects the image via BLK_RESTORED, so it needs no record flag;
+ * the right half (block 1) records XLH_SPLIT_RPREFIX so redo restores it
+ * from its forced image rather than rebuilding it from logged tuples.
+ * dataitem copies and the lefthighkey copy are freed via the short-lived
+ * insert context.
+ */
+ if (isleaf)
+ {
+ _bt_split_leaf_half(rel, leftpage, lefthighkey, leftitems, nleft,
+ origprefix, origprefixlen,
+ leftprefix, &leftprefixlen);
+ _bt_split_leaf_half(rel, rightpage, righthighkey, rightitems, nright,
+ origprefix, origprefixlen,
+ rightprefix, &rightprefixlen);
+
+ /*
+ * Build the right page's WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the right prefix bytes, then the
+ * right high key (unless rightmost), then the right half's plain items,
+ * each MAXALIGN'd, in one contiguous buffer logged with a single rdata
+ * slot. A very compressible right half whose plain items would exceed
+ * a page is instead carried as a full-page image (set below), so this
+ * buffer is bounded.
+ */
+ if (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0)
+ {
+ Size roff = rightprefixlen;
+
+ if (righthighkey != NULL)
+ roff += MAXALIGN(IndexTupleSize(righthighkey));
+ for (int ri = 0; ri < nright; ri++)
+ roff += MAXALIGN(IndexTupleSize(rightitems[ri]));
+ rightwalbuf = palloc(Max(roff, 1));
+ rightwalbuflen = roff;
+
+ roff = 0;
+ if (rightprefixlen > 0)
+ {
+ memcpy(rightwalbuf, rightprefix, rightprefixlen);
+ roff += rightprefixlen;
+ }
+ if (righthighkey != NULL)
+ {
+ Size hsz = IndexTupleSize(righthighkey);
+
+ memcpy(rightwalbuf + roff, righthighkey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(rightwalbuf + roff + hsz, 0, MAXALIGN(hsz) - hsz);
+ roff += MAXALIGN(hsz);
+ }
+ for (int ri = 0; ri < nright; ri++)
+ {
+ Size rsz = IndexTupleSize(rightitems[ri]);
+
+ memcpy(rightwalbuf + roff, rightitems[ri], rsz);
+ if (MAXALIGN(rsz) != rsz)
+ memset(rightwalbuf + roff + rsz, 0, MAXALIGN(rsz) - rsz);
+ roff += MAXALIGN(rsz);
+ }
+ }
+ }
+
/*
* We have to grab the original right sibling (if any) and update its prev
* link. We are guaranteed that this is deadlock-free, since we couple
@@ -2005,17 +2208,71 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /*
+ * A leaf split that involves prefix compression (either half ends up
+ * compressed, or the pre-split page was compressed so its items are
+ * coded) is replayed by rebuilding both halves from the pre-split page,
+ * exactly as the primary did, rather than by replaying logged tuples --
+ * the coded physical layout would not match _bt_restore_page. No
+ * full-page image is needed; redo reconstructs the plain items from the
+ * original page (block 0) and lays each half out coded against the
+ * chosen prefix, whose length and bytes are carried in the record.
+ */
+ xlrec.flags = 0;
+ xlrec.leftprefixlen = 0;
+ xlrec.rightprefixlen = 0;
+ if (isleaf && (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0))
+ {
+ xlrec.flags |= XLH_SPLIT_LEAFPREFIX;
+ xlrec.leftprefixlen = (uint8) leftprefixlen;
+ xlrec.rightprefixlen = (uint8) rightprefixlen;
+
+ /*
+ * The right half is normally carried as its packed plain payload
+ * (redo lays it out coded), far cheaper than a full-page image.
+ * But a very compressible page can hold more plain data than fits
+ * in one page; if the payload would be that large, fall back to a
+ * forced full-page image of the coded right page (bounded at BLCKSZ).
+ */
+ if (rightwalbuflen > BLCKSZ)
+ xlrec.flags |= XLH_SPLIT_RFPI;
+ }
+
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
+ /*
+ * The right page is rebuilt from scratch in redo. Ordinarily that is
+ * from the logged tuple region (REGBUF_WILL_INIT). A prefix-leaf split
+ * instead rebuilds it from its logged plain items, unless those would
+ * overflow the record (XLH_SPLIT_RFPI), in which case the coded page is
+ * carried as a forced full-page image.
+ */
+ XLogRegisterBuffer(1, rbuf,
+ (xlrec.flags & XLH_SPLIT_RFPI) ?
+ (REGBUF_STANDARD | REGBUF_FORCE_IMAGE) : REGBUF_WILL_INIT);
/* Log original right sibling, since we've changed its prev-pointer */
if (!isrightmost)
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
if (!isleaf)
XLogRegisterBuffer(3, cbuf, REGBUF_STANDARD);
+ /*
+ * For a prefix leaf split, carry each half's chosen prefix bytes, and
+ * (unless the right page is carried as a full-page image) the right
+ * high key and the right half's PLAIN items on block 1, so redo can lay
+ * the right page out coded without depending on block 0's state (block
+ * 0 may be restored from an automatic full-page image). The left half
+ * is rebuilt in redo from the original page (block 0) when it needs
+ * redo, or from its own FPI otherwise, so the left items are not logged.
+ */
+ if (xlrec.flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ if (!(xlrec.flags & XLH_SPLIT_RFPI) && rightwalbuflen > 0)
+ XLogRegisterBufData(1, rightwalbuf, rightwalbuflen);
+ }
+
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
@@ -2063,6 +2320,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
XLogRegisterBufData(0, lefthighkey,
MAXALIGN(IndexTupleSize(lefthighkey)));
+ /*
+ * For a prefix leaf split, the left half's chosen prefix bytes follow
+ * the high key on block 0 (redo reads newitem/orignewitem, then the
+ * high key, then this prefix).
+ */
+ if ((xlrec.flags & XLH_SPLIT_LEAFPREFIX) && leftprefixlen > 0)
+ XLogRegisterBufData(0, leftprefix, leftprefixlen);
+
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). The whole right page will be recreated.
@@ -2071,11 +2336,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the line pointers can be reconstructed. See comments for
- * _bt_restore_page().
+ * _bt_restore_page(). A prefix-compressed leaf split instead rebuilds
+ * the right page from the original page in redo (see XLH_SPLIT_LEAFPREFIX),
+ * so its tuple region is not logged.
*/
- XLogRegisterBufData(1,
- (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
- ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
+ if (!(xlrec.flags & XLH_SPLIT_LEAFPREFIX))
+ XLogRegisterBufData(1,
+ (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
+ ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
xlinfo = newitemonleft ? XLOG_BTREE_SPLIT_L : XLOG_BTREE_SPLIT_R;
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
@@ -2820,7 +3088,15 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
- /* Perform deduplication pass (when enabled and index-is-allequalimage) */
+ /*
+ * Perform deduplication pass (when enabled and index-is-allequalimage).
+ *
+ * On a prefix-compressed page the pass decodes items and lays the deduped
+ * result out plain before re-coding it; the decoded set can exceed one
+ * page even when the coded form fit, in which case the pass aborts without
+ * touching the page and we fall through to split it. When it does fit, the
+ * recompressed page's WAL replays without a full-page image.
+ */
if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
_bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz,
(indexUnchanged || uniquedup));
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 410514d8e10..706177f7992 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1212,11 +1212,26 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * If VACUUM emptied a prefix-compressed leaf of all its data items, drop
+ * the PREFIX item too and clear BTP_PREFIX: an empty page carries no
+ * prefix, and page deletion relies on an emptied leaf holding only its
+ * high key (nbtpage.c's _bt_mark_page_halfdead). Recorded in the vacuum
+ * record so redo does the same.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
/*
@@ -2245,6 +2260,20 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
opaque = BTPageGetOpaque(page);
opaque->btpo_flags |= BTP_HALF_DEAD;
+ /*
+ * A prefix-compressed leaf being deleted may still carry its PREFIX item
+ * (e.g. emptied by simple deletion, which does not drop it). Remove it
+ * and clear BTP_PREFIX so the page holds only its high key, as the
+ * half-dead transition below requires. Redo (btree_xlog_mark_page_halfdead)
+ * reinitializes the page, so it needs no separate handling.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
MemSet(&trunctuple, 0, sizeof(IndexTupleData));
trunctuple.t_info = sizeof(IndexTupleData);
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 00000000000..c725f75cd92
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,814 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Leaf key prefix compression for Postgres btrees.
+ *
+ * A leaf page of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page front-codes the first key column of each non-pivot tuple against a
+ * per-page PREFIX item, so that a long key that repeats on a page costs
+ * little more than its unshared tail. The on-disk format is documented at
+ * BTP_PREFIX in nbtree.h; this file is the encoder, decoder, and the policy
+ * that chooses a page's prefix.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo repeats them, and decoding reproduces the plain
+ * tuple byte for byte. A tuple inserted later that shares less of the prefix
+ * just stores a smaller `shared' count, so no insert, VACUUM, deduplication
+ * or change of the page's key range ever has to rewrite the page to keep the
+ * prefix valid. The prefix is chosen only when a page is written as a whole
+ * (CREATE INDEX, or a half of a split).
+ *
+ * This is modeled on the leaf prefix compression of the BARK access method
+ * (src/backend/access/bark/barkutils.c).
+ *
+ *
+ * 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 "storage/bufpage.h"
+#include "utils/rel.h"
+
+/*
+ * Where each part of a tuple goes in its coded form. Coding and decoding
+ * both lay the tuple out from this, so the two cannot disagree.
+ */
+typedef struct BTPrefixPlan
+{
+ Size hoff; /* data offset: header and null bitmap */
+ Size c1; /* plain first column's size, with header */
+ Size keyend; /* end of the plain key (posting offset) */
+ uint8 code; /* first payload byte of the coded column */
+ const char *suffix; /* bytes stored after it */
+ Size suffixlen;
+ Size vsize; /* coded column's size, with header */
+ Size codedkeyend; /* end of the coded key */
+ Size total; /* size of the coded tuple */
+} BTPrefixPlan;
+
+/*
+ * Is `itup' stored coded on a BTP_PREFIX page? Only a non-pivot tuple (plain
+ * or posting) whose first column is not NULL; a pivot (high key, downlink)
+ * is stored as it is.
+ */
+static bool
+_bt_prefix_codable(IndexTuple itup)
+{
+ if (BTreeTupleIsPivot(itup))
+ return false;
+ return !IndexTupleHasNulls(itup) ||
+ !att_isnull(0, (uint8 *) itup + sizeof(IndexTupleData));
+}
+
+/*
+ * Does the varlena `val' have the header decoding would give it back: the
+ * 1-byte header if its data fit one, else an uncompressed 4-byte header?
+ */
+static bool
+_bt_prefix_canonical(const char *val)
+{
+ if (VARATT_IS_SHORT(val))
+ return !VARATT_IS_EXTERNAL(val);
+ return !VARATT_IS_EXTENDED(val) &&
+ VARSIZE(val) - VARHDRSZ + VARHDRSZ_SHORT > VARATT_SHORT_MAX;
+}
+
+/*
+ * End of a leaf tuple's key: the posting-list offset for a posting tuple, the
+ * whole tuple size otherwise. The bytes after the key (a posting list) are
+ * moved as they are.
+ */
+static Size
+_bt_prefix_keyend(IndexTuple itup)
+{
+ if (BTreeTupleIsPosting(itup))
+ return BTreeTupleGetPostingOffset(itup);
+ return IndexTupleSize(itup);
+}
+
+/*
+ * The size of a coded first column whose payload is `payload' bytes: a
+ * varlena with a 1-byte header when it fits one.
+ */
+static inline Size
+_bt_prefix_vsize(Size payload)
+{
+ if (payload + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ return payload + VARHDRSZ_SHORT;
+ return payload + VARHDRSZ;
+}
+
+/*
+ * Offset at which the bytes after the first column go in a coded tuple whose
+ * coded column ends at `vend', when in the plain tuple they start at
+ * `restorig': the first offset at or after `vend' that is the same distance
+ * past a MAXALIGN boundary.
+ */
+static inline Size
+_bt_prefix_restoff(Size vend, Size restorig)
+{
+ return vend + (MAXIMUM_ALIGNOF + restorig % MAXIMUM_ALIGNOF -
+ vend % MAXIMUM_ALIGNOF) % MAXIMUM_ALIGNOF;
+}
+
+/* Plan the coding of the codable tuple `itup' against `prefix'. */
+static void
+_bt_prefix_plan(const char *prefix, Size prefixlen, IndexTuple itup,
+ BTPrefixPlan *plan)
+{
+ const char *val;
+ Size restorig;
+ bool storerest;
+
+ plan->hoff = IndexInfoFindDataOffset(itup->t_info);
+ val = (const char *) itup + plan->hoff;
+ plan->c1 = VARSIZE_ANY(val);
+ plan->keyend = _bt_prefix_keyend(itup);
+
+ if (_bt_prefix_canonical(val))
+ {
+ const char *data = VARDATA_ANY(val);
+ Size datalen = VARSIZE_ANY_EXHDR(val);
+ Size limit = Min(prefixlen, datalen);
+ Size shared = 0;
+
+ while (shared < limit && prefix[shared] == data[shared])
+ shared++;
+ plan->code = (uint8) shared;
+ plan->suffix = data + shared;
+ plan->suffixlen = datalen - shared;
+ }
+ else
+ {
+ plan->code = BTREE_PREFIX_RAW;
+ plan->suffix = val;
+ plan->suffixlen = plan->c1;
+ }
+ plan->vsize = _bt_prefix_vsize(1 + plan->suffixlen);
+
+ /*
+ * The bytes after the first column can be left out when they are just the
+ * zero padding that ends the key.
+ */
+ restorig = plan->hoff + plan->c1;
+ storerest = plan->keyend != MAXALIGN(restorig);
+ for (Size i = restorig; !storerest && i < plan->keyend; i++)
+ storerest = ((const char *) itup)[i] != 0;
+
+ if (storerest)
+ {
+ plan->code |= BTREE_PREFIX_REST;
+ plan->codedkeyend = _bt_prefix_restoff(plan->hoff + plan->vsize,
+ restorig) +
+ (plan->keyend - restorig);
+ }
+ else
+ plan->codedkeyend = MAXALIGN(plan->hoff + plan->vsize);
+ plan->total = plan->codedkeyend + (IndexTupleSize(itup) - plan->keyend);
+}
+
+/*
+ * Copy n bytes, as memcpy. Decoding moves a few short runs of bytes per
+ * tuple; eight bytes at a time is several times faster than a libc memcpy
+ * for runs this short (the string-move startup cost dominates otherwise).
+ */
+static inline void
+_bt_prefix_copy(char *dst, const char *src, Size n)
+{
+ while (n >= sizeof(uint64))
+ {
+ memcpy(dst, src, sizeof(uint64));
+ dst += sizeof(uint64);
+ src += sizeof(uint64);
+ n -= sizeof(uint64);
+ }
+ while (n-- > 0)
+ *dst++ = *src++;
+}
+
+/* The prefix of a BTP_PREFIX page, and its length in *len. */
+const char *
+_bt_page_get_prefix(Page page, Size *len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ IndexTuple item;
+
+ Assert(P_HASPREFIX(opaque));
+ item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page,
+ BTPageGetPrefixOff(opaque)));
+ *len = IndexTupleSize(item) - sizeof(IndexTupleData);
+ return (const char *) item + sizeof(IndexTupleData);
+}
+
+/*
+ * Give `page', a leaf holding nothing yet but its high key (if it has one),
+ * the prefix `prefix' of `len' bytes: add the PREFIX item and set BTP_PREFIX.
+ * Its padding comes from the page's free space, which _bt_pageinit zeroed, so
+ * a page rebuilt in redo is the same.
+ */
+void
+_bt_page_set_prefix(Page page, const char *prefix, Size len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ union
+ {
+ IndexTupleData hdr;
+ char data[sizeof(IndexTupleData) + BTREE_PREFIX_MAX];
+ } item;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+ Assert(len >= 1 && len <= BTREE_PREFIX_MAX);
+ Assert(PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque) - 1);
+
+ memset(&item.hdr, 0, sizeof(IndexTupleData));
+ item.hdr.t_info = (unsigned short) (sizeof(IndexTupleData) + len);
+ memcpy(item.data + sizeof(IndexTupleData), prefix, len);
+ if (PageAddItem(page, item.data, sizeof(IndexTupleData) + len,
+ BTPageGetPrefixOff(opaque), false, false) ==
+ InvalidOffsetNumber)
+ elog(ERROR, "failed to add prefix item to btree page");
+ opaque->btpo_flags |= BTP_PREFIX;
+}
+
+/*
+ * The coded form of the leaf tuple `itup' for `page', palloc'd; `itup' itself
+ * when the page has no prefix or the tuple is stored plain.
+ */
+IndexTuple
+_bt_prefix_encode(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+ char *coded;
+ char *v;
+ char *payload;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ Assert(plan.total <= INDEX_SIZE_MASK);
+
+ coded = palloc0(plan.total);
+ memcpy(coded, itup, plan.hoff);
+ v = coded + plan.hoff;
+ if (plan.vsize <= VARATT_SHORT_MAX)
+ {
+ SET_VARSIZE_SHORT(v, plan.vsize);
+ payload = v + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ SET_VARSIZE(v, plan.vsize);
+ payload = v + VARHDRSZ;
+ }
+ payload[0] = (char) plan.code;
+ memcpy(payload + 1, plan.suffix, plan.suffixlen);
+ if (plan.code & BTREE_PREFIX_REST)
+ {
+ Size restorig = plan.hoff + plan.c1;
+
+ memcpy(coded + _bt_prefix_restoff(plan.hoff + plan.vsize, restorig),
+ (char *) itup + restorig, plan.keyend - restorig);
+ }
+ memcpy(coded + plan.codedkeyend, (char *) itup + plan.keyend,
+ IndexTupleSize(itup) - plan.keyend);
+
+ ((IndexTuple) coded)->t_info =
+ (itup->t_info & ~INDEX_SIZE_MASK) | (unsigned short) plan.total;
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) coded, BTreeTupleGetNPosting(itup),
+ plan.codedkeyend);
+
+#ifdef USE_ASSERT_CHECKING
+ {
+ BTItemBuf check;
+ IndexTuple decoded = _bt_prefix_decode(page, (IndexTuple) coded,
+ &check);
+
+ Assert(IndexTupleSize(decoded) == IndexTupleSize(itup) &&
+ memcmp(decoded, itup, IndexTupleSize(itup)) == 0);
+ }
+#endif
+
+ return (IndexTuple) coded;
+}
+
+/* The MAXALIGNed size `itup' takes on `page', coded if the page codes it. */
+Size
+_bt_prefix_coded_size(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return MAXALIGN(IndexTupleSize(itup));
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ return MAXALIGN(plan.total);
+}
+
+/*
+ * Decode the item `itup' of the BTP_PREFIX page `page' into `buf', and return
+ * it; an item stored plain is returned as it is. The inverse of
+ * _bt_prefix_encode, byte for byte. An item that does not decode within its
+ * own bounds, or claims more of the prefix than the page has, is reported as
+ * corruption.
+ */
+IndexTuple
+_bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf)
+{
+ const char *prefix;
+ Size prefixlen;
+ Size size = IndexTupleSize(itup);
+ Size hoff;
+ Size codedkeyend;
+ const char *v;
+ Size vsize = 0;
+ const char *payload;
+ Size paylen = 0;
+ uint8 code;
+ Size shared;
+ char *out = buf->data;
+ char *val = NULL;
+ Size c1;
+ Size restorig;
+ Size keyend;
+
+ if (!_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ hoff = IndexInfoFindDataOffset(itup->t_info);
+ codedkeyend = _bt_prefix_keyend(itup);
+ v = (const char *) itup + hoff;
+ if (hoff >= codedkeyend || codedkeyend > size ||
+ (vsize = VARSIZE_ANY(v)) > codedkeyend - hoff ||
+ (paylen = VARSIZE_ANY_EXHDR(v)) < 1)
+ goto corrupt;
+ payload = VARDATA_ANY(v);
+ code = (uint8) payload[0];
+ shared = code & BTREE_PREFIX_SHARED_MASK;
+
+ _bt_prefix_copy(out, (const char *) itup, hoff);
+ val = out + hoff;
+ if (shared == BTREE_PREFIX_RAW)
+ {
+ c1 = paylen - 1;
+ _bt_prefix_copy(val, payload + 1, c1);
+ if (c1 < VARHDRSZ_SHORT || VARSIZE_ANY(val) != c1)
+ goto corrupt;
+ }
+ else
+ {
+ Size datalen = shared + paylen - 1;
+ char *data;
+
+ if (hoff + VARHDRSZ + datalen > sizeof(BTItemBuf))
+ goto corrupt;
+ if (shared > prefixlen)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple shares %zu bytes of a %zu-byte page prefix",
+ shared, prefixlen)));
+ if (datalen + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ {
+ c1 = datalen + VARHDRSZ_SHORT;
+ SET_VARSIZE_SHORT(val, c1);
+ data = val + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ c1 = datalen + VARHDRSZ;
+ SET_VARSIZE(val, c1);
+ data = val + VARHDRSZ;
+ }
+ _bt_prefix_copy(data, prefix, shared);
+ _bt_prefix_copy(data + shared, payload + 1, paylen - 1);
+ }
+
+ restorig = hoff + c1;
+ if (code & BTREE_PREFIX_REST)
+ {
+ Size restoff = _bt_prefix_restoff(hoff + vsize, restorig);
+
+ if (restoff > codedkeyend)
+ goto corrupt;
+ keyend = restorig + (codedkeyend - restoff);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ _bt_prefix_copy(out + restorig, (const char *) itup + restoff,
+ codedkeyend - restoff);
+ }
+ else
+ {
+ keyend = MAXALIGN(restorig);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ memset(out + restorig, 0, keyend - restorig);
+ }
+ _bt_prefix_copy(out + keyend, (const char *) itup + codedkeyend,
+ size - codedkeyend);
+
+ ((IndexTuple) out)->t_info = (itup->t_info & ~INDEX_SIZE_MASK) |
+ (unsigned short) (keyend + (size - codedkeyend));
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) out, BTreeTupleGetNPosting(itup),
+ keyend);
+ return (IndexTuple) out;
+
+corrupt:
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple is not a valid prefix-coded tuple")));
+ return NULL; /* keep compiler quiet */
+}
+
+/*
+ * The bytes of `itup''s first column a page prefix may be taken from, and how
+ * many (at most BTREE_PREFIX_MAX) in *len; NULL when the tuple has none: it
+ * is not codable, its value is empty, or it would be stored raw.
+ */
+const char *
+_bt_prefix_candidate(IndexTuple itup, Size *len)
+{
+ const char *val;
+
+ if (!_bt_prefix_codable(itup))
+ return NULL;
+ val = (const char *) itup + IndexInfoFindDataOffset(itup->t_info);
+ if (!_bt_prefix_canonical(val) || VARSIZE_ANY_EXHDR(val) == 0)
+ return NULL;
+ *len = Min(VARSIZE_ANY_EXHDR(val), BTREE_PREFIX_MAX);
+ return VARDATA_ANY(val);
+}
+
+/*
+ * How many bytes of `prefix' the first column of `itup' would share on a page
+ * with that prefix: -1 when the tuple would not be coded, 0 when it would be
+ * stored raw.
+ */
+int
+_bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup)
+{
+ BTPrefixPlan plan;
+
+ if (!_bt_prefix_codable(itup))
+ return -1;
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ if ((plan.code & BTREE_PREFIX_SHARED_MASK) == BTREE_PREFIX_RAW)
+ return 0;
+ return plan.code & BTREE_PREFIX_SHARED_MASK;
+}
+
+/*
+ * May the leaves of `index' have a prefix? Only when the prefix_compression
+ * reloption is on and the first key column is of a varlena type.
+ */
+bool
+_bt_prefix_enabled(Relation index)
+{
+ return BTGetPrefixCompression(index) &&
+ TupleDescAttr(RelationGetDescr(index), 0)->attlen == -1;
+}
+
+/*
+ * Choose the prefix for a leaf about to be built from the `n' tuples `items',
+ * in key order: the first tuple's first-column bytes, cut to the longest run
+ * any later tuple shares, provided the tuples share at least
+ * BTREE_PREFIX_MIN_SHARED bytes of it on average. Returns the prefix's
+ * length, with *prefix pointing into items[0], or 0 for no prefix.
+ */
+Size
+_bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix)
+{
+ const char *cand;
+ Size candlen;
+ Size longest = 0;
+ Size total = 0;
+ int ncoded = 0;
+
+ if (!_bt_prefix_enabled(index) ||
+ (cand = _bt_prefix_candidate(items[0], &candlen)) == NULL)
+ return 0;
+ if (n == 1)
+ {
+ *prefix = cand;
+ return candlen;
+ }
+ for (int i = 1; i < n; i++)
+ {
+ int shared = _bt_prefix_shared(cand, candlen, items[i]);
+
+ if (shared < 0)
+ continue;
+ total += shared;
+ ncoded++;
+ longest = Max(longest, (Size) shared);
+ }
+ if (longest == 0 || total < (Size) BTREE_PREFIX_MIN_SHARED * ncoded)
+ return 0;
+ *prefix = cand;
+ return longest;
+}
+
+/*
+ * Fault a freshly finished leaf `page' forward into the BTP_PREFIX format if
+ * a prefix pays off (Strategy 1: compress a page only when it is written as a
+ * whole -- CREATE INDEX page finish, a split half, a dedup rewrite -- so that
+ * no insert, VACUUM or key-range change ever forces a page rewrite, and an
+ * old-format page is converted only when it is next written whole).
+ *
+ * On entry `page' is a complete leaf: its high key at P_HIKEY if it is not
+ * rightmost, then its data items, none of them coded (the page does not yet
+ * have BTP_PREFIX). If the data items share enough of a common first-column
+ * prefix, the page is rebuilt in place with the PREFIX item inserted after
+ * the high key and every data item coded against it; otherwise the page is
+ * left unchanged. Item order and the high key are preserved.
+ *
+ * Caller must hold the page's buffer exclusively (or own the page outright,
+ * as the build and split paths do with a temp page). No WAL is written here:
+ * callers log the resulting page (a build writes it whole; a split logs each
+ * half whole or by full-page image), and redo reaches the same bytes by
+ * running _bt_prefix_encode against the same prefix.
+ */
+void
+_bt_prefix_compress_page(Relation index, Page page)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber minoff;
+ OffsetNumber maxoff;
+ int n;
+ IndexTuple *items;
+ Size prefixlen;
+ const char *prefix;
+ Page tmp;
+ OffsetNumber o;
+ IndexTuple hikey = NULL;
+ Size hikeysz = 0;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+
+ if (!_bt_prefix_enabled(index))
+ return;
+
+ minoff = P_FIRSTDATAKEY(opaque);
+ maxoff = PageGetMaxOffsetNumber(page);
+ n = (int) maxoff - (int) minoff + 1;
+ if (n <= 0)
+ return;
+
+ items = (IndexTuple *) palloc(n * sizeof(IndexTuple));
+ for (int i = 0; i < n; i++)
+ items[i] = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, minoff + i));
+
+ prefixlen = _bt_prefix_choose(index, items, n, &prefix);
+ if (prefixlen == 0)
+ {
+ pfree(items);
+ return;
+ }
+
+ /* The high key (if any) stays at P_HIKEY, uncoded. */
+ if (!P_RIGHTMOST(opaque))
+ {
+ ItemId hii = PageGetItemId(page, P_HIKEY);
+
+ hikeysz = ItemIdGetLength(hii);
+ hikey = (IndexTuple) PageGetItem(page, hii);
+ }
+
+ /*
+ * Rebuild into a temp page: high key (if any), PREFIX item, then each
+ * data item coded. The coded form can be up to MAXALIGN larger than the
+ * plain one, but the PREFIX item replaces far more shared bytes than it
+ * adds, so a page that was not overfull stays within bounds; if a coded
+ * item somehow does not fit, leave the page uncompressed (always valid).
+ */
+ tmp = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(tmp)->btpo_flags &= ~BTP_PREFIX;
+ if (hikey != NULL &&
+ PageAddItem(tmp, (char *) hikey, hikeysz, P_HIKEY, false, false) ==
+ InvalidOffsetNumber)
+ goto giveup;
+ _bt_page_set_prefix(tmp, prefix, prefixlen);
+ o = OffsetNumberNext(PageGetMaxOffsetNumber(tmp));
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(tmp, items[i]);
+ bool ok;
+
+ ok = PageAddItem(tmp, (char *) coded, IndexTupleSize(coded), o++,
+ false, false) != InvalidOffsetNumber;
+ if (coded != items[i])
+ pfree(coded);
+ if (!ok)
+ goto giveup;
+ }
+
+ memcpy(page, tmp, BLCKSZ);
+ pfree(tmp);
+ pfree(items);
+ return;
+
+giveup:
+ pfree(tmp);
+ pfree(items);
+}
+
+/*
+ * Lay out one half of a leaf split on `page', which the caller has
+ * initialized (flags, links) and left empty: the high key `hikey' (NULL for
+ * a rightmost half), then the `n' plain tuples `items' in key order.
+ *
+ * The half is formed as a whole, so it may take a prefix of its own: its
+ * first item's leading bytes, if the items share enough (_bt_prefix_choose).
+ * Because a prefix-compressed leaf can hold more logical data than fits in a
+ * plain page, the half is laid out coded from the start rather than built
+ * plain and compressed afterwards. When the half does not fit with its own
+ * prefix (or shares too little to take one) it is laid out plain, and if that
+ * does not fit either, with `origprefix' (the splitting page's prefix, zero
+ * length when it had none), against which it fits by construction: the items
+ * keep the shares they had on the original page.
+ *
+ * Mirrors BARK's bark_split_leaf_half. Callers: _bt_split (both halves).
+ */
+void
+_bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen)
+{
+ BTPageOpaqueData saved = *BTPageGetOpaque(page);
+ const char *prefix[3];
+ Size prefixlen[3];
+ int ntries = 0;
+
+ Assert(P_ISLEAF(&saved) && !P_HASPREFIX(&saved));
+
+ /* Candidate prefixes to try, in order: own, none, the orig page's. */
+ if (_bt_prefix_enabled(index))
+ {
+ prefixlen[ntries] = _bt_prefix_choose(index, items, n, &prefix[ntries]);
+ if (prefixlen[ntries] > 0)
+ ntries++;
+ }
+ prefix[ntries] = NULL;
+ prefixlen[ntries++] = 0;
+ if (origprefixlen > 0)
+ {
+ prefix[ntries] = origprefix;
+ prefixlen[ntries++] = origprefixlen;
+ }
+
+ for (int t = 0; t < ntries; t++)
+ {
+ if (_bt_split_leaf_layout(page, hikey, items, n, prefix[t], prefixlen[t]))
+ {
+ /* Report the prefix that won, so the split can log it for redo. */
+ if (chosenprefixlen != NULL)
+ {
+ *chosenprefixlen = prefixlen[t];
+ if (prefixlen[t] > 0)
+ memcpy(chosenprefix, prefix[t], prefixlen[t]);
+ }
+ return;
+ }
+ /* restore the empty-page state for the next attempt */
+ _bt_pageinit(page, BLCKSZ);
+ *BTPageGetOpaque(page) = saved;
+ }
+ elog(ERROR, "failed to lay out a split half of index \"%s\"",
+ RelationGetRelationName(index));
+}
+
+/*
+ * Lay out one leaf split half on the caller-initialized empty `page' using a
+ * GIVEN prefix (`prefixlen' 0 for none): the high key `hikey' (NULL for a
+ * rightmost half), the PREFIX item, then the `n' plain tuples `items' coded
+ * against the prefix. Returns false if they do not fit (caller tries another
+ * prefix). This is Relation-free and deterministic in (hikey, items, prefix),
+ * so WAL redo calls it with the logged prefix to reproduce the primary's page
+ * byte for byte.
+ */
+bool
+_bt_split_leaf_layout(Page page, IndexTuple hikey, IndexTuple *items, int n,
+ const char *prefix, Size prefixlen)
+{
+ OffsetNumber o = P_HIKEY;
+ bool fit = true;
+
+ Assert(P_ISLEAF(BTPageGetOpaque(page)) && !P_HASPREFIX(BTPageGetOpaque(page)));
+ if (hikey != NULL &&
+ PageAddItem(page, (char *) hikey, MAXALIGN(IndexTupleSize(hikey)),
+ o++, false, false) == InvalidOffsetNumber)
+ return false;
+ if (prefixlen > 0)
+ {
+ _bt_page_set_prefix(page, prefix, prefixlen);
+ o++;
+ }
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(page, items[i]);
+
+ if (PageAddItem(page, (char *) coded, IndexTupleSize(coded),
+ o++, false, false) == InvalidOffsetNumber)
+ fit = false;
+ if (coded != items[i])
+ pfree(coded);
+ if (!fit)
+ return false;
+ }
+#ifdef USE_ASSERT_CHECKING
+ if (P_HASPREFIX(BTPageGetOpaque(page)))
+ {
+ OffsetNumber mx = PageGetMaxOffsetNumber(page);
+ OffsetNumber off;
+
+ for (off = P_FIRSTDATAKEY(BTPageGetOpaque(page)); off <= mx; off++)
+ {
+ BTItemBuf vb;
+
+ (void) _bt_getitem(page, off, &vb);
+ }
+ }
+#endif
+ return true;
+}
+
+/*
+ * Collect the plain (decoded) data items of a leaf split into the left and
+ * right arrays, in the exact order and with the exact substitutions that the
+ * split performs: the original page's data items split at `firstrightoff',
+ * with `newitem' inserted at `newitemoff' on the side given by `newitemonleft'
+ * and, for a posting-list split, the posting at `origpagepostingoff' replaced
+ * by `nposting'. `origpage' is the pre-split page (its items are decoded when
+ * it is a BTP_PREFIX page). The arrays receive pointers that are valid until
+ * the caller is done laying out the halves; items taken from the page are
+ * copied, so the caller owns and should reset the surrounding memory context.
+ *
+ * Both the primary (_bt_split) and WAL redo (btree_xlog_split) collect items
+ * this way and then lay out each half with _bt_split_leaf_half, so the two
+ * produce byte-identical coded pages without logging a full-page image.
+ */
+void
+_bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft, OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright)
+{
+ BTPageOpaque oopaque = BTPageGetOpaque(origpage);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(origpage);
+ bool hasprefix = P_HASPREFIX(oopaque);
+ OffsetNumber i;
+
+ *nleft = 0;
+ *nright = 0;
+ for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
+ {
+ ItemId itemid = PageGetItemId(origpage, i);
+ IndexTuple dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ BTItemBuf dibuf;
+
+ if (hasprefix)
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+
+ if (i == origpagepostingoff)
+ dataitem = nposting;
+ else if (i == newitemoff)
+ {
+ if (newitemonleft)
+ leftitems[(*nleft)++] = newitem;
+ else
+ rightitems[(*nright)++] = newitem;
+ }
+
+ if (i < firstrightoff)
+ leftitems[(*nleft)++] = CopyIndexTuple(dataitem);
+ else
+ rightitems[(*nright)++] = CopyIndexTuple(dataitem);
+ }
+ if (i <= newitemoff)
+ rightitems[(*nright)++] = newitem;
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..ffc5846e4c9 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -145,6 +145,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ BTItemBuf itupbuf; /* decode workspace for a loop's current item */
+ BTItemBuf finalbuf; /* decode workspace for a backward finaltup */
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -252,7 +254,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
continue;
}
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -353,9 +355,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,7 +415,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
else
tuple_alive = true;
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -596,9 +596,10 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
BTScanOpaque so = (BTScanOpaque) scan->opaque;
Relation rel = scan->indexRelation;
TupleDesc tupdesc = RelationGetDescr(rel);
- ItemId iid;
IndexTuple firsttup,
lasttup;
+ BTItemBuf firstbuf,
+ lastbuf;
int startikey = 0,
arrayidx = 0,
firstchangingattnum;
@@ -616,12 +617,10 @@ _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);
+ 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 +2052,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
ScanDirection dir = pstate->dir;
OffsetNumber aheadoffnum;
IndexTuple ahead;
+ BTItemBuf aheadbuf; /* decode workspace for the look-ahead tuple */
Assert(!pstate->forcenonrequired);
@@ -2088,8 +2088,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..b8b8ec018f3 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -435,8 +435,10 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ Size half = BTTupleStorageSize(scan->indexRelation);
+
+ so->currTuples = (char *) palloc(half * 2);
+ so->markTuples = so->currTuples + half;
}
/*
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..f932ca33c3a 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -696,6 +696,7 @@ _bt_compare(Relation rel,
TupleDesc itupdesc = RelationGetDescr(rel);
BTPageOpaque opaque = BTPageGetOpaque(page);
IndexTuple itup;
+ BTItemBuf itupbuf;
ItemPointer heapTid;
ScanKey scankey;
int ncmpkey;
@@ -713,7 +714,7 @@ _bt_compare(Relation rel,
if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque))
return 1;
- itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ itup = _bt_getitem(page, offnum, &itupbuf);
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dcf4..9a8b372033e 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -241,6 +241,10 @@ typedef struct BTPageState
Size btps_lastextra; /* last item's extra posting list space */
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
+ /* Leaf prefix compression: the candidate prefix of the page being filled */
+ const char *btps_prefix; /* into the page's first data item, or NULL */
+ Size btps_prefixlen; /* its length (0 = no candidate) */
+ Size btps_codedsaved; /* bytes the page's items would save coded */
struct BTPageState *btps_next; /* link to parent level, if any */
} BTPageState;
@@ -856,7 +860,8 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
*/
Assert(last_truncextra == 0 || isleaf);
if (pgspc < itupsz + (isleaf ? MAXALIGN(sizeof(ItemPointerData)) : 0) ||
- (pgspc + last_truncextra < state->btps_full && last_off > P_FIRSTKEY))
+ (pgspc + last_truncextra + state->btps_codedsaved < state->btps_full &&
+ last_off > P_FIRSTKEY))
{
/*
* Finish off the page and write it out.
@@ -980,6 +985,15 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
nopaque->btpo_next = P_NONE; /* redundant */
}
+ /*
+ * The old leaf page is complete: fault it forward into the
+ * BTP_PREFIX format if a prefix pays off. Done after btps_lowkey
+ * (the downlink, a copy of the uncoded high key) is saved, since
+ * compression rewrites opage in place.
+ */
+ if (isleaf)
+ _bt_prefix_compress_page(wstate->index, opage);
+
/*
* Write out the old page. _bt_blwritepage takes ownership of the
* 'opage' buffer.
@@ -990,6 +1004,11 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
* Reset last_off to point to new page
*/
last_off = P_FIRSTKEY;
+
+ /* The new page starts with no prefix candidate and no savings yet. */
+ state->btps_prefix = NULL;
+ state->btps_prefixlen = 0;
+ state->btps_codedsaved = 0;
}
/*
@@ -1017,6 +1036,34 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_sortaddtup(npage, itupsz, itup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Leaf prefix compression accounting (Strategy 1): the page's eventual
+ * prefix is its first data item's first-column bytes; estimate how much
+ * room coding this page's items would free, so the fillfactor test above
+ * can pack more plain items onto a page that will shrink when it is
+ * compressed at finish. Items are still placed plain here; the estimate
+ * only relaxes the soft limit, never the hard fit check.
+ */
+ if (isleaf && _bt_prefix_enabled(wstate->index))
+ {
+ if (state->btps_prefix == NULL)
+ state->btps_prefix = _bt_prefix_candidate(itup,
+ &state->btps_prefixlen);
+ else
+ {
+ int shared = _bt_prefix_shared(state->btps_prefix,
+ state->btps_prefixlen, itup);
+
+ /*
+ * A coded item drops `shared' value bytes but keeps a 1-byte
+ * share count; count the net MAXALIGN-rounded saving.
+ */
+ if (shared > 1)
+ state->btps_codedsaved +=
+ MAXALIGN(itupsz) - MAXALIGN(itupsz - (shared - 1));
+ }
+ }
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1115,6 +1162,11 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
* back one slot. Then we can dump out the page.
*/
_bt_slideleft((Page) s->btps_buf);
+
+ /* Fault the finished rightmost leaf forward into BTP_PREFIX if it pays. */
+ if (s->btps_level == 0)
+ _bt_prefix_compress_page(wstate->index, (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/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8b2..14bee568e45 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -52,6 +52,9 @@ typedef struct
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
+ bool hasprefix; /* origpage is prefix-compressed (BTP_PREFIX) */
+ BTItemBuf lbuf; /* decode scratch for _bt_split_lastleft */
+ BTItemBuf rbuf; /* decode scratch for _bt_split_firstright */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
@@ -172,6 +175,23 @@ _bt_findsplitloc(Relation rel,
olddataitemstotal = rightspace - (int) PageGetExactFreeSpace(origpage);
leaffillfactor = BTGetFillFactor(rel);
+ /*
+ * On a prefix-compressed leaf the page carries an extra PREFIX item that
+ * is not a data item. The split is reasoned about in CODED (on-page)
+ * bytes -- the physical reality of the page and of each recompressed half
+ * -- so subtract only the PREFIX item's space from olddataitemstotal and
+ * use the on-page (coded) item sizes in the scan below. (Plain-size
+ * accounting is not usable here: a compressed leaf can hold far more
+ * logical data than two plain pages, so a plain split can be infeasible.)
+ */
+ if (P_HASPREFIX(opaque))
+ {
+ ItemId pii = PageGetItemId(origpage, BTPageGetPrefixOff(opaque));
+
+ olddataitemstotal -= (int) (MAXALIGN(ItemIdGetLength(pii)) +
+ sizeof(ItemIdData));
+ }
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
state.rel = rel;
@@ -185,6 +205,7 @@ _bt_findsplitloc(Relation rel,
state.olddataitemstotal = olddataitemstotal;
state.minfirstrightsz = SIZE_MAX;
state.newitemoff = newitemoff;
+ state.hasprefix = P_HASPREFIX(opaque);
/* newitem cannot be a posting list item */
Assert(!BTreeTupleIsPosting(newitem));
@@ -463,6 +484,19 @@ _bt_recsplitloc(FindSplitData *state,
newitemisfirstright = (firstrightoff == state->newitemoff &&
!newitemonleft);
+ /*
+ * Reject a split that would leave the left page with no data items at all
+ * (firstrightoff at the first data key, with newitem not on the left).
+ * Upstream's split-point heuristics never choose such a point for a plain
+ * page; on a prefix-compressed page the coded-size accounting can make it
+ * look attractive, and _bt_split cannot form a left high key from a
+ * nonexistent lastleft.
+ */
+ if (state->hasprefix &&
+ firstrightoff == P_FIRSTDATAKEY(BTPageGetOpaque(state->origpage)) &&
+ !newitemonleft)
+ return;
+
if (newitemisfirstright)
firstrightsz = state->newitemsz;
else
@@ -686,8 +720,12 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
if (state->newitemoff > maxoff)
{
+ BTItemBuf tbuf;
+
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ if (state->hasprefix)
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
@@ -714,12 +752,20 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
+ /* Do cheaper test first (t_tid and the posting bit survive coding) */
if (BTreeTupleIsPosting(tup) ||
!_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
return false;
/* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ if (state->hasprefix)
+ {
+ BTItemBuf tbuf;
+
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ }
+ else
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -1160,12 +1206,38 @@ static inline IndexTuple
_bt_split_lastleft(FindSplitData *state, SplitPoint *split)
{
ItemId itemid;
+ OffsetNumber off;
if (split->newitemonleft && split->firstrightoff == state->newitemoff)
return state->newitem;
- itemid = PageGetItemId(state->origpage,
- OffsetNumberPrev(split->firstrightoff));
+ off = OffsetNumberPrev(split->firstrightoff);
+
+ /*
+ * On a prefix-compressed leaf the PREFIX item sits just before the first
+ * data item. When the split boundary is at the first data key, the data
+ * predecessor is the high key on a plain page (a pivot, read plain); a
+ * rightmost page has none, so fall back to the first data item (this only
+ * feeds penalty estimation, never placement). Never read the PREFIX item
+ * as a data tuple.
+ */
+ if (state->hasprefix)
+ {
+ BTPageOpaque op = BTPageGetOpaque(state->origpage);
+
+ if (off == BTPageGetPrefixOff(op))
+ {
+ if (!P_RIGHTMOST(op))
+ return (IndexTuple) PageGetItem(state->origpage,
+ PageGetItemId(state->origpage, P_HIKEY));
+ off = P_FIRSTDATAKEY(op);
+ }
+ itemid = PageGetItemId(state->origpage, off);
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->lbuf);
+ }
+ itemid = PageGetItemId(state->origpage, off);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
@@ -1181,5 +1253,9 @@ _bt_split_firstright(FindSplitData *state, SplitPoint *split)
return state->newitem;
itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ if (state->hasprefix)
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->rbuf);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
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..098c785d4d8 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -243,6 +243,209 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting,
_bt_restore_meta(record, 2);
}
+/*
+ * Replay a prefix-compressed leaf page split.
+ *
+ * Rather than replay logged tuples (whose coded physical layout
+ * _bt_restore_page could not reproduce), rebuild both halves the way the
+ * primary did: collect the split's plain items from the pre-split original
+ * page (block 0) with _bt_split_leaf_collect, then lay each half out coded
+ * against its chosen prefix (carried in the record) with _bt_split_leaf_layout.
+ * The result is byte-identical to the primary, so no full-page image is
+ * needed. The record's block-0 data carries, in order: newitem (when it went
+ * on the left, or for a posting split), the left high key, and the left half's
+ * prefix bytes; block-1 data carries the right half's prefix bytes.
+ */
+static void
+btree_xlog_split_prefix(XLogReaderState *record, BlockNumber origpagenumber,
+ BlockNumber rightpagenumber, BlockNumber spagenumber,
+ bool newitemonleft)
+{
+ XLogRecPtr lsn = record->EndRecPtr;
+ xl_btree_split *xlrec = (xl_btree_split *) XLogRecGetData(record);
+ Buffer rbuf;
+ Buffer origbuf;
+ Page origpage;
+ BTPageOpaque oopaque;
+ char *datapos;
+ Size datalen;
+ IndexTuple newitem = NULL;
+ IndexTuple left_hikey;
+ IndexTuple right_hikey = NULL;
+ IndexTuple nposting = NULL;
+ Size newitemsz = 0;
+ const char *leftprefix = NULL;
+ const char *rightprefix = NULL;
+ IndexTuple rightitems[MaxIndexTuplesPerPage];
+ int nright = 0;
+ Page rightpage;
+ BTPageOpaque ropaque;
+ XLogRedoAction origaction;
+
+ /*
+ * The right (new) page is rebuilt entirely from data logged with the
+ * record, independent of block 0, because block 0 may be restored from an
+ * automatic full-page image (so its live contents after redo are the left
+ * half, not the pre-split page). Block-1 data is, in order: the right
+ * half's chosen prefix bytes (rightprefixlen), the right high key when the
+ * split is not rightmost (spagenumber != P_NONE), then the right half's
+ * plain data items in offset order (self-describing by IndexTupleSize).
+ */
+ datapos = XLogRecGetBlockData(record, 1, &datalen);
+ if (xlrec->flags & XLH_SPLIT_RFPI)
+ {
+ /*
+ * The right half's plain items would have overflowed the WAL record,
+ * so the primary carried the coded right page as a forced full-page
+ * image. Restore it from that image.
+ */
+ if (XLogReadBufferForRedo(record, 1, &rbuf) != BLK_RESTORED)
+ elog(ERROR, "prefix leaf split right page lacks its full-page image");
+ }
+ else
+ {
+ if (xlrec->rightprefixlen > 0)
+ {
+ rightprefix = datapos;
+ datapos += xlrec->rightprefixlen;
+ datalen -= xlrec->rightprefixlen;
+ }
+ if (spagenumber != P_NONE)
+ {
+ right_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(right_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(right_hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ rightitems[nright++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+
+ rbuf = XLogInitBufferForRedo(record, 1);
+ rightpage = BufferGetPage(rbuf);
+ _bt_pageinit(rightpage, BufferGetPageSize(rbuf));
+ ropaque = BTPageGetOpaque(rightpage);
+ ropaque->btpo_prev = origpagenumber;
+ ropaque->btpo_next = spagenumber;
+ ropaque->btpo_level = xlrec->level;
+ ropaque->btpo_flags = BTP_LEAF;
+ ropaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(rightpage, right_hikey, rightitems, nright,
+ rightprefix, xlrec->rightprefixlen))
+ elog(ERROR, "failed to replay right half of a prefix leaf split");
+ PageSetLSN(rightpage, lsn);
+ }
+ MarkBufferDirty(rbuf);
+
+ /*
+ * Rebuild the left (original) page from the pre-split page, unless it was
+ * restored from a full-page image (then it is already the final left
+ * half). Block-0 data is: [newitem (left/posting)] [left high key]
+ * [left prefix]. The left plain items come from the pre-split page.
+ */
+ origaction = XLogReadBufferForRedo(record, 0, &origbuf);
+ origpage = BufferGetPage(origbuf);
+
+ if (origaction == BLK_NEEDS_REDO)
+ {
+ IndexTuple *leftitems;
+ int nleft;
+ OffsetNumber origpagepostingoff = InvalidOffsetNumber;
+ Page leftpage;
+ BTPageOpaque lopaque;
+
+ oopaque = BTPageGetOpaque(origpage);
+
+ datapos = XLogRecGetBlockData(record, 0, &datalen);
+ if (newitemonleft || xlrec->postingoff != 0)
+ {
+ newitem = (IndexTuple) datapos;
+ newitemsz = MAXALIGN(IndexTupleSize(newitem));
+ datapos += newitemsz;
+ datalen -= newitemsz;
+ }
+ left_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(left_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(left_hikey));
+ if (xlrec->leftprefixlen > 0)
+ {
+ leftprefix = datapos;
+ datapos += xlrec->leftprefixlen;
+ datalen -= xlrec->leftprefixlen;
+ }
+ Assert(datalen == 0);
+
+ if (xlrec->postingoff != 0)
+ {
+ ItemId itemid;
+ IndexTuple oposting;
+ BTItemBuf obuf;
+
+ origpagepostingoff = OffsetNumberPrev(xlrec->newitemoff);
+ newitem = CopyIndexTuple(newitem);
+ itemid = PageGetItemId(origpage, origpagepostingoff);
+ oposting = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ oposting = _bt_prefix_decode(origpage, oposting, &obuf);
+ nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff);
+ }
+
+ leftitems = palloc_array(IndexTuple, PageGetMaxOffsetNumber(origpage) + 2);
+ rightitems[0] = NULL; /* not reused below */
+ {
+ IndexTuple dummyright[MaxIndexTuplesPerPage];
+ int dummynr;
+
+ _bt_split_leaf_collect(origpage, xlrec->firstrightoff,
+ xlrec->newitemoff, newitem, newitemonleft,
+ origpagepostingoff, nposting,
+ leftitems, &nleft, dummyright, &dummynr);
+ }
+
+ leftpage = PageGetTempPageCopySpecial(origpage);
+ lopaque = BTPageGetOpaque(leftpage);
+ lopaque->btpo_flags = BTP_LEAF | BTP_INCOMPLETE_SPLIT;
+ lopaque->btpo_prev = oopaque->btpo_prev;
+ lopaque->btpo_next = rightpagenumber;
+ lopaque->btpo_level = xlrec->level;
+ lopaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(leftpage, left_hikey, leftitems, nleft,
+ leftprefix, xlrec->leftprefixlen))
+ elog(ERROR, "failed to replay left half of a prefix leaf split");
+ PageRestoreTempPage(leftpage, origpage);
+ PageSetLSN(origpage, lsn);
+ MarkBufferDirty(origbuf);
+ pfree(leftitems);
+ }
+
+ /* Fix left-link of the page to the right of the new right sibling. */
+ if (spagenumber != P_NONE)
+ {
+ Buffer sbuf;
+
+ if (XLogReadBufferForRedo(record, 2, &sbuf) == BLK_NEEDS_REDO)
+ {
+ Page spage = BufferGetPage(sbuf);
+ BTPageOpaque spageop = BTPageGetOpaque(spage);
+
+ spageop->btpo_prev = rightpagenumber;
+ PageSetLSN(spage, lsn);
+ MarkBufferDirty(sbuf);
+ }
+ if (BufferIsValid(sbuf))
+ UnlockReleaseBuffer(sbuf);
+ }
+
+ UnlockReleaseBuffer(rbuf);
+ if (BufferIsValid(origbuf))
+ UnlockReleaseBuffer(origbuf);
+}
+
static void
btree_xlog_split(bool newitemonleft, XLogReaderState *record)
{
@@ -264,6 +467,18 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
if (!XLogRecGetBlockTagExtended(record, 2, NULL, NULL, &spagenumber, NULL))
spagenumber = P_NONE;
+ /*
+ * A prefix-compressed leaf split is replayed by rebuilding both halves
+ * from the pre-split original page, exactly as the primary did, so the
+ * coded pages come out byte-identical without a full-page image.
+ */
+ if (xlrec->flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ btree_xlog_split_prefix(record, origpagenumber, rightpagenumber,
+ spagenumber, newitemonleft);
+ return;
+ }
+
/*
* Clear the incomplete split flag on the appropriate child page one level
* down when origpage/buf is an internal page (there must have been
@@ -280,7 +495,7 @@ 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 the logged tuples. */
rbuf = XLogInitBufferForRedo(record, 1);
datapos = XLogRecGetBlockData(record, 1, &datalen);
rpage = BufferGetPage(rbuf);
@@ -468,6 +683,54 @@ btree_xlog_dedup(XLogReaderState *record)
BTDedupState state;
BTDedupInterval *intervals;
Page newpage;
+ bool pageprefix; /* source page is BTP_PREFIX */
+ BTItemBuf curbuf; /* decode scratch for the current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
+
+ if (xlrec->prefixlen > 0)
+ {
+ /*
+ * Prefix-dedup: the deduped result was carried as the chosen prefix
+ * bytes, then (unless rightmost) the high key, then the deduped
+ * plain items. Rebuild the coded page byte-for-byte the way the
+ * primary did, using the same layout routine as a prefix leaf split.
+ */
+ Size datalen;
+ char *datapos = XLogRecGetBlockData(record, 0, &datalen);
+ const char *prefix = datapos;
+ IndexTuple hikey = NULL;
+ IndexTuple items[MaxIndexTuplesPerPage];
+ int nitems = 0;
+
+ datapos += xlrec->prefixlen;
+ datalen -= xlrec->prefixlen;
+ if (xlrec->flags & XLH_DEDUP_NOT_RIGHTMOST)
+ {
+ hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(hikey));
+ datalen -= MAXALIGN(IndexTupleSize(hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ items[nitems++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+
+ newpage = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(newpage)->btpo_flags &= ~(BTP_PREFIX | BTP_HAS_GARBAGE);
+ if (!_bt_split_leaf_layout(newpage, hikey, items, nitems,
+ prefix, xlrec->prefixlen))
+ elog(ERROR, "failed to replay prefix deduplication");
+ PageRestoreTempPage(newpage, page);
+ PageSetLSN(page, lsn);
+ MarkBufferDirty(buf);
+ UnlockReleaseBuffer(buf);
+ return;
+ }
state = palloc_object(BTDedupStateData);
state->deduplicate = true; /* unused */
@@ -487,6 +750,19 @@ btree_xlog_dedup(XLogReaderState *record)
maxoff = PageGetMaxOffsetNumber(page);
newpage = PageGetTempPageCopySpecial(page);
+ /*
+ * The source page may be prefix-compressed even on the ordinary
+ * (non-prefix) dedup path: that happens when the primary deduplicated
+ * a prefix page but re-compression of the result declined, leaving a
+ * plain page (prefixlen == 0, no full-page image). Reproduce the
+ * primary: lay the result out plain and decode each coded data item
+ * before feeding it to the dedup state machine. The high key is a
+ * pivot and is never coded.
+ */
+ pageprefix = P_HASPREFIX(opaque);
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
if (!P_RIGHTMOST(opaque))
{
ItemId itemid = PageGetItemId(page, P_HIKEY);
@@ -505,8 +781,18 @@ btree_xlog_dedup(XLogReaderState *record)
ItemId itemid = PageGetItemId(page, offnum);
IndexTuple itup = (IndexTuple) PageGetItem(page, itemid);
+ /* Decode on a prefix source so keys/posting lists are real. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
+ {
+ /* base must survive the whole run; keep it in its own buffer */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
+ }
else if (state->nintervals < xlrec->nintervals &&
state->baseoff == intervals[state->nintervals].baseoff &&
state->nitems < intervals[state->nintervals].nitems)
@@ -516,12 +802,15 @@ btree_xlog_dedup(XLogReaderState *record)
}
else
{
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
Assert(state->nintervals == xlrec->nintervals);
Assert(memcmp(state->intervals, intervals,
state->nintervals * sizeof(BTDedupInterval)) == 0);
@@ -621,11 +910,23 @@ btree_xlog_vacuum(XLogReaderState *record)
if (xlrec->ndeleted > 0)
PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * Mirror the primary: an emptied prefix-compressed leaf drops its
+ * PREFIX item and the BTP_PREFIX flag (see _bt_delitems_vacuum).
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* Clear the vacuum cycle ID, and mark the page as not containing any
* LP_DEAD items
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..ca7e6852542 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,61 @@ 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 format and code).
+ *
+ * A leaf of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page holds one extra item, the PREFIX item, at BTPageGetPrefixOff (where
+ * its first data item would otherwise be); its data items start one offset
+ * later, so P_FIRSTDATAKEY -- which every loop over a page's data items
+ * starts from -- accounts for the flag. The PREFIX item is an
+ * IndexTupleData header (t_tid zero, t_info its size and nothing else, so
+ * that code walking a page's items by IndexTupleSize, such as split redo,
+ * steps over it) followed by 1..BTREE_PREFIX_MAX prefix bytes. The prefix is
+ * chosen only when a page is written whole (CREATE INDEX, or a half of a
+ * split) and never changes after that; a page's high key, which fixes the
+ * prefix item's offset, also changes only in a split.
+ *
+ * On a BTP_PREFIX page, every non-pivot tuple (plain or posting) whose first
+ * key column is not NULL stores that column coded, as a varlena (1-byte
+ * header when it fits one, else 4-byte) whose payload starts with a uint8
+ * `shared':
+ *
+ * shared <= BTREE_PREFIX_MAX: the value's data bytes are the page prefix's
+ * first `shared' bytes followed by the rest of the payload.
+ * The value's own varlena header is recomputed on decode (the
+ * form index_form_tuple gives it: 1-byte when it fits, else
+ * 4-byte uncompressed).
+ * shared == BTREE_PREFIX_RAW: the rest of the payload is the value exactly
+ * as stored, header included. Used for a value compressed
+ * inline, or whose header is not the canonical form above.
+ *
+ * The bytes after the first column (later key columns, INCLUDE columns, the
+ * posting list, and alignment padding) are kept as they are, moved to keep
+ * their alignment, or left out when they are only the key's closing zero
+ * padding (decode puts them back). A posting list follows the key at the new
+ * key end, and the posting offset in t_tid is changed to match. Pivots, NULL
+ * first columns, and the high key are stored as on any other page.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo can repeat them, and decoding gives back the plain
+ * tuple byte for byte.
+ */
+#define BTREE_PREFIX_MAX 126
+#define BTREE_PREFIX_RAW 127 /* shared count of a raw value */
+#define BTREE_PREFIX_SHARED_MASK 0x7F /* shared count bits */
+#define BTREE_PREFIX_REST 0x80 /* bytes after the column follow */
+
+/* A leaf whose tuples share less of its prefix than this, on average, has none. */
+#define BTREE_PREFIX_MIN_SHARED 4
+
+#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 +732,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.
@@ -1122,6 +1208,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 +1224,33 @@ 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))
+
+/*
+ * Size of one half (currTuples or markTuples) of an index-only scan's tuple
+ * storage. A plain leaf's live non-pivot tuples fit in one page, so BLCKSZ
+ * would suffice for them. A prefix-compressed (BTP_PREFIX) leaf can hold more
+ * logical data than fits plain: when its tuples are decoded for the scan they
+ * can exceed BLCKSZ. Because _bt_getitem() decodes based on the page flag,
+ * not on the current reloption, and a BTP_PREFIX page can persist after
+ * prefix_compression is turned off (the flag is cleared only when a page is
+ * split, emptied or dedup-rewritten -- never by ALTER INDEX), ANY btree index
+ * may contain such a page. The buffer must therefore always reserve the slack
+ * for a decoded page, independent of the live reloption; sizing it on the
+ * option would overflow currTuples/markTuples on a surviving prefix leaf.
+ *
+ * The bound is the most distinct index tuples a leaf can hold (each at least a
+ * line pointer plus a minimally coded tuple) times the most a single first
+ * column can regain on decode (the page prefix, at most BTREE_PREFIX_MAX
+ * bytes, MAXALIGNed).
+ */
+#define BTTupleStorageSize(relation) \
+ (BLCKSZ + (BLCKSZ / (int) MAXALIGN(sizeof(IndexTupleData) + 2)) * \
+ (int) MAXALIGN(BTREE_PREFIX_MAX))
/*
* Constant definition for progress reporting. Phase numbers must match
@@ -1202,7 +1316,8 @@ extern bool _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
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 Size _bt_dedup_finish_pending(Page newpage, BTDedupState state,
+ bool overflowok);
extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids,
int nhtids);
extern void _bt_update_posting(BTVacuumPosting vacposting);
@@ -1315,6 +1430,34 @@ 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 void _bt_page_set_prefix(Page page, const char *prefix, Size len);
+extern IndexTuple _bt_prefix_encode(Page page, IndexTuple itup);
+extern Size _bt_prefix_coded_size(Page page, IndexTuple itup);
+extern bool _bt_prefix_enabled(Relation index);
+extern const char *_bt_prefix_candidate(IndexTuple itup, Size *len);
+extern int _bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup);
+extern Size _bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix);
+extern void _bt_prefix_compress_page(Relation index, Page page);
+extern void _bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen);
+extern bool _bt_split_leaf_layout(Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *prefix, Size prefixlen);
+extern void _bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft,
+ OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright);
+
/*
* prototypes for functions in nbtvalidate.c
*/
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..285eac6f1aa 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -156,25 +156,59 @@ 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 */
+ uint16 flags; /* XLH_SPLIT_* below */
+ uint8 leftprefixlen; /* leaf: left half's prefix length (0 none) */
+ uint8 rightprefixlen; /* leaf: right half's prefix length (0 none) */
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, rightprefixlen) + sizeof(uint8))
+
+/*
+ * xl_btree_split flag bits.
+ *
+ * A prefix-compressed leaf split logs no full-page image in the common case:
+ * redo rebuilds each half the way the primary did. The left half is
+ * reconstructed from the pre-split original page (block 0) together with the
+ * logged new item and high key; the right half is reconstructed from its plain
+ * data items, which are logged as block-1 data. Each half's chosen prefix
+ * bytes are logged too (block 0 for the left half, block 1 for the right), and
+ * applied with _bt_split_leaf_layout(); redo cannot run the prefix-choosing
+ * heuristic itself, so the primary logs the prefix it chose.
+ * leftprefixlen/rightprefixlen give the lengths (0 means the half is not
+ * prefix-compressed). XLH_SPLIT_LEAFPREFIX marks a leaf split that uses this
+ * path at all (so redo rebuilds coded rather than replaying items).
+ */
+#define XLH_SPLIT_LEAFPREFIX 0x01 /* leaf split, rebuild halves coded */
+#define XLH_SPLIT_RFPI 0x08 /* right half carried by a full-page image
+ * (its plain items would overflow the WAL
+ * record); redo restores it from the image */
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
* merged together into posting list tuples.
*
- * The WAL record represents a deduplication pass for a leaf page. An array
- * of BTDedupInterval structs follows.
+ * The WAL record represents a deduplication pass for a leaf page. Normally
+ * (prefixlen == 0) an array of BTDedupInterval structs follows and redo
+ * replays them against the old page. When the page is prefix-compressed
+ * (prefixlen > 0) the deduped result is instead carried as the chosen prefix
+ * bytes (prefixlen) followed by, when not rightmost, the high key, then the
+ * deduped plain data items -- each MAXALIGN'd -- and redo lays them out coded
+ * with _bt_split_leaf_layout(), avoiding a full-page image.
*/
typedef struct xl_btree_dedup
{
uint16 nintervals;
+ uint8 prefixlen; /* >0: prefix-dedup payload, see above */
+ uint8 flags; /* XLH_DEDUP_* */
- /* DEDUPLICATION INTERVALS FOLLOW */
+ /* DEDUPLICATION INTERVALS or PREFIX PAYLOAD FOLLOW */
} xl_btree_dedup;
-#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, flags) + sizeof(uint8))
+
+/* xl_btree_dedup flag bits */
+#define XLH_DEDUP_NOT_RIGHTMOST 0x01 /* prefix payload includes a high key */
+#define XLH_DEDUP_RFPI 0x02 /* prefix page carried as a forced image */
/*
* This is what we need to know about page reuse within btree. This record
diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out
index 7c53d598649..c1dae4b55ad 100644
--- a/src/test/regress/expected/btree_index.out
+++ b/src/test/regress/expected/btree_index.out
@@ -891,3 +891,259 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql
index 6990d7818da..6cd053a1596 100644
--- a/src/test/regress/sql/btree_index.sql
+++ b/src/test/regress/sql/btree_index.sql
@@ -520,3 +520,170 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a7a8dbdd23f..611c820d627 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,7 @@ BTPageStat
BTPageState
BTParallelScanDesc
BTPendingFSM
+BTPrefixPlan
BTReadPageState
BTScanInsert
BTScanInsertData
--
2.54.0
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-10-27 16:50 ` Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
1 sibling, 1 reply; 12+ messages in thread
From: Greg Burd @ 2026-10-27 16:50 UTC (permalink / raw)
To: pgsql-hackers@lists.postgresql.org; +Cc: Peter Geoghegan <pg@bowt.ie>
Hello,
v2 attached to appease the cf-bot, master moved under me (notably Peter's
d42c5457e5, the multi-level page-deletion WAL fix, which touches nbtpage.c
right where this patch drops the PREFIX item on an emptied leaf). v2 is
rebased on top of that; the two changes don't actually overlap, and page
deletion of a prefix leaf still replays byte-for-byte under
wal_consistency_checking=all.
While rebasing I did a review pass of my own with Peter's nbtree
sensibilities in mind, since he's the obvious reviewer and I'd rather
not waste his time on things I can see coming.
To that end, three things changed:
1. One split-collection routine, not two.
v1 had the primary _bt_split() build the left/right plain-item
arrays with its own inline loop, while redo built them with
_bt_split_leaf_collect() -- two hand-maintained copies of the same
logic that have to agree exactly or a standby diverges with no
version bump to catch it. That's the classic footgun. v2 has
_bt_split() call _bt_split_leaf_collect() too, so the primary and
redo derive the identical plain-item sequence from one place and
then code it with the shared _bt_split_leaf_layout(). The incremental
path for internal pages is unchanged.
2. amcheck actually checks the new format.
v1 taught verify_nbtree.c to *decode* coded items (so the existing
checks run on real keys), but it validated none of the BTP_PREFIX
structure and the test suite never ran amcheck on a prefix index at
all. v2 adds structural checks -- the flag only on leaf pages, a
well-formed PREFIX item at the expected offset with a legal length --
and contrib/amcheck now builds a prefix-compressed index (shared
prefix + no-shared-prefix + NULL, enough rows to split and dedup) and
runs bt_index_check / bt_index_parent_check with heapallindexed over
it. amcheck is how "two formats coexist forever" stays true, so it
should carry its weight here.
3. Docs and comments matched to the code.
The README still described the first design, where split and dedup
pages were carried by full-page image; that's long gone (redo rebuilds
the coded bytes from logged plain items now), and it also still
claimed dedup and bottom-up deletion don't run on prefix pages, which
is also no longer true. Rewritten to match. A couple of stale
XLH_SPLIT_RPREFIX-era comments went with it. I also added the
"can't happen" bounds asserts on the WAL-data-driven item loops in
redo.
The one thing I deliberately did NOT change, because I think it's a
real design question rather than a cleanup, is how much nbtsplitloc.c
knows about the feature.
Right now _bt_findsplitloc()/_bt_recsplitloc() reason in coded (on-page)
bytes on a prefix page, and there's a small guard that rejects a split
point that would leave the left page with no data items. I know the
history here: when deduplication went in, the strong preference was for
nbtsplitloc.c to stay ignorant of posting lists and for _bt_split() to
fix up reality at one point just before WAL-logging. I tried to honor
that -- the split chooser still treats everything as plain tuples for
placement, and the "room for at least one heap TID" conservatism in
_bt_recsplitloc() is untouched. But I could not make the *space*
accounting pretend coding doesn't exist: a compressed leaf legitimately
holds more logical data than two plain pages, so feeding plain sizes to
the split chooser makes some splits look infeasible that are not. So
the accounting is in coded bytes, and the guard exists because coded-size
accounting can otherwise pick a degenerate point that plain accounting
never would.
I can see two ways to push the feature further out of nbtsplitloc.c and
I'd like an opinion before I build either:
(a) leave the coded-size accounting where it is but move the
degenerate-split guard into _bt_split() (reject/retry there), so
nbtsplitloc.c at least stops having a prefix-only early return; or
(b) have nbtsplitloc.c reason entirely in plain sizes and teach
_bt_split() to fall back to a plain (uncompressed) left/right half
when the chosen point doesn't fit coded -- i.e. the page just
doesn't get compressed this time, rather than the chooser knowing
about coding at all.
(b) is closer to the "nbtsplitloc.c knows nothing" ideal but gives up
compression on exactly the fullest pages; (a) keeps the space win but
leaves the accounting coded. I lean (a). Thoughts?
Two open items I flagged in v1 still stand: the forced-full-page-image
fallbacks (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI) only fire for a page whose
plain payload would exceed a block, which I've never hit on real data,
I can either add a test that forces the path or demote it to a
can't-happen error if we decide it's unreachable; and I owe an
"alternatives considered" note on why this is a new on-disk format
rather than an extension of posting lists (short version: posting lists
collapse *equal* keys, this compresses *distinct* keys that share a
prefix, so they're complementary, which the dedup composition in the
patch relies on).
best.
-greg
Attachments:
v2-0001-nbtree-opt-in-leaf-key-prefix-compression.patch (text/x-patch, 155.6K) download
From 99985b999e7dc96525f818a7f85139c74cac3842 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 15:25:58 -0400
Subject: [PATCH v2] nbtree: opt-in leaf key prefix compression
Add leaf key prefix compression to nbtree, controlled by a new
per-index storage parameter, prefix_compression, that is off by default.
When a leaf page is written as a whole -- during an index build, as a
split half, or as the result of a deduplication pass -- and its data
items share a leading byte-prefix of their first key column, the page is
rebuilt in the BTP_PREFIX format: a single PREFIX item after the high
key holds the shared bytes, and each data item is stored "coded" (its
copy of the shared prefix replaced by a one-byte length). Reads of a
leaf data item funnel through _bt_getitem(), which decodes on demand;
high keys and internal-page downlinks are never coded and are read with
the existing PageGetItem().
The feature is deliberately a per-page opt-in with no on-disk version
change: BTREE_VERSION and the catalog version are untouched. A page is
converted only when it is next written whole, never by an insert, VACUUM
or key-range change, so compression "faults forward" on an existing
index. Old-format and new-format pages coexist in the same index, which
keeps pg_upgrade, physical replication, and standbys working with no
REINDEX: a replica that has not yet seen a page's conversion still reads
it correctly, and turning the parameter off (ALTER INDEX ... SET) stops
new conversions while leaving existing coded pages readable.
Deduplication and prefix compression compose rather than compete. A
build deduplicates first and then codes the finished page, so a
duplicate-heavy prefix index is no larger than a plain one; distinct
keys with a shared prefix (URL or JSONB columns, the motivating case)
are typically a few times smaller. Bottom-up index deletion runs on
coded pages unchanged, since it only removes items. A runtime
deduplication pass on a coded page decodes first, and because a coded
page can hold more logical data than fits plain, the pass aborts cleanly
and the page splits instead when the decoded set would overflow.
WAL replay reproduces the coded bytes without resorting to full-page
images in the common paths. A Relation-free layout routine,
_bt_split_leaf_layout(), together with a single item-collection routine,
_bt_split_leaf_collect(), are shared by the primary (_bt_split) and redo
(btree_xlog_split) so both derive the same plain item sequence and code
it against the same logged prefix, emitting identical bytes. An
in-place insert logs the coded tuple; a split logs each half's chosen
prefix plus the right half's plain items; a prefix deduplication logs
the chosen prefix plus the deduped plain items. Each path keeps a
bounded fallback to a forced image for a pathologically compressible
page whose plain payload would exceed one block.
amcheck decodes every leaf data item, including under heapallindexed,
and checks the BTP_PREFIX structural invariants (flag only on leaf
pages, a well-formed PREFIX item at the expected offset); its test suite
covers a prefix-compressed index. The create_index documentation and
the nbtree README describe the parameter and the on-disk format. Tested
with wal_consistency_checking=all on a primary and a streaming standby
across insert, split, deduplication, delete and vacuum workloads,
through crash recovery, and across a pg_upgrade that carries both page
formats.
---
contrib/amcheck/expected/check_btree.out | 27 +
contrib/amcheck/sql/check_btree.sql | 18 +
contrib/amcheck/verify_nbtree.c | 120 +++-
doc/src/sgml/ref/create_index.sgml | 35 +
src/backend/access/common/reloptions.c | 10 +
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 81 +++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 258 ++++++-
src/backend/access/nbtree/nbtinsert.c | 360 ++++++++--
src/backend/access/nbtree/nbtpage.c | 31 +-
src/backend/access/nbtree/nbtprefix.c | 814 ++++++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 23 +-
src/backend/access/nbtree/nbtree.c | 6 +-
src/backend/access/nbtree/nbtsearch.c | 3 +-
src/backend/access/nbtree/nbtsort.c | 54 +-
src/backend/access/nbtree/nbtsplitloc.c | 84 ++-
src/backend/access/nbtree/nbtutils.c | 4 +-
src/backend/access/nbtree/nbtxlog.c | 313 ++++++++-
src/include/access/nbtree.h | 147 +++-
src/include/access/nbtxlog.h | 44 +-
src/test/regress/expected/btree_index.out | 256 +++++++
src/test/regress/sql/btree_index.sql | 167 +++++
src/tools/pgindent/typedefs.list | 2 +
24 files changed, 2745 insertions(+), 114 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
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..18551483e88 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -158,7 +158,12 @@ typedef struct BtreeLastVisibleEntry
OffsetNumber offset; /* Offset on index block */
int postingIndex; /* Number in the posting list (-1 for
* non-deduplicated tuples) */
- ItemPointer tid; /* Heap tid */
+ ItemPointer tid; /* Heap tid, or NULL; points at tiddata */
+ ItemPointerData tiddata; /* Own storage for *tid: a prefix-coded leaf
+ * tuple is decoded into a scratch buffer that
+ * is reused on the next loop iteration, so we
+ * must keep the heap TID by value, not point
+ * into the (transient) decoded tuple */
} BtreeLastVisibleEntry;
/*
@@ -935,7 +940,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
lVis->postingIndex = i;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
}
}
}
@@ -961,7 +967,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
lVis->postingIndex = -1;
}
}
@@ -1229,6 +1236,16 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber max;
BTPageOpaque topaque;
+ /*
+ * Scratch space for decoding prefix-coded leaf tuples (_bt_getitem).
+ * itupbuf backs the current loop item; crossbuf backs the right sibling's
+ * first item in the cross-page unique check, which must coexist with the
+ * current item's state. A pivot, high key or non-prefix leaf item is
+ * returned by _bt_getitem unchanged (buffer unused).
+ */
+ BTItemBuf itupbuf;
+ BTItemBuf crossbuf;
+
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1266,6 +1283,54 @@ bt_target_page_check(BtreeCheckState *state)
}
}
+ /*
+ * Structural checks for a prefix-compressed (BTP_PREFIX) leaf. A reader
+ * trusts this format implicitly, so validate the parts that an out-of-
+ * place decode would otherwise read as garbage: the flag appears only on
+ * leaf pages, and the PREFIX item sits at the expected offset with a
+ * length in the legal range. The per-item key/content checks below run
+ * on the decoded tuple from _bt_getitem, which fails on a malformed coded
+ * item, so item-level coding is covered there.
+ */
+ if (P_HASPREFIX(topaque))
+ {
+ ItemId pitemid;
+ IndexTuple pitup;
+ Size prefixlen;
+ OffsetNumber prefixoff = BTPageGetPrefixOff(topaque);
+
+ if (!P_ISLEAF(topaque))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix compression flag set on non-leaf page in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u page lsn=%X/%08X.",
+ state->targetblock,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ if (prefixoff > max)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix-compressed page has no PREFIX item in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix offset=%u max offset=%u page lsn=%X/%08X.",
+ state->targetblock, prefixoff, max,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ pitemid = PageGetItemIdCareful(state, state->targetblock,
+ state->target, prefixoff);
+ pitup = (IndexTuple) PageGetItem(state->target, pitemid);
+ prefixlen = IndexTupleSize(pitup) - sizeof(IndexTupleData);
+ if (prefixlen < 1 || prefixlen > BTREE_PREFIX_MAX)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("PREFIX item has invalid length in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix length=%zu page lsn=%X/%08X.",
+ state->targetblock, prefixlen,
+ 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"
@@ -1293,8 +1358,15 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, state->targetblock,
state->target, offset);
- itup = (IndexTuple) PageGetItem(state->target, itemid);
- tupsize = IndexTupleSize(itup);
+
+ /*
+ * lp_len and the tuple size limit are physical properties of the item
+ * as stored, so they are checked against the raw on-page tuple (coded,
+ * on a BTP_PREFIX leaf). Every key/content check below instead uses
+ * the decoded tuple returned by _bt_getitem.
+ */
+ tupsize = IndexTupleSize((IndexTuple) PageGetItem(state->target, itemid));
+ itup = _bt_getitem(state->target, offset, &itupbuf);
/*
* lp_len should match the IndexTuple reported length exactly, since
@@ -1606,7 +1678,7 @@ 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), &itupbuf);
tid = BTreeTupleGetPointsToTID(itup);
nhtid = psprintf("(%u,%u)",
ItemPointerGetBlockNumberNoCheck(tid),
@@ -1793,7 +1865,7 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, rightblock_number,
rightpage,
rightfirstoffset);
- itup = (IndexTuple) PageGetItem(rightpage, itemid);
+ itup = _bt_getitem(rightpage, rightfirstoffset, &crossbuf);
bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis);
@@ -1857,6 +1929,8 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
BlockNumber targetnext;
Page rightpage;
OffsetNumber nline;
+ OffsetNumber rightoffset;
+ BTItemBuf firstbuf;
/* Determine target's next block number */
opaque = BTPageGetOpaque(state->target);
@@ -2016,6 +2090,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/* Return first data item (if any) */
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
P_FIRSTDATAKEY(opaque));
+ rightoffset = P_FIRSTDATAKEY(opaque);
*rightfirstoffset = P_FIRSTDATAKEY(opaque);
}
else if (!P_ISLEAF(opaque) &&
@@ -2027,6 +2102,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
*/
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
OffsetNumberNext(P_FIRSTDATAKEY(opaque)));
+ rightoffset = OffsetNumberNext(P_FIRSTDATAKEY(opaque));
}
else
{
@@ -2046,9 +2122,19 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/*
* Return first real item scankey. Note that this relies on right page
* memory remaining allocated.
+ *
+ * On a BTP_PREFIX leaf the first data item is prefix-coded, so decode it;
+ * because the returned scankey (built by _bt_mkscankey) references the
+ * tuple's own storage, and _bt_getitem decodes into a buffer local to
+ * this function, copy the decoded tuple into the caller-reset memory
+ * context so it outlives the call. A pivot or non-prefix item is
+ * returned by _bt_getitem unchanged (it lives in the right page, which
+ * remains allocated), so no copy is needed then.
*/
- firstitup = (IndexTuple) PageGetItem(rightpage, rightitem);
- return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ firstitup = _bt_getitem(rightpage, rightoffset, &firstbuf);
+ if (firstitup == (IndexTuple) PageGetItem(rightpage, rightitem))
+ return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ return bt_mkscankey_pivotsearch(state->rel, CopyIndexTuple(firstitup));
}
/*
@@ -3095,14 +3181,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);
@@ -3122,11 +3207,12 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key,
{
BTPageOpaque topaque;
IndexTuple ritup;
+ BTItemBuf ritupbuf;
int uppnkeyatts;
ItemPointer rheaptid;
bool nonpivot;
- ritup = (IndexTuple) PageGetItem(state->target, itemid);
+ ritup = _bt_getitem(state->target, upperbound, &ritupbuf);
topaque = BTPageGetOpaque(state->target);
nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque);
@@ -3218,14 +3304,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 */
@@ -3236,12 +3321,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key,
if (cmp == 0)
{
IndexTuple child;
+ BTItemBuf childbuf;
int uppnkeyatts;
ItemPointer childheaptid;
BTPageOpaque copaque;
bool nonpivot;
- child = (IndexTuple) PageGetItem(nontarget, itemid);
+ child = _bt_getitem(nontarget, upperbound, &childbuf);
copaque = BTPageGetOpaque(nontarget);
nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque);
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..7e66765c3d3 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -477,6 +477,41 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
</note>
</listitem>
</varlistentry>
+
+ <varlistentry id="index-reloption-prefix-compression" xreflabel="prefix_compression">
+ <term><literal>prefix_compression</literal> (<type>boolean</type>)
+ <indexterm>
+ <primary><varname>prefix_compression</varname> storage parameter</primary>
+ </indexterm>
+ </term>
+ <listitem>
+ <para>
+ Controls leaf key prefix compression. When set to
+ <literal>ON</literal>, and the index's first key column is of a
+ variable-length type, a leaf page may store a shared prefix of that
+ column once and record only each tuple's unshared remainder, which can
+ substantially shrink indexes on keys that share long leading bytes
+ (for example URLs or <type>jsonb</type> keys with common paths). Set
+ to <literal>ON</literal> or <literal>OFF</literal> to enable or disable
+ the optimization. The default is <literal>OFF</literal>.
+ </para>
+
+ <note>
+ <para>
+ 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 adopts a prefix only when
+ it is written as a whole (during an index build, or either half of a
+ page split), so turning <literal>prefix_compression</literal> on or off
+ via <command>ALTER INDEX</command> does not rewrite the index: existing
+ pages are left as they are and are converted the next time they are
+ written whole. No <command>REINDEX</command> is required, and indexes
+ upgraded with <application>pg_upgrade</application> keep working
+ unchanged.
+ </para>
+ </note>
+ </listitem>
+ </varlistentry>
</variablelist>
<para>
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..ed444c895ff 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1081,3 +1081,84 @@ 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
+front-code the first key column of each of its data tuples against a per-page
+prefix, which shrinks indexes on keys that share long leading bytes (URLs,
+jsonb keys with common paths, zero-padded codes). Like deduplication, this
+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.
+
+A leaf that uses the technique carries the BTP_PREFIX page flag and one extra
+item, the PREFIX item, 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. The PREFIX item is an
+IndexTupleData header followed by 1..BTREE_PREFIX_MAX prefix bytes, shaped so
+that code which walks a page's items by IndexTupleSize (such as
+_bt_restore_page) steps over it. On such a page every non-pivot tuple (plain
+or posting) whose first key column is not NULL stores that column as a varlena
+whose payload begins with a one-byte count of how many leading bytes it shares
+with the page prefix, followed by the unshared remainder (or, for a value whose
+header is not the form index_form_tuple would reproduce, the whole value after
+a sentinel count). Trailing key columns, INCLUDE columns, a posting list and
+alignment padding follow, moved to preserve alignment; padding that is only the
+key's closing zeros is dropped and restored on decode. Pivots, NULL first
+columns and the high key are always stored uncompressed. Coding and decoding
+depend only on the page prefix and the tuple, never the catalog, so WAL redo
+reproduces them and decoding yields the original tuple byte for byte. All of
+nbtprefix.c implements this; _bt_getitem() is the one accessor through which
+readers obtain a decoded leaf data tuple.
+
+A page adopts a prefix only when it is written as a whole: a finished leaf
+during an index build, or either half of a leaf page split (built coded from
+the start by _bt_split_leaf_half, since a compressed leaf can hold more logical
+data than fits on a plain page, choosing each half's own prefix and falling
+back to a plain layout or the original page's prefix if its own does not fit).
+Crucially, no insert, VACUUM, deduplication or change of a page's key range
+ever rewrites a page to keep its prefix valid: a tuple that happens to share
+fewer bytes simply records a smaller shared count. 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 physical/logical replication keep
+working and existing indexes continue to work with no REINDEX. Turning the
+parameter on with ALTER INDEX does not rewrite anything; 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 (the flag is cleared
+only when a page is split, emptied or dedup-rewritten) and are still read back
+correctly with the parameter off, since every reader decodes based on the page
+flag, not on the current reloption. Scan-time workspace that must hold decoded
+tuples (the index-only-scan currTuples/markTuples buffers, see
+BTTupleStorageSize) is therefore sized for a decoded page on every btree index,
+not just those whose parameter is currently on.
+
+Leaf changes to a prefix page are replayed without a full-page image in the
+common case. Redo cannot run the prefix-choosing heuristic (it has no
+relcache), so the primary logs the prefix it chose and both sides lay the
+page out through one Relation-free routine, _bt_split_leaf_layout(), fed by
+one item-collection routine, _bt_split_leaf_collect(), that the primary
+(_bt_split) and redo (btree_xlog_split) both call. Because the two derive
+the plain item sequence the same way and code it against the same logged
+prefix, they produce byte-identical pages. An in-place insert logs the coded
+tuple (standard insert redo adds it verbatim); a split logs each half's
+chosen prefix plus the right half's plain items; a deduplication logs the
+chosen prefix plus the deduped plain items. A bounded fallback forces a
+full-page image only when a pathologically compressible page's plain payload
+would exceed BLCKSZ (so the logged payload can never exceed one block); redo
+then restores the image via BLK_RESTORED. When VACUUM or page deletion
+empties a prefix leaf, the PREFIX item is removed and the flag cleared so that
+the invariant that an emptied leaf holds only its high key (relied on by page
+deletion) still holds.
+
+Deduplication and bottom-up index deletion both run on BTP_PREFIX pages.
+Bottom-up deletion only removes items and never rewrites survivors, so it is
+safe on coded pages unchanged. A deduplication pass decodes the page's items,
+merges duplicates into posting lists and re-compresses the result as a whole;
+because a coded page can hold more logical data than fits plain, if the
+decoded set would overflow a plain page the pass aborts cleanly and the page
+splits instead, so no in-place rewrite is ever forced to fail. An index build
+deduplicates before coding the finished page, so a duplicate-heavy prefix
+index is no larger than a plain one.
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..6dc7ebe25b4 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -69,7 +69,19 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Size pagesaving PG_USED_FOR_ASSERTS_ONLY = 0;
bool singlevalstrat = false;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
XLogRecPtr recptr;
+ IndexTuple *dedup_plain = NULL; /* deduped plain items, for prefix WAL */
+ int dedup_nplain = 0;
+ IndexTuple dedup_hikey = NULL;
+ char dedup_prefix[BTREE_PREFIX_MAX];
+ Size dedup_prefixlen = 0;
+ bool dedup_islogprefix = false; /* page ended up prefix-coded */
+ char *dedup_walbuf = NULL; /* packed prefix-dedup WAL payload */
+ Size dedup_walbuflen = 0;
+ bool dedup_rfpi = false; /* fall back to a forced full-page image */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -120,7 +132,16 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
newpage = PageGetTempPageCopySpecial(page);
PageSetLSN(newpage, PageGetLSN(page));
- /* Copy high key, if any */
+ /*
+ * The rebuilt page is laid out plain here (clear any inherited BTP_PREFIX;
+ * PageGetTempPageCopySpecial copies the special area but not the PREFIX
+ * item). If the page was prefix-compressed it is re-compressed as a whole
+ * below, after deduplication, honouring Strategy 1.
+ */
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
+ /* Copy high key, if any (a pivot, stored plain on any page) */
if (!P_RIGHTMOST(opaque))
{
ItemId hitemid = PageGetItemId(page, P_HIKEY);
@@ -140,12 +161,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Assert(!ItemIdIsDead(itemid));
+ /* Decode on a prefix page so keys/posting lists are the real ones. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/*
* No previous/base tuple for the data item -- use the data item
- * as base tuple of pending posting list
+ * as base tuple of pending posting list. On a prefix page the
+ * base must survive the whole merge run (it is kept as a pointer
+ * in state and read by _bt_dedup_finish_pending), so keep it in a
+ * dedicated buffer rather than the per-iteration curbuf.
*/
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (state->deduplicate &&
@@ -170,7 +201,20 @@ _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);
+ /*
+ * On a prefix page the deduped items are laid out plain here; the
+ * decoded set can exceed one page even when the coded form fit, so
+ * tolerate an overflow and abort the whole pass (the caller then
+ * splits the page instead of deduplicating it).
+ */
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state,
+ pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
if (singlevalstrat)
{
@@ -198,12 +242,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
}
/* itup starts new pending posting list */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page,
+ (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
/* Handle the last item */
- pagesaving += _bt_dedup_finish_pending(newpage, state);
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state, pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
/*
* If no items suitable for deduplication were found, newpage must be
@@ -238,6 +292,103 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
nopaque->btpo_flags &= ~BTP_HAS_GARBAGE;
}
+ /*
+ * Re-apply prefix compression to the rebuilt (plain) page as a whole
+ * (Strategy 1), before the critical section since it allocates a scratch
+ * page. First capture the deduped plain items and high key: if the page
+ * does become prefix-compressed, redo rebuilds its coded bytes from these
+ * plus the chosen prefix (via _bt_split_leaf_layout), the same way a prefix
+ * leaf split is replayed -- no full-page image needed.
+ */
+ if (pageprefix)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(newpage);
+ OffsetNumber nmin = P_FIRSTDATAKEY(nopq);
+ OffsetNumber nmax = PageGetMaxOffsetNumber(newpage);
+
+ dedup_nplain = (nmax >= nmin) ? (int) nmax - (int) nmin + 1 : 0;
+ if (dedup_nplain > 0)
+ {
+ dedup_plain = palloc(dedup_nplain * sizeof(IndexTuple));
+ for (int i = 0; i < dedup_nplain; i++)
+ dedup_plain[i] = CopyIndexTuple((IndexTuple)
+ PageGetItem(newpage,
+ PageGetItemId(newpage, nmin + i)));
+ }
+ if (!P_RIGHTMOST(nopq))
+ {
+ ItemId hii = PageGetItemId(newpage, P_HIKEY);
+
+ dedup_hikey = CopyIndexTuple((IndexTuple) PageGetItem(newpage, hii));
+ }
+
+ _bt_prefix_compress_page(rel, newpage);
+
+ /* Did it actually compress? (It gives up if a coded item won't fit.) */
+ if (P_HASPREFIX(BTPageGetOpaque(newpage)))
+ {
+ const char *pfx = _bt_page_get_prefix(newpage, &dedup_prefixlen);
+
+ if (dedup_prefixlen > 0)
+ memcpy(dedup_prefix, pfx, dedup_prefixlen);
+ dedup_islogprefix = true;
+ }
+ }
+
+ /*
+ * Pack the prefix-dedup WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the chosen prefix bytes, then the high
+ * key (unless rightmost), then the deduped plain items, each MAXALIGN'd,
+ * in one contiguous buffer logged with a single rdata slot.
+ */
+ if (dedup_islogprefix)
+ {
+ Size off = dedup_prefixlen;
+
+ if (dedup_hikey != NULL)
+ off += MAXALIGN(IndexTupleSize(dedup_hikey));
+ for (int i = 0; i < dedup_nplain; i++)
+ off += MAXALIGN(IndexTupleSize(dedup_plain[i]));
+
+ /*
+ * A pathologically compressible page can hold more plain data than
+ * fits in one page; if the payload would be that large, fall back to
+ * a forced full-page image of the coded page (bounded at BLCKSZ).
+ */
+ if (off > BLCKSZ)
+ dedup_rfpi = true;
+ else
+ {
+ dedup_walbuflen = off;
+ dedup_walbuf = palloc(Max(off, 1));
+
+ off = 0;
+ if (dedup_prefixlen > 0)
+ {
+ memcpy(dedup_walbuf, dedup_prefix, dedup_prefixlen);
+ off += dedup_prefixlen;
+ }
+ if (dedup_hikey != NULL)
+ {
+ Size hsz = IndexTupleSize(dedup_hikey);
+
+ memcpy(dedup_walbuf + off, dedup_hikey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(dedup_walbuf + off + hsz, 0, MAXALIGN(hsz) - hsz);
+ off += MAXALIGN(hsz);
+ }
+ for (int i = 0; i < dedup_nplain; i++)
+ {
+ Size sz = IndexTupleSize(dedup_plain[i]);
+
+ memcpy(dedup_walbuf + off, dedup_plain[i], sz);
+ if (MAXALIGN(sz) != sz)
+ memset(dedup_walbuf + off + sz, 0, MAXALIGN(sz) - sz);
+ off += MAXALIGN(sz);
+ }
+ }
+ }
+
START_CRIT_SECTION();
PageRestoreTempPage(newpage, page);
@@ -248,19 +399,38 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
{
xl_btree_dedup xlrec_dedup;
- xlrec_dedup.nintervals = state->nintervals;
+ xlrec_dedup.nintervals = dedup_islogprefix ? 0 : state->nintervals;
+ xlrec_dedup.prefixlen = (uint8) dedup_prefixlen;
+ xlrec_dedup.flags = (dedup_hikey != NULL) ? XLH_DEDUP_NOT_RIGHTMOST : 0;
+ if (dedup_rfpi)
+ xlrec_dedup.flags |= XLH_DEDUP_RFPI;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ (dedup_rfpi ? REGBUF_FORCE_IMAGE : 0));
XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
- /*
- * The intervals array is not in the buffer, but pretend that it is.
- * When XLogInsert stores the whole buffer, the array need not be
- * stored too.
- */
- XLogRegisterBufData(0, state->intervals,
- state->nintervals * sizeof(BTDedupInterval));
+ if (dedup_islogprefix)
+ {
+ /*
+ * Prefix-dedup: carry the chosen prefix and the deduped plain
+ * items so redo can lay out the coded page; no full-page image
+ * (unless the payload was too large, handled by the forced image).
+ */
+ if (!dedup_rfpi && dedup_walbuflen > 0)
+ XLogRegisterBufData(0, dedup_walbuf, dedup_walbuflen);
+ }
+ else
+ {
+ /*
+ * The intervals array is not in the buffer, but pretend that it
+ * is. When XLogInsert stores the whole buffer, the array need not
+ * be stored too.
+ */
+ XLogRegisterBufData(0, state->intervals,
+ state->nintervals * sizeof(BTDedupInterval));
+ }
recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DEDUP);
}
@@ -277,6 +447,20 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
/* cannot leak memory here */
pfree(state->htids);
pfree(state);
+ return;
+
+dedup_overflow:
+
+ /*
+ * A prefix page's deduped items did not fit when laid out plain. Leave
+ * the original page untouched and return without writing anything; the
+ * caller falls through to split the page. No critical section was
+ * entered and the buffer was not marked dirty, so the page is unchanged.
+ */
+ Assert(pageprefix);
+ pfree(newpage);
+ pfree(state->htids);
+ pfree(state);
}
/*
@@ -318,6 +502,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
TM_IndexDeleteOp delstate;
bool neverdedup;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -371,9 +558,24 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
Assert(!ItemIdIsDead(itemid));
+ /*
+ * On a prefix-compressed page decode the item so _bt_keep_natts_fast
+ * sees the real key. The base of a pending interval must survive the
+ * whole run (it is compared against each following item), so keep it
+ * in its own buffer; the current item uses a reused buffer. Heap TIDs
+ * and posting lists are preserved by coding, so _bt_dedup_start_pending
+ * and _bt_bottomupdel_finish_pending (which read only TIDs/sizes, by
+ * page offset) need no decode.
+ */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/* itup starts first pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
@@ -387,6 +589,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
_bt_bottomupdel_finish_pending(page, state, &delstate);
/* itup starts new pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
@@ -554,7 +759,7 @@ _bt_dedup_save_htid(BTDedupState state, IndexTuple itup)
* where no deduplication was possible.
*/
Size
-_bt_dedup_finish_pending(Page newpage, BTDedupState state)
+_bt_dedup_finish_pending(Page newpage, BTDedupState state, bool overflowok)
{
OffsetNumber tupoff;
Size tuplesz;
@@ -572,7 +777,11 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
spacesaving = 0;
}
@@ -591,13 +800,26 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(final)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ pfree(final);
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
pfree(final);
spacesaving = state->phystupsize - (tuplesz + sizeof(ItemIdData));
/* Increment nintervals, since we wrote a new posting list tuple */
state->nintervals++;
- Assert(spacesaving > 0 && spacesaving < BLCKSZ);
+
+ /*
+ * On an ordinary page the merged interval's tuples were all physically
+ * present on one page, so the saving is bounded by BLCKSZ. On a prefix
+ * page (overflowok) phystupsize is the DECODED size of those tuples,
+ * which can exceed a page (a coded page holds more logical data than
+ * fits plain), so only require the saving to be positive there.
+ */
+ Assert(spacesaving > 0 && (overflowok || spacesaving < BLCKSZ));
}
/* Reset state for next pending posting list */
@@ -783,16 +1005,22 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state,
OffsetNumber minoff, IndexTuple newitem)
{
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(BTPageGetOpaque(page));
ItemId itemid;
IndexTuple itup;
+ BTItemBuf buf;
itemid = PageGetItemId(page, minoff);
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &buf);
if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts)
{
itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page));
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &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..2803302d24e 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -348,8 +348,8 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate)
P_ISLEAF(opaque) &&
!P_IGNORE(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
@@ -1135,6 +1135,11 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple codeditup; /* itup coded for the page, or itup */
+ Size codeditemsz; /* its MAXALIGNed size */
+ IndexTuple codednposting = NULL; /* nposting coded for the page */
+ BTItemBuf opostbuf; /* decode workspace for an existing posting */
+ bool pageprefix; /* target page is BTP_PREFIX */
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1143,6 +1148,7 @@ _bt_insertonpg(Relation rel,
isroot = P_ISROOT(opaque);
isrightmost = P_RIGHTMOST(opaque);
isonly = P_LEFTMOST(opaque) && P_RIGHTMOST(opaque);
+ pageprefix = P_HASPREFIX(opaque);
/* child buffer must be given iff inserting on an internal page */
Assert(isleaf == !BufferIsValid(cbuf));
@@ -1182,6 +1188,9 @@ _bt_insertonpg(Relation rel,
*/
Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage);
oposting = (IndexTuple) PageGetItem(page, itemid);
+ /* On a prefix page the existing posting is coded; decode for the swap. */
+ if (pageprefix)
+ oposting = _bt_prefix_decode(page, oposting, &opostbuf);
/*
* postingoff value comes from earlier call to _bt_binsrch_posting().
@@ -1213,19 +1222,50 @@ _bt_insertonpg(Relation rel,
newitemoff = OffsetNumberNext(newitemoff);
}
+ /*
+ * On a prefix-compressed page the new item is stored coded, which is at
+ * most MAXALIGN larger than plain and usually much smaller. Size the fit
+ * test and the placement by the coded size. (Split is handled on plain
+ * tuples; _bt_split re-compresses each half itself.)
+ */
+ if (pageprefix)
+ {
+ codeditup = _bt_prefix_encode(page, itup);
+ codeditemsz = MAXALIGN(IndexTupleSize(codeditup));
+ /*
+ * A posting-list split replaces the existing coded posting with a
+ * re-coded one; encode it here, before the critical section, since
+ * _bt_prefix_encode() allocates.
+ */
+ if (nposting != NULL)
+ codednposting = _bt_prefix_encode(page, nposting);
+ }
+ else
+ {
+ codeditup = itup;
+ codeditemsz = itemsz;
+ }
+
/*
* Do we need to split the page to fit the item on it?
*
* 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.
+ * only for the item and not for its line pointer. The coded item size is
+ * used; a posting-list split additionally changes the posting tuple's
+ * size, but that can only shrink or keep free space (the new posting has
+ * one fewer TID's worth of room reused by newitem), so the item fit test
+ * remains a safe upper bound.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < codeditemsz)
{
Buffer rbuf;
Assert(!split_only_page);
+ if (codeditup != itup)
+ pfree(codeditup);
+
/* split the buffer into left and right halves */
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
@@ -1295,9 +1335,25 @@ _bt_insertonpg(Relation rel,
START_CRIT_SECTION();
if (postingoff != 0)
- memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ {
+ if (pageprefix)
+ {
+ /*
+ * The existing posting is stored coded; replace its slot (at
+ * the offset just before newitemoff, since newitemoff was
+ * advanced past it above) with the coded new posting (encoded
+ * before the critical section), whose size may differ.
+ */
+ if (!PageIndexTupleOverwrite(page, OffsetNumberPrev(newitemoff),
+ codednposting, IndexTupleSize(codednposting)))
+ elog(PANIC, "failed to overwrite posting list in block %u in index \"%s\"",
+ BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+ }
+ else
+ memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ }
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, codeditup, codeditemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1381,8 +1437,30 @@ _bt_insertonpg(Relation rel,
}
}
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- if (postingoff == 0)
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ ((pageprefix && postingoff != 0) ? REGBUF_FORCE_IMAGE : 0));
+ if (pageprefix && postingoff != 0)
+ {
+ /*
+ * A posting-list split on a prefix page rewrites an existing
+ * coded posting and adds a coded item; rather than teach redo to
+ * re-code both, force a full-page image (this is a rare case).
+ * postingoff is still recorded for the record format.
+ */
+ upostingoff = postingoff;
+ XLogRegisterBufData(0, &upostingoff, sizeof(uint16));
+ }
+ else if (pageprefix)
+ {
+ /*
+ * Simple insert on a prefix page: log the CODED item bytes as
+ * they were placed on the page, so redo adds them verbatim at
+ * newitemoff -- no full-page image needed, and byte-identical.
+ */
+ XLogRegisterBufData(0, (char *) codeditup, IndexTupleSize(codeditup));
+ }
+ else if (postingoff == 0)
{
/* Just log itup from caller */
XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -1418,6 +1496,11 @@ _bt_insertonpg(Relation rel,
END_CRIT_SECTION();
+ if (codeditup != itup)
+ pfree(codeditup);
+ if (codednposting != NULL && codednposting != nposting)
+ pfree(codednposting);
+
/* Release subsidiary buffers */
if (BufferIsValid(metabuf))
_bt_relbuf(rel, metabuf);
@@ -1506,6 +1589,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
ItemId itemid;
IndexTuple firstright,
lefthighkey;
+ BTItemBuf frbuf; /* decode scratch for firstright */
+ BTItemBuf llbuf; /* decode scratch for lastleft */
+ IndexTuple *leftitems = NULL; /* leaf: plain items for the left half */
+ IndexTuple *rightitems = NULL; /* leaf: plain items for the right half */
+ int nleft = 0;
+ int nright = 0;
+ IndexTuple righthighkey = NULL; /* leaf right half's high key (NULL=rightmost) */
+ const char *origprefix = NULL;
+ Size origprefixlen = 0;
+ char leftprefix[BTREE_PREFIX_MAX]; /* chosen prefix of each leaf half */
+ char rightprefix[BTREE_PREFIX_MAX];
+ Size leftprefixlen = 0;
+ Size rightprefixlen = 0;
+ char *rightwalbuf = NULL; /* packed right-page WAL payload */
+ Size rightwalbuflen = 0;
OffsetNumber firstrightoff;
OffsetNumber afterleftoff,
afterrightoff,
@@ -1575,7 +1673,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
lopaque->btpo_flags = oopaque->btpo_flags;
- lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
/* set flag in leftpage indicating that rightpage has no downlink yet */
lopaque->btpo_flags |= BTP_INCOMPLETE_SPLIT;
lopaque->btpo_prev = oopaque->btpo_prev;
@@ -1650,6 +1748,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
itemid = PageGetItemId(origpage, firstrightoff);
itemsz = ItemIdGetLength(itemid);
firstright = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ firstright = _bt_prefix_decode(origpage, firstright, &frbuf);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
}
@@ -1673,6 +1773,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque));
itemid = PageGetItemId(origpage, lastleftoff);
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ lastleft = _bt_prefix_decode(origpage, lastleft, &llbuf);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
}
@@ -1712,7 +1814,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
/*
- * Add new high key to leftpage
+ * Add new high key to leftpage. For a leaf split the left half is built
+ * as a whole below (so it can be prefix-compressed from the start, which
+ * a plain incremental build cannot represent when the page holds more
+ * logical data than fits plain); the high key is passed to the builder
+ * then. Internal halves are built incrementally here as before.
*/
afterleftoff = P_HIKEY;
@@ -1720,11 +1826,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(BTreeTupleGetNAtts(lefthighkey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(itemsz == MAXALIGN(IndexTupleSize(lefthighkey)));
- if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
- elog(ERROR, "failed to add high key to the left sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
- afterleftoff = OffsetNumberNext(afterleftoff);
+ if (isleaf)
+ {
+ /* The builder needs a stable copy; lefthighkey points into workspace. */
+ lefthighkey = CopyIndexTuple(lefthighkey);
+ leftitems = palloc_array(IndexTuple, maxoff + 2);
+ rightitems = palloc_array(IndexTuple, maxoff + 2);
+ if (P_HASPREFIX(oopaque))
+ origprefix = _bt_page_get_prefix(origpage, &origprefixlen);
+ }
+ else
+ {
+ if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the left sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterleftoff = OffsetNumberNext(afterleftoff);
+ }
/*
* Acquire a new right page to split into, now that left page has a new
@@ -1765,7 +1883,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
ropaque->btpo_flags = oopaque->btpo_flags;
- ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = oopaque->btpo_next;
ropaque->btpo_level = oopaque->btpo_level;
@@ -1782,21 +1900,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (!isrightmost)
{
- IndexTuple righthighkey;
+ ItemId hiitemid = PageGetItemId(origpage, P_HIKEY);
+ Size hiitemsz = ItemIdGetLength(hiitemid);
+ IndexTuple hikey = (IndexTuple) PageGetItem(origpage, hiitemid);
- itemid = PageGetItemId(origpage, P_HIKEY);
- itemsz = ItemIdGetLength(itemid);
- righthighkey = (IndexTuple) PageGetItem(origpage, itemid);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) > 0);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) <=
+ Assert(BTreeTupleGetNAtts(hikey, rel) > 0);
+ Assert(BTreeTupleGetNAtts(hikey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
- if (PageAddItem(rightpage, righthighkey, itemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ if (isleaf)
+ righthighkey = CopyIndexTuple(hikey); /* stable: origpage is
+ * overwritten by the left
+ * half before WAL logging */
+ else
{
- elog(ERROR, "failed to add high key to the right sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
+ if (PageAddItem(rightpage, hikey, hiitemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the right sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterrightoff = OffsetNumberNext(afterrightoff);
}
- afterrightoff = OffsetNumberNext(afterrightoff);
}
/*
@@ -1813,7 +1935,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*
* Note: we *must* insert at least the right page's items in item-number
* order, for the benefit of _bt_restore_page().
+ *
+ * A leaf split builds each half as a whole page below (so it can be
+ * prefix-compressed from the start), which redo must reproduce byte for
+ * byte. To keep the primary and redo in lock step, both derive the
+ * split's plain item arrays from the original page through the single
+ * shared routine _bt_split_leaf_collect(); the incremental loop here
+ * handles only the internal (!isleaf) case.
*/
+ if (isleaf)
+ {
+ _bt_split_leaf_collect(origpage, firstrightoff, newitemoff, newitem,
+ newitemonleft, origpagepostingoff, nposting,
+ leftitems, &nleft, rightitems, &nright);
+ }
+ else
for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
{
IndexTuple dataitem;
@@ -1837,24 +1973,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
{
Assert(newitemoff <= firstrightoff);
if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
- false))
- {
+ false))
elog(ERROR, "failed to add new item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
Assert(newitemoff >= firstrightoff);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1863,28 +1995,24 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (i < firstrightoff)
{
if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
- {
elog(ERROR, "failed to add old item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add old item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
- /* Handle case where newitem goes at the end of rightpage */
- if (i <= newitemoff)
+ /* Handle case where newitem goes at the end of rightpage (internal only) */
+ if (!isleaf && i <= newitemoff)
{
/*
* Can't have newitemonleft here; that would imply we were told to put
@@ -1893,15 +2021,76 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*/
Assert(!newitemonleft && newitemoff == maxoff + 1);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
+ /*
+ * Leaf halves are built as whole pages now, so they can be prefix-
+ * compressed from the start (Strategy 1: a page is faulted into the
+ * BTP_PREFIX format only when it is written whole). The plain item arrays
+ * were collected above by _bt_split_leaf_collect(), the same routine redo
+ * uses, so the primary and redo lay out identical bytes.
+ */
+ if (isleaf)
+ {
+ _bt_split_leaf_half(rel, leftpage, lefthighkey, leftitems, nleft,
+ origprefix, origprefixlen,
+ leftprefix, &leftprefixlen);
+ _bt_split_leaf_half(rel, rightpage, righthighkey, rightitems, nright,
+ origprefix, origprefixlen,
+ rightprefix, &rightprefixlen);
+
+ /*
+ * Build the right page's WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the right prefix bytes, then the
+ * right high key (unless rightmost), then the right half's plain items,
+ * each MAXALIGN'd, in one contiguous buffer logged with a single rdata
+ * slot. A very compressible right half whose plain items would exceed
+ * a page is instead carried as a full-page image (set below), so this
+ * buffer is bounded.
+ */
+ if (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0)
+ {
+ Size roff = rightprefixlen;
+
+ if (righthighkey != NULL)
+ roff += MAXALIGN(IndexTupleSize(righthighkey));
+ for (int ri = 0; ri < nright; ri++)
+ roff += MAXALIGN(IndexTupleSize(rightitems[ri]));
+ rightwalbuf = palloc(Max(roff, 1));
+ rightwalbuflen = roff;
+
+ roff = 0;
+ if (rightprefixlen > 0)
+ {
+ memcpy(rightwalbuf, rightprefix, rightprefixlen);
+ roff += rightprefixlen;
+ }
+ if (righthighkey != NULL)
+ {
+ Size hsz = IndexTupleSize(righthighkey);
+
+ memcpy(rightwalbuf + roff, righthighkey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(rightwalbuf + roff + hsz, 0, MAXALIGN(hsz) - hsz);
+ roff += MAXALIGN(hsz);
+ }
+ for (int ri = 0; ri < nright; ri++)
+ {
+ Size rsz = IndexTupleSize(rightitems[ri]);
+
+ memcpy(rightwalbuf + roff, rightitems[ri], rsz);
+ if (MAXALIGN(rsz) != rsz)
+ memset(rightwalbuf + roff + rsz, 0, MAXALIGN(rsz) - rsz);
+ roff += MAXALIGN(rsz);
+ }
+ }
+ }
+
/*
* We have to grab the original right sibling (if any) and update its prev
* link. We are guaranteed that this is deadlock-free, since we couple
@@ -2005,17 +2194,71 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /*
+ * A leaf split that involves prefix compression (either half ends up
+ * compressed, or the pre-split page was compressed so its items are
+ * coded) is replayed by rebuilding both halves from the pre-split page,
+ * exactly as the primary did, rather than by replaying logged tuples --
+ * the coded physical layout would not match _bt_restore_page. No
+ * full-page image is needed; redo reconstructs the plain items from the
+ * original page (block 0) and lays each half out coded against the
+ * chosen prefix, whose length and bytes are carried in the record.
+ */
+ xlrec.flags = 0;
+ xlrec.leftprefixlen = 0;
+ xlrec.rightprefixlen = 0;
+ if (isleaf && (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0))
+ {
+ xlrec.flags |= XLH_SPLIT_LEAFPREFIX;
+ xlrec.leftprefixlen = (uint8) leftprefixlen;
+ xlrec.rightprefixlen = (uint8) rightprefixlen;
+
+ /*
+ * The right half is normally carried as its packed plain payload
+ * (redo lays it out coded), far cheaper than a full-page image.
+ * But a very compressible page can hold more plain data than fits
+ * in one page; if the payload would be that large, fall back to a
+ * forced full-page image of the coded right page (bounded at BLCKSZ).
+ */
+ if (rightwalbuflen > BLCKSZ)
+ xlrec.flags |= XLH_SPLIT_RFPI;
+ }
+
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
+ /*
+ * The right page is rebuilt from scratch in redo. Ordinarily that is
+ * from the logged tuple region (REGBUF_WILL_INIT). A prefix-leaf split
+ * instead rebuilds it from its logged plain items, unless those would
+ * overflow the record (XLH_SPLIT_RFPI), in which case the coded page is
+ * carried as a forced full-page image.
+ */
+ XLogRegisterBuffer(1, rbuf,
+ (xlrec.flags & XLH_SPLIT_RFPI) ?
+ (REGBUF_STANDARD | REGBUF_FORCE_IMAGE) : REGBUF_WILL_INIT);
/* Log original right sibling, since we've changed its prev-pointer */
if (!isrightmost)
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
if (!isleaf)
XLogRegisterBuffer(3, cbuf, REGBUF_STANDARD);
+ /*
+ * For a prefix leaf split, carry each half's chosen prefix bytes, and
+ * (unless the right page is carried as a full-page image) the right
+ * high key and the right half's PLAIN items on block 1, so redo can lay
+ * the right page out coded without depending on block 0's state (block
+ * 0 may be restored from an automatic full-page image). The left half
+ * is rebuilt in redo from the original page (block 0) when it needs
+ * redo, or from its own FPI otherwise, so the left items are not logged.
+ */
+ if (xlrec.flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ if (!(xlrec.flags & XLH_SPLIT_RFPI) && rightwalbuflen > 0)
+ XLogRegisterBufData(1, rightwalbuf, rightwalbuflen);
+ }
+
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
@@ -2063,6 +2306,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
XLogRegisterBufData(0, lefthighkey,
MAXALIGN(IndexTupleSize(lefthighkey)));
+ /*
+ * For a prefix leaf split, the left half's chosen prefix bytes follow
+ * the high key on block 0 (redo reads newitem/orignewitem, then the
+ * high key, then this prefix).
+ */
+ if ((xlrec.flags & XLH_SPLIT_LEAFPREFIX) && leftprefixlen > 0)
+ XLogRegisterBufData(0, leftprefix, leftprefixlen);
+
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). The whole right page will be recreated.
@@ -2071,11 +2322,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the line pointers can be reconstructed. See comments for
- * _bt_restore_page().
+ * _bt_restore_page(). A prefix-compressed leaf split instead rebuilds
+ * the right page from the original page in redo (see XLH_SPLIT_LEAFPREFIX),
+ * so its tuple region is not logged.
*/
- XLogRegisterBufData(1,
- (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
- ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
+ if (!(xlrec.flags & XLH_SPLIT_LEAFPREFIX))
+ XLogRegisterBufData(1,
+ (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
+ ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
xlinfo = newitemonleft ? XLOG_BTREE_SPLIT_L : XLOG_BTREE_SPLIT_R;
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
@@ -2820,7 +3074,15 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
- /* Perform deduplication pass (when enabled and index-is-allequalimage) */
+ /*
+ * Perform deduplication pass (when enabled and index-is-allequalimage).
+ *
+ * On a prefix-compressed page the pass decodes items and lays the deduped
+ * result out plain before re-coding it; the decoded set can exceed one
+ * page even when the coded form fit, in which case the pass aborts without
+ * touching the page and we fall through to split it. When it does fit, the
+ * recompressed page's WAL replays without a full-page image.
+ */
if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
_bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz,
(indexUnchanged || uniquedup));
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f81..c755efc81b0 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1212,11 +1212,26 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * If VACUUM emptied a prefix-compressed leaf of all its data items, drop
+ * the PREFIX item too and clear BTP_PREFIX: an empty page carries no
+ * prefix, and page deletion relies on an emptied leaf holding only its
+ * high key (nbtpage.c's _bt_mark_page_halfdead). Recorded in the vacuum
+ * record so redo does the same.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
/*
@@ -2245,6 +2260,20 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
opaque = BTPageGetOpaque(page);
opaque->btpo_flags |= BTP_HALF_DEAD;
+ /*
+ * A prefix-compressed leaf being deleted may still carry its PREFIX item
+ * (e.g. emptied by simple deletion, which does not drop it). Remove it
+ * and clear BTP_PREFIX so the page holds only its high key, as the
+ * half-dead transition below requires. Redo (btree_xlog_mark_page_halfdead)
+ * reinitializes the page, so it needs no separate handling.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
MemSet(&trunctuple, 0, sizeof(IndexTupleData));
trunctuple.t_info = sizeof(IndexTupleData);
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 00000000000..c725f75cd92
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,814 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Leaf key prefix compression for Postgres btrees.
+ *
+ * A leaf page of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page front-codes the first key column of each non-pivot tuple against a
+ * per-page PREFIX item, so that a long key that repeats on a page costs
+ * little more than its unshared tail. The on-disk format is documented at
+ * BTP_PREFIX in nbtree.h; this file is the encoder, decoder, and the policy
+ * that chooses a page's prefix.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo repeats them, and decoding reproduces the plain
+ * tuple byte for byte. A tuple inserted later that shares less of the prefix
+ * just stores a smaller `shared' count, so no insert, VACUUM, deduplication
+ * or change of the page's key range ever has to rewrite the page to keep the
+ * prefix valid. The prefix is chosen only when a page is written as a whole
+ * (CREATE INDEX, or a half of a split).
+ *
+ * This is modeled on the leaf prefix compression of the BARK access method
+ * (src/backend/access/bark/barkutils.c).
+ *
+ *
+ * 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 "storage/bufpage.h"
+#include "utils/rel.h"
+
+/*
+ * Where each part of a tuple goes in its coded form. Coding and decoding
+ * both lay the tuple out from this, so the two cannot disagree.
+ */
+typedef struct BTPrefixPlan
+{
+ Size hoff; /* data offset: header and null bitmap */
+ Size c1; /* plain first column's size, with header */
+ Size keyend; /* end of the plain key (posting offset) */
+ uint8 code; /* first payload byte of the coded column */
+ const char *suffix; /* bytes stored after it */
+ Size suffixlen;
+ Size vsize; /* coded column's size, with header */
+ Size codedkeyend; /* end of the coded key */
+ Size total; /* size of the coded tuple */
+} BTPrefixPlan;
+
+/*
+ * Is `itup' stored coded on a BTP_PREFIX page? Only a non-pivot tuple (plain
+ * or posting) whose first column is not NULL; a pivot (high key, downlink)
+ * is stored as it is.
+ */
+static bool
+_bt_prefix_codable(IndexTuple itup)
+{
+ if (BTreeTupleIsPivot(itup))
+ return false;
+ return !IndexTupleHasNulls(itup) ||
+ !att_isnull(0, (uint8 *) itup + sizeof(IndexTupleData));
+}
+
+/*
+ * Does the varlena `val' have the header decoding would give it back: the
+ * 1-byte header if its data fit one, else an uncompressed 4-byte header?
+ */
+static bool
+_bt_prefix_canonical(const char *val)
+{
+ if (VARATT_IS_SHORT(val))
+ return !VARATT_IS_EXTERNAL(val);
+ return !VARATT_IS_EXTENDED(val) &&
+ VARSIZE(val) - VARHDRSZ + VARHDRSZ_SHORT > VARATT_SHORT_MAX;
+}
+
+/*
+ * End of a leaf tuple's key: the posting-list offset for a posting tuple, the
+ * whole tuple size otherwise. The bytes after the key (a posting list) are
+ * moved as they are.
+ */
+static Size
+_bt_prefix_keyend(IndexTuple itup)
+{
+ if (BTreeTupleIsPosting(itup))
+ return BTreeTupleGetPostingOffset(itup);
+ return IndexTupleSize(itup);
+}
+
+/*
+ * The size of a coded first column whose payload is `payload' bytes: a
+ * varlena with a 1-byte header when it fits one.
+ */
+static inline Size
+_bt_prefix_vsize(Size payload)
+{
+ if (payload + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ return payload + VARHDRSZ_SHORT;
+ return payload + VARHDRSZ;
+}
+
+/*
+ * Offset at which the bytes after the first column go in a coded tuple whose
+ * coded column ends at `vend', when in the plain tuple they start at
+ * `restorig': the first offset at or after `vend' that is the same distance
+ * past a MAXALIGN boundary.
+ */
+static inline Size
+_bt_prefix_restoff(Size vend, Size restorig)
+{
+ return vend + (MAXIMUM_ALIGNOF + restorig % MAXIMUM_ALIGNOF -
+ vend % MAXIMUM_ALIGNOF) % MAXIMUM_ALIGNOF;
+}
+
+/* Plan the coding of the codable tuple `itup' against `prefix'. */
+static void
+_bt_prefix_plan(const char *prefix, Size prefixlen, IndexTuple itup,
+ BTPrefixPlan *plan)
+{
+ const char *val;
+ Size restorig;
+ bool storerest;
+
+ plan->hoff = IndexInfoFindDataOffset(itup->t_info);
+ val = (const char *) itup + plan->hoff;
+ plan->c1 = VARSIZE_ANY(val);
+ plan->keyend = _bt_prefix_keyend(itup);
+
+ if (_bt_prefix_canonical(val))
+ {
+ const char *data = VARDATA_ANY(val);
+ Size datalen = VARSIZE_ANY_EXHDR(val);
+ Size limit = Min(prefixlen, datalen);
+ Size shared = 0;
+
+ while (shared < limit && prefix[shared] == data[shared])
+ shared++;
+ plan->code = (uint8) shared;
+ plan->suffix = data + shared;
+ plan->suffixlen = datalen - shared;
+ }
+ else
+ {
+ plan->code = BTREE_PREFIX_RAW;
+ plan->suffix = val;
+ plan->suffixlen = plan->c1;
+ }
+ plan->vsize = _bt_prefix_vsize(1 + plan->suffixlen);
+
+ /*
+ * The bytes after the first column can be left out when they are just the
+ * zero padding that ends the key.
+ */
+ restorig = plan->hoff + plan->c1;
+ storerest = plan->keyend != MAXALIGN(restorig);
+ for (Size i = restorig; !storerest && i < plan->keyend; i++)
+ storerest = ((const char *) itup)[i] != 0;
+
+ if (storerest)
+ {
+ plan->code |= BTREE_PREFIX_REST;
+ plan->codedkeyend = _bt_prefix_restoff(plan->hoff + plan->vsize,
+ restorig) +
+ (plan->keyend - restorig);
+ }
+ else
+ plan->codedkeyend = MAXALIGN(plan->hoff + plan->vsize);
+ plan->total = plan->codedkeyend + (IndexTupleSize(itup) - plan->keyend);
+}
+
+/*
+ * Copy n bytes, as memcpy. Decoding moves a few short runs of bytes per
+ * tuple; eight bytes at a time is several times faster than a libc memcpy
+ * for runs this short (the string-move startup cost dominates otherwise).
+ */
+static inline void
+_bt_prefix_copy(char *dst, const char *src, Size n)
+{
+ while (n >= sizeof(uint64))
+ {
+ memcpy(dst, src, sizeof(uint64));
+ dst += sizeof(uint64);
+ src += sizeof(uint64);
+ n -= sizeof(uint64);
+ }
+ while (n-- > 0)
+ *dst++ = *src++;
+}
+
+/* The prefix of a BTP_PREFIX page, and its length in *len. */
+const char *
+_bt_page_get_prefix(Page page, Size *len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ IndexTuple item;
+
+ Assert(P_HASPREFIX(opaque));
+ item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page,
+ BTPageGetPrefixOff(opaque)));
+ *len = IndexTupleSize(item) - sizeof(IndexTupleData);
+ return (const char *) item + sizeof(IndexTupleData);
+}
+
+/*
+ * Give `page', a leaf holding nothing yet but its high key (if it has one),
+ * the prefix `prefix' of `len' bytes: add the PREFIX item and set BTP_PREFIX.
+ * Its padding comes from the page's free space, which _bt_pageinit zeroed, so
+ * a page rebuilt in redo is the same.
+ */
+void
+_bt_page_set_prefix(Page page, const char *prefix, Size len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ union
+ {
+ IndexTupleData hdr;
+ char data[sizeof(IndexTupleData) + BTREE_PREFIX_MAX];
+ } item;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+ Assert(len >= 1 && len <= BTREE_PREFIX_MAX);
+ Assert(PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque) - 1);
+
+ memset(&item.hdr, 0, sizeof(IndexTupleData));
+ item.hdr.t_info = (unsigned short) (sizeof(IndexTupleData) + len);
+ memcpy(item.data + sizeof(IndexTupleData), prefix, len);
+ if (PageAddItem(page, item.data, sizeof(IndexTupleData) + len,
+ BTPageGetPrefixOff(opaque), false, false) ==
+ InvalidOffsetNumber)
+ elog(ERROR, "failed to add prefix item to btree page");
+ opaque->btpo_flags |= BTP_PREFIX;
+}
+
+/*
+ * The coded form of the leaf tuple `itup' for `page', palloc'd; `itup' itself
+ * when the page has no prefix or the tuple is stored plain.
+ */
+IndexTuple
+_bt_prefix_encode(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+ char *coded;
+ char *v;
+ char *payload;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ Assert(plan.total <= INDEX_SIZE_MASK);
+
+ coded = palloc0(plan.total);
+ memcpy(coded, itup, plan.hoff);
+ v = coded + plan.hoff;
+ if (plan.vsize <= VARATT_SHORT_MAX)
+ {
+ SET_VARSIZE_SHORT(v, plan.vsize);
+ payload = v + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ SET_VARSIZE(v, plan.vsize);
+ payload = v + VARHDRSZ;
+ }
+ payload[0] = (char) plan.code;
+ memcpy(payload + 1, plan.suffix, plan.suffixlen);
+ if (plan.code & BTREE_PREFIX_REST)
+ {
+ Size restorig = plan.hoff + plan.c1;
+
+ memcpy(coded + _bt_prefix_restoff(plan.hoff + plan.vsize, restorig),
+ (char *) itup + restorig, plan.keyend - restorig);
+ }
+ memcpy(coded + plan.codedkeyend, (char *) itup + plan.keyend,
+ IndexTupleSize(itup) - plan.keyend);
+
+ ((IndexTuple) coded)->t_info =
+ (itup->t_info & ~INDEX_SIZE_MASK) | (unsigned short) plan.total;
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) coded, BTreeTupleGetNPosting(itup),
+ plan.codedkeyend);
+
+#ifdef USE_ASSERT_CHECKING
+ {
+ BTItemBuf check;
+ IndexTuple decoded = _bt_prefix_decode(page, (IndexTuple) coded,
+ &check);
+
+ Assert(IndexTupleSize(decoded) == IndexTupleSize(itup) &&
+ memcmp(decoded, itup, IndexTupleSize(itup)) == 0);
+ }
+#endif
+
+ return (IndexTuple) coded;
+}
+
+/* The MAXALIGNed size `itup' takes on `page', coded if the page codes it. */
+Size
+_bt_prefix_coded_size(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return MAXALIGN(IndexTupleSize(itup));
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ return MAXALIGN(plan.total);
+}
+
+/*
+ * Decode the item `itup' of the BTP_PREFIX page `page' into `buf', and return
+ * it; an item stored plain is returned as it is. The inverse of
+ * _bt_prefix_encode, byte for byte. An item that does not decode within its
+ * own bounds, or claims more of the prefix than the page has, is reported as
+ * corruption.
+ */
+IndexTuple
+_bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf)
+{
+ const char *prefix;
+ Size prefixlen;
+ Size size = IndexTupleSize(itup);
+ Size hoff;
+ Size codedkeyend;
+ const char *v;
+ Size vsize = 0;
+ const char *payload;
+ Size paylen = 0;
+ uint8 code;
+ Size shared;
+ char *out = buf->data;
+ char *val = NULL;
+ Size c1;
+ Size restorig;
+ Size keyend;
+
+ if (!_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ hoff = IndexInfoFindDataOffset(itup->t_info);
+ codedkeyend = _bt_prefix_keyend(itup);
+ v = (const char *) itup + hoff;
+ if (hoff >= codedkeyend || codedkeyend > size ||
+ (vsize = VARSIZE_ANY(v)) > codedkeyend - hoff ||
+ (paylen = VARSIZE_ANY_EXHDR(v)) < 1)
+ goto corrupt;
+ payload = VARDATA_ANY(v);
+ code = (uint8) payload[0];
+ shared = code & BTREE_PREFIX_SHARED_MASK;
+
+ _bt_prefix_copy(out, (const char *) itup, hoff);
+ val = out + hoff;
+ if (shared == BTREE_PREFIX_RAW)
+ {
+ c1 = paylen - 1;
+ _bt_prefix_copy(val, payload + 1, c1);
+ if (c1 < VARHDRSZ_SHORT || VARSIZE_ANY(val) != c1)
+ goto corrupt;
+ }
+ else
+ {
+ Size datalen = shared + paylen - 1;
+ char *data;
+
+ if (hoff + VARHDRSZ + datalen > sizeof(BTItemBuf))
+ goto corrupt;
+ if (shared > prefixlen)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple shares %zu bytes of a %zu-byte page prefix",
+ shared, prefixlen)));
+ if (datalen + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ {
+ c1 = datalen + VARHDRSZ_SHORT;
+ SET_VARSIZE_SHORT(val, c1);
+ data = val + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ c1 = datalen + VARHDRSZ;
+ SET_VARSIZE(val, c1);
+ data = val + VARHDRSZ;
+ }
+ _bt_prefix_copy(data, prefix, shared);
+ _bt_prefix_copy(data + shared, payload + 1, paylen - 1);
+ }
+
+ restorig = hoff + c1;
+ if (code & BTREE_PREFIX_REST)
+ {
+ Size restoff = _bt_prefix_restoff(hoff + vsize, restorig);
+
+ if (restoff > codedkeyend)
+ goto corrupt;
+ keyend = restorig + (codedkeyend - restoff);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ _bt_prefix_copy(out + restorig, (const char *) itup + restoff,
+ codedkeyend - restoff);
+ }
+ else
+ {
+ keyend = MAXALIGN(restorig);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ memset(out + restorig, 0, keyend - restorig);
+ }
+ _bt_prefix_copy(out + keyend, (const char *) itup + codedkeyend,
+ size - codedkeyend);
+
+ ((IndexTuple) out)->t_info = (itup->t_info & ~INDEX_SIZE_MASK) |
+ (unsigned short) (keyend + (size - codedkeyend));
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) out, BTreeTupleGetNPosting(itup),
+ keyend);
+ return (IndexTuple) out;
+
+corrupt:
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple is not a valid prefix-coded tuple")));
+ return NULL; /* keep compiler quiet */
+}
+
+/*
+ * The bytes of `itup''s first column a page prefix may be taken from, and how
+ * many (at most BTREE_PREFIX_MAX) in *len; NULL when the tuple has none: it
+ * is not codable, its value is empty, or it would be stored raw.
+ */
+const char *
+_bt_prefix_candidate(IndexTuple itup, Size *len)
+{
+ const char *val;
+
+ if (!_bt_prefix_codable(itup))
+ return NULL;
+ val = (const char *) itup + IndexInfoFindDataOffset(itup->t_info);
+ if (!_bt_prefix_canonical(val) || VARSIZE_ANY_EXHDR(val) == 0)
+ return NULL;
+ *len = Min(VARSIZE_ANY_EXHDR(val), BTREE_PREFIX_MAX);
+ return VARDATA_ANY(val);
+}
+
+/*
+ * How many bytes of `prefix' the first column of `itup' would share on a page
+ * with that prefix: -1 when the tuple would not be coded, 0 when it would be
+ * stored raw.
+ */
+int
+_bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup)
+{
+ BTPrefixPlan plan;
+
+ if (!_bt_prefix_codable(itup))
+ return -1;
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ if ((plan.code & BTREE_PREFIX_SHARED_MASK) == BTREE_PREFIX_RAW)
+ return 0;
+ return plan.code & BTREE_PREFIX_SHARED_MASK;
+}
+
+/*
+ * May the leaves of `index' have a prefix? Only when the prefix_compression
+ * reloption is on and the first key column is of a varlena type.
+ */
+bool
+_bt_prefix_enabled(Relation index)
+{
+ return BTGetPrefixCompression(index) &&
+ TupleDescAttr(RelationGetDescr(index), 0)->attlen == -1;
+}
+
+/*
+ * Choose the prefix for a leaf about to be built from the `n' tuples `items',
+ * in key order: the first tuple's first-column bytes, cut to the longest run
+ * any later tuple shares, provided the tuples share at least
+ * BTREE_PREFIX_MIN_SHARED bytes of it on average. Returns the prefix's
+ * length, with *prefix pointing into items[0], or 0 for no prefix.
+ */
+Size
+_bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix)
+{
+ const char *cand;
+ Size candlen;
+ Size longest = 0;
+ Size total = 0;
+ int ncoded = 0;
+
+ if (!_bt_prefix_enabled(index) ||
+ (cand = _bt_prefix_candidate(items[0], &candlen)) == NULL)
+ return 0;
+ if (n == 1)
+ {
+ *prefix = cand;
+ return candlen;
+ }
+ for (int i = 1; i < n; i++)
+ {
+ int shared = _bt_prefix_shared(cand, candlen, items[i]);
+
+ if (shared < 0)
+ continue;
+ total += shared;
+ ncoded++;
+ longest = Max(longest, (Size) shared);
+ }
+ if (longest == 0 || total < (Size) BTREE_PREFIX_MIN_SHARED * ncoded)
+ return 0;
+ *prefix = cand;
+ return longest;
+}
+
+/*
+ * Fault a freshly finished leaf `page' forward into the BTP_PREFIX format if
+ * a prefix pays off (Strategy 1: compress a page only when it is written as a
+ * whole -- CREATE INDEX page finish, a split half, a dedup rewrite -- so that
+ * no insert, VACUUM or key-range change ever forces a page rewrite, and an
+ * old-format page is converted only when it is next written whole).
+ *
+ * On entry `page' is a complete leaf: its high key at P_HIKEY if it is not
+ * rightmost, then its data items, none of them coded (the page does not yet
+ * have BTP_PREFIX). If the data items share enough of a common first-column
+ * prefix, the page is rebuilt in place with the PREFIX item inserted after
+ * the high key and every data item coded against it; otherwise the page is
+ * left unchanged. Item order and the high key are preserved.
+ *
+ * Caller must hold the page's buffer exclusively (or own the page outright,
+ * as the build and split paths do with a temp page). No WAL is written here:
+ * callers log the resulting page (a build writes it whole; a split logs each
+ * half whole or by full-page image), and redo reaches the same bytes by
+ * running _bt_prefix_encode against the same prefix.
+ */
+void
+_bt_prefix_compress_page(Relation index, Page page)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber minoff;
+ OffsetNumber maxoff;
+ int n;
+ IndexTuple *items;
+ Size prefixlen;
+ const char *prefix;
+ Page tmp;
+ OffsetNumber o;
+ IndexTuple hikey = NULL;
+ Size hikeysz = 0;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+
+ if (!_bt_prefix_enabled(index))
+ return;
+
+ minoff = P_FIRSTDATAKEY(opaque);
+ maxoff = PageGetMaxOffsetNumber(page);
+ n = (int) maxoff - (int) minoff + 1;
+ if (n <= 0)
+ return;
+
+ items = (IndexTuple *) palloc(n * sizeof(IndexTuple));
+ for (int i = 0; i < n; i++)
+ items[i] = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, minoff + i));
+
+ prefixlen = _bt_prefix_choose(index, items, n, &prefix);
+ if (prefixlen == 0)
+ {
+ pfree(items);
+ return;
+ }
+
+ /* The high key (if any) stays at P_HIKEY, uncoded. */
+ if (!P_RIGHTMOST(opaque))
+ {
+ ItemId hii = PageGetItemId(page, P_HIKEY);
+
+ hikeysz = ItemIdGetLength(hii);
+ hikey = (IndexTuple) PageGetItem(page, hii);
+ }
+
+ /*
+ * Rebuild into a temp page: high key (if any), PREFIX item, then each
+ * data item coded. The coded form can be up to MAXALIGN larger than the
+ * plain one, but the PREFIX item replaces far more shared bytes than it
+ * adds, so a page that was not overfull stays within bounds; if a coded
+ * item somehow does not fit, leave the page uncompressed (always valid).
+ */
+ tmp = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(tmp)->btpo_flags &= ~BTP_PREFIX;
+ if (hikey != NULL &&
+ PageAddItem(tmp, (char *) hikey, hikeysz, P_HIKEY, false, false) ==
+ InvalidOffsetNumber)
+ goto giveup;
+ _bt_page_set_prefix(tmp, prefix, prefixlen);
+ o = OffsetNumberNext(PageGetMaxOffsetNumber(tmp));
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(tmp, items[i]);
+ bool ok;
+
+ ok = PageAddItem(tmp, (char *) coded, IndexTupleSize(coded), o++,
+ false, false) != InvalidOffsetNumber;
+ if (coded != items[i])
+ pfree(coded);
+ if (!ok)
+ goto giveup;
+ }
+
+ memcpy(page, tmp, BLCKSZ);
+ pfree(tmp);
+ pfree(items);
+ return;
+
+giveup:
+ pfree(tmp);
+ pfree(items);
+}
+
+/*
+ * Lay out one half of a leaf split on `page', which the caller has
+ * initialized (flags, links) and left empty: the high key `hikey' (NULL for
+ * a rightmost half), then the `n' plain tuples `items' in key order.
+ *
+ * The half is formed as a whole, so it may take a prefix of its own: its
+ * first item's leading bytes, if the items share enough (_bt_prefix_choose).
+ * Because a prefix-compressed leaf can hold more logical data than fits in a
+ * plain page, the half is laid out coded from the start rather than built
+ * plain and compressed afterwards. When the half does not fit with its own
+ * prefix (or shares too little to take one) it is laid out plain, and if that
+ * does not fit either, with `origprefix' (the splitting page's prefix, zero
+ * length when it had none), against which it fits by construction: the items
+ * keep the shares they had on the original page.
+ *
+ * Mirrors BARK's bark_split_leaf_half. Callers: _bt_split (both halves).
+ */
+void
+_bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen)
+{
+ BTPageOpaqueData saved = *BTPageGetOpaque(page);
+ const char *prefix[3];
+ Size prefixlen[3];
+ int ntries = 0;
+
+ Assert(P_ISLEAF(&saved) && !P_HASPREFIX(&saved));
+
+ /* Candidate prefixes to try, in order: own, none, the orig page's. */
+ if (_bt_prefix_enabled(index))
+ {
+ prefixlen[ntries] = _bt_prefix_choose(index, items, n, &prefix[ntries]);
+ if (prefixlen[ntries] > 0)
+ ntries++;
+ }
+ prefix[ntries] = NULL;
+ prefixlen[ntries++] = 0;
+ if (origprefixlen > 0)
+ {
+ prefix[ntries] = origprefix;
+ prefixlen[ntries++] = origprefixlen;
+ }
+
+ for (int t = 0; t < ntries; t++)
+ {
+ if (_bt_split_leaf_layout(page, hikey, items, n, prefix[t], prefixlen[t]))
+ {
+ /* Report the prefix that won, so the split can log it for redo. */
+ if (chosenprefixlen != NULL)
+ {
+ *chosenprefixlen = prefixlen[t];
+ if (prefixlen[t] > 0)
+ memcpy(chosenprefix, prefix[t], prefixlen[t]);
+ }
+ return;
+ }
+ /* restore the empty-page state for the next attempt */
+ _bt_pageinit(page, BLCKSZ);
+ *BTPageGetOpaque(page) = saved;
+ }
+ elog(ERROR, "failed to lay out a split half of index \"%s\"",
+ RelationGetRelationName(index));
+}
+
+/*
+ * Lay out one leaf split half on the caller-initialized empty `page' using a
+ * GIVEN prefix (`prefixlen' 0 for none): the high key `hikey' (NULL for a
+ * rightmost half), the PREFIX item, then the `n' plain tuples `items' coded
+ * against the prefix. Returns false if they do not fit (caller tries another
+ * prefix). This is Relation-free and deterministic in (hikey, items, prefix),
+ * so WAL redo calls it with the logged prefix to reproduce the primary's page
+ * byte for byte.
+ */
+bool
+_bt_split_leaf_layout(Page page, IndexTuple hikey, IndexTuple *items, int n,
+ const char *prefix, Size prefixlen)
+{
+ OffsetNumber o = P_HIKEY;
+ bool fit = true;
+
+ Assert(P_ISLEAF(BTPageGetOpaque(page)) && !P_HASPREFIX(BTPageGetOpaque(page)));
+ if (hikey != NULL &&
+ PageAddItem(page, (char *) hikey, MAXALIGN(IndexTupleSize(hikey)),
+ o++, false, false) == InvalidOffsetNumber)
+ return false;
+ if (prefixlen > 0)
+ {
+ _bt_page_set_prefix(page, prefix, prefixlen);
+ o++;
+ }
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(page, items[i]);
+
+ if (PageAddItem(page, (char *) coded, IndexTupleSize(coded),
+ o++, false, false) == InvalidOffsetNumber)
+ fit = false;
+ if (coded != items[i])
+ pfree(coded);
+ if (!fit)
+ return false;
+ }
+#ifdef USE_ASSERT_CHECKING
+ if (P_HASPREFIX(BTPageGetOpaque(page)))
+ {
+ OffsetNumber mx = PageGetMaxOffsetNumber(page);
+ OffsetNumber off;
+
+ for (off = P_FIRSTDATAKEY(BTPageGetOpaque(page)); off <= mx; off++)
+ {
+ BTItemBuf vb;
+
+ (void) _bt_getitem(page, off, &vb);
+ }
+ }
+#endif
+ return true;
+}
+
+/*
+ * Collect the plain (decoded) data items of a leaf split into the left and
+ * right arrays, in the exact order and with the exact substitutions that the
+ * split performs: the original page's data items split at `firstrightoff',
+ * with `newitem' inserted at `newitemoff' on the side given by `newitemonleft'
+ * and, for a posting-list split, the posting at `origpagepostingoff' replaced
+ * by `nposting'. `origpage' is the pre-split page (its items are decoded when
+ * it is a BTP_PREFIX page). The arrays receive pointers that are valid until
+ * the caller is done laying out the halves; items taken from the page are
+ * copied, so the caller owns and should reset the surrounding memory context.
+ *
+ * Both the primary (_bt_split) and WAL redo (btree_xlog_split) collect items
+ * this way and then lay out each half with _bt_split_leaf_half, so the two
+ * produce byte-identical coded pages without logging a full-page image.
+ */
+void
+_bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft, OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright)
+{
+ BTPageOpaque oopaque = BTPageGetOpaque(origpage);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(origpage);
+ bool hasprefix = P_HASPREFIX(oopaque);
+ OffsetNumber i;
+
+ *nleft = 0;
+ *nright = 0;
+ for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
+ {
+ ItemId itemid = PageGetItemId(origpage, i);
+ IndexTuple dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ BTItemBuf dibuf;
+
+ if (hasprefix)
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+
+ if (i == origpagepostingoff)
+ dataitem = nposting;
+ else if (i == newitemoff)
+ {
+ if (newitemonleft)
+ leftitems[(*nleft)++] = newitem;
+ else
+ rightitems[(*nright)++] = newitem;
+ }
+
+ if (i < firstrightoff)
+ leftitems[(*nleft)++] = CopyIndexTuple(dataitem);
+ else
+ rightitems[(*nright)++] = CopyIndexTuple(dataitem);
+ }
+ if (i <= newitemoff)
+ rightitems[(*nright)++] = newitem;
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..ffc5846e4c9 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -145,6 +145,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ BTItemBuf itupbuf; /* decode workspace for a loop's current item */
+ BTItemBuf finalbuf; /* decode workspace for a backward finaltup */
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -252,7 +254,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
continue;
}
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -353,9 +355,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,7 +415,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
else
tuple_alive = true;
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -596,9 +596,10 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
BTScanOpaque so = (BTScanOpaque) scan->opaque;
Relation rel = scan->indexRelation;
TupleDesc tupdesc = RelationGetDescr(rel);
- ItemId iid;
IndexTuple firsttup,
lasttup;
+ BTItemBuf firstbuf,
+ lastbuf;
int startikey = 0,
arrayidx = 0,
firstchangingattnum;
@@ -616,12 +617,10 @@ _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);
+ 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 +2052,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
ScanDirection dir = pstate->dir;
OffsetNumber aheadoffnum;
IndexTuple ahead;
+ BTItemBuf aheadbuf; /* decode workspace for the look-ahead tuple */
Assert(!pstate->forcenonrequired);
@@ -2088,8 +2088,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..b8b8ec018f3 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -435,8 +435,10 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ Size half = BTTupleStorageSize(scan->indexRelation);
+
+ so->currTuples = (char *) palloc(half * 2);
+ so->markTuples = so->currTuples + half;
}
/*
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..f932ca33c3a 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -696,6 +696,7 @@ _bt_compare(Relation rel,
TupleDesc itupdesc = RelationGetDescr(rel);
BTPageOpaque opaque = BTPageGetOpaque(page);
IndexTuple itup;
+ BTItemBuf itupbuf;
ItemPointer heapTid;
ScanKey scankey;
int ncmpkey;
@@ -713,7 +714,7 @@ _bt_compare(Relation rel,
if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque))
return 1;
- itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ itup = _bt_getitem(page, offnum, &itupbuf);
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dcf4..9a8b372033e 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -241,6 +241,10 @@ typedef struct BTPageState
Size btps_lastextra; /* last item's extra posting list space */
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
+ /* Leaf prefix compression: the candidate prefix of the page being filled */
+ const char *btps_prefix; /* into the page's first data item, or NULL */
+ Size btps_prefixlen; /* its length (0 = no candidate) */
+ Size btps_codedsaved; /* bytes the page's items would save coded */
struct BTPageState *btps_next; /* link to parent level, if any */
} BTPageState;
@@ -856,7 +860,8 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
*/
Assert(last_truncextra == 0 || isleaf);
if (pgspc < itupsz + (isleaf ? MAXALIGN(sizeof(ItemPointerData)) : 0) ||
- (pgspc + last_truncextra < state->btps_full && last_off > P_FIRSTKEY))
+ (pgspc + last_truncextra + state->btps_codedsaved < state->btps_full &&
+ last_off > P_FIRSTKEY))
{
/*
* Finish off the page and write it out.
@@ -980,6 +985,15 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
nopaque->btpo_next = P_NONE; /* redundant */
}
+ /*
+ * The old leaf page is complete: fault it forward into the
+ * BTP_PREFIX format if a prefix pays off. Done after btps_lowkey
+ * (the downlink, a copy of the uncoded high key) is saved, since
+ * compression rewrites opage in place.
+ */
+ if (isleaf)
+ _bt_prefix_compress_page(wstate->index, opage);
+
/*
* Write out the old page. _bt_blwritepage takes ownership of the
* 'opage' buffer.
@@ -990,6 +1004,11 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
* Reset last_off to point to new page
*/
last_off = P_FIRSTKEY;
+
+ /* The new page starts with no prefix candidate and no savings yet. */
+ state->btps_prefix = NULL;
+ state->btps_prefixlen = 0;
+ state->btps_codedsaved = 0;
}
/*
@@ -1017,6 +1036,34 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_sortaddtup(npage, itupsz, itup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Leaf prefix compression accounting (Strategy 1): the page's eventual
+ * prefix is its first data item's first-column bytes; estimate how much
+ * room coding this page's items would free, so the fillfactor test above
+ * can pack more plain items onto a page that will shrink when it is
+ * compressed at finish. Items are still placed plain here; the estimate
+ * only relaxes the soft limit, never the hard fit check.
+ */
+ if (isleaf && _bt_prefix_enabled(wstate->index))
+ {
+ if (state->btps_prefix == NULL)
+ state->btps_prefix = _bt_prefix_candidate(itup,
+ &state->btps_prefixlen);
+ else
+ {
+ int shared = _bt_prefix_shared(state->btps_prefix,
+ state->btps_prefixlen, itup);
+
+ /*
+ * A coded item drops `shared' value bytes but keeps a 1-byte
+ * share count; count the net MAXALIGN-rounded saving.
+ */
+ if (shared > 1)
+ state->btps_codedsaved +=
+ MAXALIGN(itupsz) - MAXALIGN(itupsz - (shared - 1));
+ }
+ }
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1115,6 +1162,11 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
* back one slot. Then we can dump out the page.
*/
_bt_slideleft((Page) s->btps_buf);
+
+ /* Fault the finished rightmost leaf forward into BTP_PREFIX if it pays. */
+ if (s->btps_level == 0)
+ _bt_prefix_compress_page(wstate->index, (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/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8b2..14bee568e45 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -52,6 +52,9 @@ typedef struct
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
+ bool hasprefix; /* origpage is prefix-compressed (BTP_PREFIX) */
+ BTItemBuf lbuf; /* decode scratch for _bt_split_lastleft */
+ BTItemBuf rbuf; /* decode scratch for _bt_split_firstright */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
@@ -172,6 +175,23 @@ _bt_findsplitloc(Relation rel,
olddataitemstotal = rightspace - (int) PageGetExactFreeSpace(origpage);
leaffillfactor = BTGetFillFactor(rel);
+ /*
+ * On a prefix-compressed leaf the page carries an extra PREFIX item that
+ * is not a data item. The split is reasoned about in CODED (on-page)
+ * bytes -- the physical reality of the page and of each recompressed half
+ * -- so subtract only the PREFIX item's space from olddataitemstotal and
+ * use the on-page (coded) item sizes in the scan below. (Plain-size
+ * accounting is not usable here: a compressed leaf can hold far more
+ * logical data than two plain pages, so a plain split can be infeasible.)
+ */
+ if (P_HASPREFIX(opaque))
+ {
+ ItemId pii = PageGetItemId(origpage, BTPageGetPrefixOff(opaque));
+
+ olddataitemstotal -= (int) (MAXALIGN(ItemIdGetLength(pii)) +
+ sizeof(ItemIdData));
+ }
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
state.rel = rel;
@@ -185,6 +205,7 @@ _bt_findsplitloc(Relation rel,
state.olddataitemstotal = olddataitemstotal;
state.minfirstrightsz = SIZE_MAX;
state.newitemoff = newitemoff;
+ state.hasprefix = P_HASPREFIX(opaque);
/* newitem cannot be a posting list item */
Assert(!BTreeTupleIsPosting(newitem));
@@ -463,6 +484,19 @@ _bt_recsplitloc(FindSplitData *state,
newitemisfirstright = (firstrightoff == state->newitemoff &&
!newitemonleft);
+ /*
+ * Reject a split that would leave the left page with no data items at all
+ * (firstrightoff at the first data key, with newitem not on the left).
+ * Upstream's split-point heuristics never choose such a point for a plain
+ * page; on a prefix-compressed page the coded-size accounting can make it
+ * look attractive, and _bt_split cannot form a left high key from a
+ * nonexistent lastleft.
+ */
+ if (state->hasprefix &&
+ firstrightoff == P_FIRSTDATAKEY(BTPageGetOpaque(state->origpage)) &&
+ !newitemonleft)
+ return;
+
if (newitemisfirstright)
firstrightsz = state->newitemsz;
else
@@ -686,8 +720,12 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
if (state->newitemoff > maxoff)
{
+ BTItemBuf tbuf;
+
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ if (state->hasprefix)
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
@@ -714,12 +752,20 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
+ /* Do cheaper test first (t_tid and the posting bit survive coding) */
if (BTreeTupleIsPosting(tup) ||
!_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
return false;
/* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ if (state->hasprefix)
+ {
+ BTItemBuf tbuf;
+
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ }
+ else
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -1160,12 +1206,38 @@ static inline IndexTuple
_bt_split_lastleft(FindSplitData *state, SplitPoint *split)
{
ItemId itemid;
+ OffsetNumber off;
if (split->newitemonleft && split->firstrightoff == state->newitemoff)
return state->newitem;
- itemid = PageGetItemId(state->origpage,
- OffsetNumberPrev(split->firstrightoff));
+ off = OffsetNumberPrev(split->firstrightoff);
+
+ /*
+ * On a prefix-compressed leaf the PREFIX item sits just before the first
+ * data item. When the split boundary is at the first data key, the data
+ * predecessor is the high key on a plain page (a pivot, read plain); a
+ * rightmost page has none, so fall back to the first data item (this only
+ * feeds penalty estimation, never placement). Never read the PREFIX item
+ * as a data tuple.
+ */
+ if (state->hasprefix)
+ {
+ BTPageOpaque op = BTPageGetOpaque(state->origpage);
+
+ if (off == BTPageGetPrefixOff(op))
+ {
+ if (!P_RIGHTMOST(op))
+ return (IndexTuple) PageGetItem(state->origpage,
+ PageGetItemId(state->origpage, P_HIKEY));
+ off = P_FIRSTDATAKEY(op);
+ }
+ itemid = PageGetItemId(state->origpage, off);
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->lbuf);
+ }
+ itemid = PageGetItemId(state->origpage, off);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
@@ -1181,5 +1253,9 @@ _bt_split_firstright(FindSplitData *state, SplitPoint *split)
return state->newitem;
itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ if (state->hasprefix)
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->rbuf);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
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..41bda6aba06 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -243,6 +243,211 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting,
_bt_restore_meta(record, 2);
}
+/*
+ * Replay a prefix-compressed leaf page split.
+ *
+ * Rather than replay logged tuples (whose coded physical layout
+ * _bt_restore_page could not reproduce), rebuild both halves the way the
+ * primary did: collect the split's plain items from the pre-split original
+ * page (block 0) with _bt_split_leaf_collect, then lay each half out coded
+ * against its chosen prefix (carried in the record) with _bt_split_leaf_layout.
+ * The result is byte-identical to the primary, so no full-page image is
+ * needed. The record's block-0 data carries, in order: newitem (when it went
+ * on the left, or for a posting split), the left high key, and the left half's
+ * prefix bytes; block-1 data carries the right half's prefix bytes.
+ */
+static void
+btree_xlog_split_prefix(XLogReaderState *record, BlockNumber origpagenumber,
+ BlockNumber rightpagenumber, BlockNumber spagenumber,
+ bool newitemonleft)
+{
+ XLogRecPtr lsn = record->EndRecPtr;
+ xl_btree_split *xlrec = (xl_btree_split *) XLogRecGetData(record);
+ Buffer rbuf;
+ Buffer origbuf;
+ Page origpage;
+ BTPageOpaque oopaque;
+ char *datapos;
+ Size datalen;
+ IndexTuple newitem = NULL;
+ IndexTuple left_hikey;
+ IndexTuple right_hikey = NULL;
+ IndexTuple nposting = NULL;
+ Size newitemsz = 0;
+ const char *leftprefix = NULL;
+ const char *rightprefix = NULL;
+ IndexTuple rightitems[MaxIndexTuplesPerPage];
+ int nright = 0;
+ Page rightpage;
+ BTPageOpaque ropaque;
+ XLogRedoAction origaction;
+
+ /*
+ * The right (new) page is rebuilt entirely from data logged with the
+ * record, independent of block 0, because block 0 may be restored from an
+ * automatic full-page image (so its live contents after redo are the left
+ * half, not the pre-split page). Block-1 data is, in order: the right
+ * half's chosen prefix bytes (rightprefixlen), the right high key when the
+ * split is not rightmost (spagenumber != P_NONE), then the right half's
+ * plain data items in offset order (self-describing by IndexTupleSize).
+ */
+ datapos = XLogRecGetBlockData(record, 1, &datalen);
+ if (xlrec->flags & XLH_SPLIT_RFPI)
+ {
+ /*
+ * The right half's plain items would have overflowed the WAL record,
+ * so the primary carried the coded right page as a forced full-page
+ * image. Restore it from that image.
+ */
+ if (XLogReadBufferForRedo(record, 1, &rbuf) != BLK_RESTORED)
+ elog(ERROR, "prefix leaf split right page lacks its full-page image");
+ }
+ else
+ {
+ if (xlrec->rightprefixlen > 0)
+ {
+ rightprefix = datapos;
+ datapos += xlrec->rightprefixlen;
+ datalen -= xlrec->rightprefixlen;
+ }
+ if (spagenumber != P_NONE)
+ {
+ right_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(right_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(right_hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nright < MaxIndexTuplesPerPage);
+ rightitems[nright++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ rbuf = XLogInitBufferForRedo(record, 1);
+ rightpage = BufferGetPage(rbuf);
+ _bt_pageinit(rightpage, BufferGetPageSize(rbuf));
+ ropaque = BTPageGetOpaque(rightpage);
+ ropaque->btpo_prev = origpagenumber;
+ ropaque->btpo_next = spagenumber;
+ ropaque->btpo_level = xlrec->level;
+ ropaque->btpo_flags = BTP_LEAF;
+ ropaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(rightpage, right_hikey, rightitems, nright,
+ rightprefix, xlrec->rightprefixlen))
+ elog(ERROR, "failed to replay right half of a prefix leaf split");
+ PageSetLSN(rightpage, lsn);
+ }
+ MarkBufferDirty(rbuf);
+
+ /*
+ * Rebuild the left (original) page from the pre-split page, unless it was
+ * restored from a full-page image (then it is already the final left
+ * half). Block-0 data is: [newitem (left/posting)] [left high key]
+ * [left prefix]. The left plain items come from the pre-split page.
+ */
+ origaction = XLogReadBufferForRedo(record, 0, &origbuf);
+ origpage = BufferGetPage(origbuf);
+
+ if (origaction == BLK_NEEDS_REDO)
+ {
+ IndexTuple *leftitems;
+ int nleft;
+ OffsetNumber origpagepostingoff = InvalidOffsetNumber;
+ Page leftpage;
+ BTPageOpaque lopaque;
+
+ oopaque = BTPageGetOpaque(origpage);
+
+ datapos = XLogRecGetBlockData(record, 0, &datalen);
+ if (newitemonleft || xlrec->postingoff != 0)
+ {
+ newitem = (IndexTuple) datapos;
+ newitemsz = MAXALIGN(IndexTupleSize(newitem));
+ datapos += newitemsz;
+ datalen -= newitemsz;
+ }
+ left_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(left_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(left_hikey));
+ if (xlrec->leftprefixlen > 0)
+ {
+ leftprefix = datapos;
+ datapos += xlrec->leftprefixlen;
+ datalen -= xlrec->leftprefixlen;
+ }
+ Assert(datalen == 0);
+
+ if (xlrec->postingoff != 0)
+ {
+ ItemId itemid;
+ IndexTuple oposting;
+ BTItemBuf obuf;
+
+ origpagepostingoff = OffsetNumberPrev(xlrec->newitemoff);
+ newitem = CopyIndexTuple(newitem);
+ itemid = PageGetItemId(origpage, origpagepostingoff);
+ oposting = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ oposting = _bt_prefix_decode(origpage, oposting, &obuf);
+ nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff);
+ }
+
+ leftitems = palloc_array(IndexTuple, PageGetMaxOffsetNumber(origpage) + 2);
+ rightitems[0] = NULL; /* not reused below */
+ {
+ IndexTuple dummyright[MaxIndexTuplesPerPage];
+ int dummynr;
+
+ _bt_split_leaf_collect(origpage, xlrec->firstrightoff,
+ xlrec->newitemoff, newitem, newitemonleft,
+ origpagepostingoff, nposting,
+ leftitems, &nleft, dummyright, &dummynr);
+ }
+
+ leftpage = PageGetTempPageCopySpecial(origpage);
+ lopaque = BTPageGetOpaque(leftpage);
+ lopaque->btpo_flags = BTP_LEAF | BTP_INCOMPLETE_SPLIT;
+ lopaque->btpo_prev = oopaque->btpo_prev;
+ lopaque->btpo_next = rightpagenumber;
+ lopaque->btpo_level = xlrec->level;
+ lopaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(leftpage, left_hikey, leftitems, nleft,
+ leftprefix, xlrec->leftprefixlen))
+ elog(ERROR, "failed to replay left half of a prefix leaf split");
+ PageRestoreTempPage(leftpage, origpage);
+ PageSetLSN(origpage, lsn);
+ MarkBufferDirty(origbuf);
+ pfree(leftitems);
+ }
+
+ /* Fix left-link of the page to the right of the new right sibling. */
+ if (spagenumber != P_NONE)
+ {
+ Buffer sbuf;
+
+ if (XLogReadBufferForRedo(record, 2, &sbuf) == BLK_NEEDS_REDO)
+ {
+ Page spage = BufferGetPage(sbuf);
+ BTPageOpaque spageop = BTPageGetOpaque(spage);
+
+ spageop->btpo_prev = rightpagenumber;
+ PageSetLSN(spage, lsn);
+ MarkBufferDirty(sbuf);
+ }
+ if (BufferIsValid(sbuf))
+ UnlockReleaseBuffer(sbuf);
+ }
+
+ UnlockReleaseBuffer(rbuf);
+ if (BufferIsValid(origbuf))
+ UnlockReleaseBuffer(origbuf);
+}
+
static void
btree_xlog_split(bool newitemonleft, XLogReaderState *record)
{
@@ -264,6 +469,18 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
if (!XLogRecGetBlockTagExtended(record, 2, NULL, NULL, &spagenumber, NULL))
spagenumber = P_NONE;
+ /*
+ * A prefix-compressed leaf split is replayed by rebuilding both halves
+ * from the pre-split original page, exactly as the primary did, so the
+ * coded pages come out byte-identical without a full-page image.
+ */
+ if (xlrec->flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ btree_xlog_split_prefix(record, origpagenumber, rightpagenumber,
+ spagenumber, newitemonleft);
+ return;
+ }
+
/*
* Clear the incomplete split flag on the appropriate child page one level
* down when origpage/buf is an internal page (there must have been
@@ -280,7 +497,7 @@ 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 the logged tuples. */
rbuf = XLogInitBufferForRedo(record, 1);
datapos = XLogRecGetBlockData(record, 1, &datalen);
rpage = BufferGetPage(rbuf);
@@ -468,6 +685,56 @@ btree_xlog_dedup(XLogReaderState *record)
BTDedupState state;
BTDedupInterval *intervals;
Page newpage;
+ bool pageprefix; /* source page is BTP_PREFIX */
+ BTItemBuf curbuf; /* decode scratch for the current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
+
+ if (xlrec->prefixlen > 0)
+ {
+ /*
+ * Prefix-dedup: the deduped result was carried as the chosen prefix
+ * bytes, then (unless rightmost) the high key, then the deduped
+ * plain items. Rebuild the coded page byte-for-byte the way the
+ * primary did, using the same layout routine as a prefix leaf split.
+ */
+ Size datalen;
+ char *datapos = XLogRecGetBlockData(record, 0, &datalen);
+ const char *prefix = datapos;
+ IndexTuple hikey = NULL;
+ IndexTuple items[MaxIndexTuplesPerPage];
+ int nitems = 0;
+
+ datapos += xlrec->prefixlen;
+ datalen -= xlrec->prefixlen;
+ if (xlrec->flags & XLH_DEDUP_NOT_RIGHTMOST)
+ {
+ hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(hikey));
+ datalen -= MAXALIGN(IndexTupleSize(hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nitems < MaxIndexTuplesPerPage);
+ items[nitems++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ newpage = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(newpage)->btpo_flags &= ~(BTP_PREFIX | BTP_HAS_GARBAGE);
+ if (!_bt_split_leaf_layout(newpage, hikey, items, nitems,
+ prefix, xlrec->prefixlen))
+ elog(ERROR, "failed to replay prefix deduplication");
+ PageRestoreTempPage(newpage, page);
+ PageSetLSN(page, lsn);
+ MarkBufferDirty(buf);
+ UnlockReleaseBuffer(buf);
+ return;
+ }
state = palloc_object(BTDedupStateData);
state->deduplicate = true; /* unused */
@@ -487,6 +754,19 @@ btree_xlog_dedup(XLogReaderState *record)
maxoff = PageGetMaxOffsetNumber(page);
newpage = PageGetTempPageCopySpecial(page);
+ /*
+ * The source page may be prefix-compressed even on the ordinary
+ * (non-prefix) dedup path: that happens when the primary deduplicated
+ * a prefix page but re-compression of the result declined, leaving a
+ * plain page (prefixlen == 0, no full-page image). Reproduce the
+ * primary: lay the result out plain and decode each coded data item
+ * before feeding it to the dedup state machine. The high key is a
+ * pivot and is never coded.
+ */
+ pageprefix = P_HASPREFIX(opaque);
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
if (!P_RIGHTMOST(opaque))
{
ItemId itemid = PageGetItemId(page, P_HIKEY);
@@ -505,8 +785,18 @@ btree_xlog_dedup(XLogReaderState *record)
ItemId itemid = PageGetItemId(page, offnum);
IndexTuple itup = (IndexTuple) PageGetItem(page, itemid);
+ /* Decode on a prefix source so keys/posting lists are real. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
+ {
+ /* base must survive the whole run; keep it in its own buffer */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
+ }
else if (state->nintervals < xlrec->nintervals &&
state->baseoff == intervals[state->nintervals].baseoff &&
state->nitems < intervals[state->nintervals].nitems)
@@ -516,12 +806,15 @@ btree_xlog_dedup(XLogReaderState *record)
}
else
{
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
Assert(state->nintervals == xlrec->nintervals);
Assert(memcmp(state->intervals, intervals,
state->nintervals * sizeof(BTDedupInterval)) == 0);
@@ -621,11 +914,23 @@ btree_xlog_vacuum(XLogReaderState *record)
if (xlrec->ndeleted > 0)
PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * Mirror the primary: an emptied prefix-compressed leaf drops its
+ * PREFIX item and the BTP_PREFIX flag (see _bt_delitems_vacuum).
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* Clear the vacuum cycle ID, and mark the page as not containing any
* LP_DEAD items
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..ca7e6852542 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,61 @@ 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 format and code).
+ *
+ * A leaf of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page holds one extra item, the PREFIX item, at BTPageGetPrefixOff (where
+ * its first data item would otherwise be); its data items start one offset
+ * later, so P_FIRSTDATAKEY -- which every loop over a page's data items
+ * starts from -- accounts for the flag. The PREFIX item is an
+ * IndexTupleData header (t_tid zero, t_info its size and nothing else, so
+ * that code walking a page's items by IndexTupleSize, such as split redo,
+ * steps over it) followed by 1..BTREE_PREFIX_MAX prefix bytes. The prefix is
+ * chosen only when a page is written whole (CREATE INDEX, or a half of a
+ * split) and never changes after that; a page's high key, which fixes the
+ * prefix item's offset, also changes only in a split.
+ *
+ * On a BTP_PREFIX page, every non-pivot tuple (plain or posting) whose first
+ * key column is not NULL stores that column coded, as a varlena (1-byte
+ * header when it fits one, else 4-byte) whose payload starts with a uint8
+ * `shared':
+ *
+ * shared <= BTREE_PREFIX_MAX: the value's data bytes are the page prefix's
+ * first `shared' bytes followed by the rest of the payload.
+ * The value's own varlena header is recomputed on decode (the
+ * form index_form_tuple gives it: 1-byte when it fits, else
+ * 4-byte uncompressed).
+ * shared == BTREE_PREFIX_RAW: the rest of the payload is the value exactly
+ * as stored, header included. Used for a value compressed
+ * inline, or whose header is not the canonical form above.
+ *
+ * The bytes after the first column (later key columns, INCLUDE columns, the
+ * posting list, and alignment padding) are kept as they are, moved to keep
+ * their alignment, or left out when they are only the key's closing zero
+ * padding (decode puts them back). A posting list follows the key at the new
+ * key end, and the posting offset in t_tid is changed to match. Pivots, NULL
+ * first columns, and the high key are stored as on any other page.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo can repeat them, and decoding gives back the plain
+ * tuple byte for byte.
+ */
+#define BTREE_PREFIX_MAX 126
+#define BTREE_PREFIX_RAW 127 /* shared count of a raw value */
+#define BTREE_PREFIX_SHARED_MASK 0x7F /* shared count bits */
+#define BTREE_PREFIX_REST 0x80 /* bytes after the column follow */
+
+/* A leaf whose tuples share less of its prefix than this, on average, has none. */
+#define BTREE_PREFIX_MIN_SHARED 4
+
+#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 +732,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.
@@ -1122,6 +1208,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 +1224,33 @@ 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))
+
+/*
+ * Size of one half (currTuples or markTuples) of an index-only scan's tuple
+ * storage. A plain leaf's live non-pivot tuples fit in one page, so BLCKSZ
+ * would suffice for them. A prefix-compressed (BTP_PREFIX) leaf can hold more
+ * logical data than fits plain: when its tuples are decoded for the scan they
+ * can exceed BLCKSZ. Because _bt_getitem() decodes based on the page flag,
+ * not on the current reloption, and a BTP_PREFIX page can persist after
+ * prefix_compression is turned off (the flag is cleared only when a page is
+ * split, emptied or dedup-rewritten -- never by ALTER INDEX), ANY btree index
+ * may contain such a page. The buffer must therefore always reserve the slack
+ * for a decoded page, independent of the live reloption; sizing it on the
+ * option would overflow currTuples/markTuples on a surviving prefix leaf.
+ *
+ * The bound is the most distinct index tuples a leaf can hold (each at least a
+ * line pointer plus a minimally coded tuple) times the most a single first
+ * column can regain on decode (the page prefix, at most BTREE_PREFIX_MAX
+ * bytes, MAXALIGNed).
+ */
+#define BTTupleStorageSize(relation) \
+ (BLCKSZ + (BLCKSZ / (int) MAXALIGN(sizeof(IndexTupleData) + 2)) * \
+ (int) MAXALIGN(BTREE_PREFIX_MAX))
/*
* Constant definition for progress reporting. Phase numbers must match
@@ -1202,7 +1316,8 @@ extern bool _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
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 Size _bt_dedup_finish_pending(Page newpage, BTDedupState state,
+ bool overflowok);
extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids,
int nhtids);
extern void _bt_update_posting(BTVacuumPosting vacposting);
@@ -1315,6 +1430,34 @@ 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 void _bt_page_set_prefix(Page page, const char *prefix, Size len);
+extern IndexTuple _bt_prefix_encode(Page page, IndexTuple itup);
+extern Size _bt_prefix_coded_size(Page page, IndexTuple itup);
+extern bool _bt_prefix_enabled(Relation index);
+extern const char *_bt_prefix_candidate(IndexTuple itup, Size *len);
+extern int _bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup);
+extern Size _bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix);
+extern void _bt_prefix_compress_page(Relation index, Page page);
+extern void _bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen);
+extern bool _bt_split_leaf_layout(Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *prefix, Size prefixlen);
+extern void _bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft,
+ OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright);
+
/*
* prototypes for functions in nbtvalidate.c
*/
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..285eac6f1aa 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -156,25 +156,59 @@ 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 */
+ uint16 flags; /* XLH_SPLIT_* below */
+ uint8 leftprefixlen; /* leaf: left half's prefix length (0 none) */
+ uint8 rightprefixlen; /* leaf: right half's prefix length (0 none) */
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, rightprefixlen) + sizeof(uint8))
+
+/*
+ * xl_btree_split flag bits.
+ *
+ * A prefix-compressed leaf split logs no full-page image in the common case:
+ * redo rebuilds each half the way the primary did. The left half is
+ * reconstructed from the pre-split original page (block 0) together with the
+ * logged new item and high key; the right half is reconstructed from its plain
+ * data items, which are logged as block-1 data. Each half's chosen prefix
+ * bytes are logged too (block 0 for the left half, block 1 for the right), and
+ * applied with _bt_split_leaf_layout(); redo cannot run the prefix-choosing
+ * heuristic itself, so the primary logs the prefix it chose.
+ * leftprefixlen/rightprefixlen give the lengths (0 means the half is not
+ * prefix-compressed). XLH_SPLIT_LEAFPREFIX marks a leaf split that uses this
+ * path at all (so redo rebuilds coded rather than replaying items).
+ */
+#define XLH_SPLIT_LEAFPREFIX 0x01 /* leaf split, rebuild halves coded */
+#define XLH_SPLIT_RFPI 0x08 /* right half carried by a full-page image
+ * (its plain items would overflow the WAL
+ * record); redo restores it from the image */
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
* merged together into posting list tuples.
*
- * The WAL record represents a deduplication pass for a leaf page. An array
- * of BTDedupInterval structs follows.
+ * The WAL record represents a deduplication pass for a leaf page. Normally
+ * (prefixlen == 0) an array of BTDedupInterval structs follows and redo
+ * replays them against the old page. When the page is prefix-compressed
+ * (prefixlen > 0) the deduped result is instead carried as the chosen prefix
+ * bytes (prefixlen) followed by, when not rightmost, the high key, then the
+ * deduped plain data items -- each MAXALIGN'd -- and redo lays them out coded
+ * with _bt_split_leaf_layout(), avoiding a full-page image.
*/
typedef struct xl_btree_dedup
{
uint16 nintervals;
+ uint8 prefixlen; /* >0: prefix-dedup payload, see above */
+ uint8 flags; /* XLH_DEDUP_* */
- /* DEDUPLICATION INTERVALS FOLLOW */
+ /* DEDUPLICATION INTERVALS or PREFIX PAYLOAD FOLLOW */
} xl_btree_dedup;
-#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, flags) + sizeof(uint8))
+
+/* xl_btree_dedup flag bits */
+#define XLH_DEDUP_NOT_RIGHTMOST 0x01 /* prefix payload includes a high key */
+#define XLH_DEDUP_RFPI 0x02 /* prefix page carried as a forced image */
/*
* This is what we need to know about page reuse within btree. This record
diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out
index 7c53d598649..c1dae4b55ad 100644
--- a/src/test/regress/expected/btree_index.out
+++ b/src/test/regress/expected/btree_index.out
@@ -891,3 +891,259 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql
index 6990d7818da..6cd053a1596 100644
--- a/src/test/regress/sql/btree_index.sql
+++ b/src/test/regress/sql/btree_index.sql
@@ -520,3 +520,170 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index cfef572df80..f314d8feb04 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,7 @@ BTPageStat
BTPageState
BTParallelScanDesc
BTPendingFSM
+BTPrefixPlan
BTReadPageState
BTScanInsert
BTScanInsertData
--
2.50.1
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-10-27 17:50 ` Greg Burd <greg@burd.me>
2026-10-29 16:42 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
0 siblings, 1 reply; 12+ messages in thread
From: Greg Burd @ 2026-10-27 17:50 UTC (permalink / raw)
To: pgsql-hackers@lists.postgresql.org; +Cc: Peter Geoghegan <pg@bowt.ie>
Hello,
v3 attached. This closes the two open items I flagged with v2 and reports back
on the nbtsplitloc.c design question, which turned out to have a clearer answer
than I expected once I measured it.
1. The forced-full-page-image fallbacks
XLH_SPLIT_RFPI and XLH_DEDUP_RFPI carry a coded page as a full-page image in
the one case redo can't rebuild from logged plain items: a page so
compressible that its decoded payload exceeds a block. I could not reach
that threshold with any realistic data in v2, which left the path
unexercised. I instrumented it on maximally compressible keys (a 125-byte
shared prefix, tiny suffixes), the right half of a split tops out around 6
KB of plain payload, which is close, but the split-point balance keeps a
half under a block, so the fallback never fires naturally.
It is reachable in principle (a different item-size mix could get there), so
I'm keeping it rather than demoting it to a can't-happen error. An error
there would be a rare production PANIC on data that is merely unusual, not
corrupt. To make it testable, there's now a nbtree-prefix-force-rfpi
injection point that forces the fallback for every prefix split and
deduplication, and a test in src/test/modules/nbtree that builds and
deduplicates a prefix index with it attached, then amchecks the result.
Writing that test immediately caught a bug (the injection-point check sat
inside the WAL critical section, where the first-use cache load palloc()s)-
which is now fixed (the decision is made before the critical section).
Byte-for-byte redo through the forced image is covered by the existing
crash-recovery and streaming runs under wal_consistency_checking.
2. Alternatives-considered
The README now spells out why this is a separate mechanism rather than an
extension of posting lists: deduplication collapses tuples whose whole key
is equal; prefix compression factors out a leading run shared by distinct
keys. They attack different redundancy and compose on one page (deduplicate
first, then code the survivors), which is why a build runs both.
Overloading the posting-list format to also carry a shared prefix was
considered and rejected; a posting list is one key plus many TIDs, prefix
coding is many keys that share a prefix, so they'd need different per-item
layouts anyway, and it would entangle the two paths this patch keeps apart.
3. The nbtsplitloc.c question
I proposed moving the degenerate-split guard (the one that rejects a split
leaving the left page with no data items) out of nbtsplitloc.c. I tried the
cleanest version of that; make the guard unconditional instead of
prefix-only, on the theory that upstream never chooses that split point for
a plain page anyway, so it would be a no-op there. It is not a no-op: a
probe showed _bt_recsplitloc() is called at that point for plain pages all
the time, and rejecting it unconditionally broke three test suites. The
guard genuinely has to be prefix-specific, because it exists precisely
because coded-size accounting makes a point attractive that plain-size
accounting rejects on its own via the leftfree check.
Moving it into _bt_split() instead of nbtsplitloc.c doesn't help either:
_bt_split() doesn't choose split points, so it would have to re-derive or
reject-and-retry, which is more prefix logic in a more dangerous place (next
to the critical section), not less. So I've left the guard where it is: one
well-commented "if" in _bt_recsplitloc(). The coded-size accounting stays
too, for the reason in v2 (a compressed leaf legitimately holds more than
two plain pages' worth, so plain-size accounting makes feasible splits look
infeasible). If you'd still rather see it elsewhere I'll do it, but I
wanted to report that the "obvious" relocation makes the code worse, not
better.
Everything else from v2 stands: one split-collection routine shared by
primary and redo, amcheck structural checks plus a prefix-index test,
docs matched to the tuple-rebuild WAL design.
I think v3 is in good shape, so I'll leave it here and pick it back up on
review feedback (or if cf-bot turns something up).
I might run more benchmarks, just for fun.
-greg
Attachments:
v3-0001-nbtree-opt-in-leaf-key-prefix-compression.patch (text/x-patch, 165.9K) download
From ca1841830732abf6c534a7ca529fe29062ba6749 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 15:25:58 -0400
Subject: [PATCH v3] nbtree: opt-in leaf key prefix compression
Add leaf key prefix compression to nbtree, controlled by a new
per-index storage parameter, prefix_compression, that is off by default.
When a leaf page is written as a whole -- during an index build, as a
split half, or as the result of a deduplication pass -- and its data
items share a leading byte-prefix of their first key column, the page is
rebuilt in the BTP_PREFIX format: a single PREFIX item after the high
key holds the shared bytes, and each data item is stored "coded" (its
copy of the shared prefix replaced by a one-byte length). Reads of a
leaf data item funnel through _bt_getitem(), which decodes on demand;
high keys and internal-page downlinks are never coded and are read with
the existing PageGetItem().
The feature is deliberately a per-page opt-in with no on-disk version
change: BTREE_VERSION and the catalog version are untouched. A page is
converted only when it is next written whole, never by an insert, VACUUM
or key-range change, so compression "faults forward" on an existing
index. Old-format and new-format pages coexist in the same index, which
keeps pg_upgrade, physical replication, and standbys working with no
REINDEX: a replica that has not yet seen a page's conversion still reads
it correctly, and turning the parameter off (ALTER INDEX ... SET) stops
new conversions while leaving existing coded pages readable.
Prefix compression is a separate mechanism from deduplication, not an
extension of it: deduplication collapses tuples whose whole key is
equal, while prefix compression factors out a leading run shared by
distinct keys (a URL or jsonb-path column is the motivating case, where
deduplication does not help). The two compose on one page -- a build
deduplicates first and then codes the survivors, so a duplicate-heavy
prefix index is no larger than a plain one. Bottom-up index deletion
runs on coded pages unchanged, since it only removes items. A runtime
deduplication pass on a coded page decodes first, and because a coded
page can hold more logical data than fits plain, the pass aborts cleanly
and the page splits instead when the decoded set would overflow.
WAL replay reproduces the coded bytes without resorting to full-page
images in the common paths. A Relation-free layout routine,
_bt_split_leaf_layout(), together with a single item-collection routine,
_bt_split_leaf_collect(), are shared by the primary (_bt_split) and redo
(btree_xlog_split) so both derive the same plain item sequence and code
it against the same logged prefix, emitting identical bytes. An
in-place insert logs the coded tuple; a split logs each half's chosen
prefix plus the right half's plain items; a prefix deduplication logs
the chosen prefix plus the deduped plain items. A bounded fallback
carries the page as a forced full-page image only when a pathologically
compressible page's plain payload would exceed one block; realistic data
never reaches that threshold, so the nbtree-prefix-force-rfpi injection
point and a dedicated test in src/test/modules/nbtree exercise the
fallback deterministically.
amcheck decodes every leaf data item, including under heapallindexed,
and checks the BTP_PREFIX structural invariants (flag only on leaf
pages, a well-formed PREFIX item at the expected offset); its test suite
covers a prefix-compressed index. The create_index documentation and
the nbtree README describe the parameter, the on-disk format, and the
relationship to deduplication. Tested with wal_consistency_checking=all
on a primary and a streaming standby across insert, split, deduplication
(including the forced-image fallback), delete and vacuum workloads,
through crash recovery, and across a pg_upgrade that carries both page
formats.
---
contrib/amcheck/expected/check_btree.out | 27 +
contrib/amcheck/sql/check_btree.sql | 18 +
contrib/amcheck/verify_nbtree.c | 120 ++-
doc/src/sgml/ref/create_index.sgml | 35 +
src/backend/access/common/reloptions.c | 10 +
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 97 +++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 266 +++++-
src/backend/access/nbtree/nbtinsert.c | 375 ++++++--
src/backend/access/nbtree/nbtpage.c | 31 +-
src/backend/access/nbtree/nbtprefix.c | 814 ++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 23 +-
src/backend/access/nbtree/nbtree.c | 6 +-
src/backend/access/nbtree/nbtsearch.c | 3 +-
src/backend/access/nbtree/nbtsort.c | 54 +-
src/backend/access/nbtree/nbtsplitloc.c | 84 +-
src/backend/access/nbtree/nbtutils.c | 4 +-
src/backend/access/nbtree/nbtxlog.c | 313 ++++++-
src/include/access/nbtree.h | 147 +++-
src/include/access/nbtxlog.h | 44 +-
src/test/modules/nbtree/Makefile | 3 +-
.../nbtree/expected/nbtree_prefix_rfpi.out | 77 ++
src/test/modules/nbtree/meson.build | 1 +
.../modules/nbtree/sql/nbtree_prefix_rfpi.sql | 59 ++
src/test/regress/expected/btree_index.out | 256 ++++++
src/test/regress/sql/btree_index.sql | 167 ++++
src/tools/pgindent/typedefs.list | 2 +
28 files changed, 2923 insertions(+), 115 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
create mode 100644 src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
create mode 100644 src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
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..18551483e88 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -158,7 +158,12 @@ typedef struct BtreeLastVisibleEntry
OffsetNumber offset; /* Offset on index block */
int postingIndex; /* Number in the posting list (-1 for
* non-deduplicated tuples) */
- ItemPointer tid; /* Heap tid */
+ ItemPointer tid; /* Heap tid, or NULL; points at tiddata */
+ ItemPointerData tiddata; /* Own storage for *tid: a prefix-coded leaf
+ * tuple is decoded into a scratch buffer that
+ * is reused on the next loop iteration, so we
+ * must keep the heap TID by value, not point
+ * into the (transient) decoded tuple */
} BtreeLastVisibleEntry;
/*
@@ -935,7 +940,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
lVis->postingIndex = i;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
}
}
}
@@ -961,7 +967,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
lVis->postingIndex = -1;
}
}
@@ -1229,6 +1236,16 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber max;
BTPageOpaque topaque;
+ /*
+ * Scratch space for decoding prefix-coded leaf tuples (_bt_getitem).
+ * itupbuf backs the current loop item; crossbuf backs the right sibling's
+ * first item in the cross-page unique check, which must coexist with the
+ * current item's state. A pivot, high key or non-prefix leaf item is
+ * returned by _bt_getitem unchanged (buffer unused).
+ */
+ BTItemBuf itupbuf;
+ BTItemBuf crossbuf;
+
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1266,6 +1283,54 @@ bt_target_page_check(BtreeCheckState *state)
}
}
+ /*
+ * Structural checks for a prefix-compressed (BTP_PREFIX) leaf. A reader
+ * trusts this format implicitly, so validate the parts that an out-of-
+ * place decode would otherwise read as garbage: the flag appears only on
+ * leaf pages, and the PREFIX item sits at the expected offset with a
+ * length in the legal range. The per-item key/content checks below run
+ * on the decoded tuple from _bt_getitem, which fails on a malformed coded
+ * item, so item-level coding is covered there.
+ */
+ if (P_HASPREFIX(topaque))
+ {
+ ItemId pitemid;
+ IndexTuple pitup;
+ Size prefixlen;
+ OffsetNumber prefixoff = BTPageGetPrefixOff(topaque);
+
+ if (!P_ISLEAF(topaque))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix compression flag set on non-leaf page in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u page lsn=%X/%08X.",
+ state->targetblock,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ if (prefixoff > max)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix-compressed page has no PREFIX item in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix offset=%u max offset=%u page lsn=%X/%08X.",
+ state->targetblock, prefixoff, max,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ pitemid = PageGetItemIdCareful(state, state->targetblock,
+ state->target, prefixoff);
+ pitup = (IndexTuple) PageGetItem(state->target, pitemid);
+ prefixlen = IndexTupleSize(pitup) - sizeof(IndexTupleData);
+ if (prefixlen < 1 || prefixlen > BTREE_PREFIX_MAX)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("PREFIX item has invalid length in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix length=%zu page lsn=%X/%08X.",
+ state->targetblock, prefixlen,
+ 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"
@@ -1293,8 +1358,15 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, state->targetblock,
state->target, offset);
- itup = (IndexTuple) PageGetItem(state->target, itemid);
- tupsize = IndexTupleSize(itup);
+
+ /*
+ * lp_len and the tuple size limit are physical properties of the item
+ * as stored, so they are checked against the raw on-page tuple (coded,
+ * on a BTP_PREFIX leaf). Every key/content check below instead uses
+ * the decoded tuple returned by _bt_getitem.
+ */
+ tupsize = IndexTupleSize((IndexTuple) PageGetItem(state->target, itemid));
+ itup = _bt_getitem(state->target, offset, &itupbuf);
/*
* lp_len should match the IndexTuple reported length exactly, since
@@ -1606,7 +1678,7 @@ 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), &itupbuf);
tid = BTreeTupleGetPointsToTID(itup);
nhtid = psprintf("(%u,%u)",
ItemPointerGetBlockNumberNoCheck(tid),
@@ -1793,7 +1865,7 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, rightblock_number,
rightpage,
rightfirstoffset);
- itup = (IndexTuple) PageGetItem(rightpage, itemid);
+ itup = _bt_getitem(rightpage, rightfirstoffset, &crossbuf);
bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis);
@@ -1857,6 +1929,8 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
BlockNumber targetnext;
Page rightpage;
OffsetNumber nline;
+ OffsetNumber rightoffset;
+ BTItemBuf firstbuf;
/* Determine target's next block number */
opaque = BTPageGetOpaque(state->target);
@@ -2016,6 +2090,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/* Return first data item (if any) */
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
P_FIRSTDATAKEY(opaque));
+ rightoffset = P_FIRSTDATAKEY(opaque);
*rightfirstoffset = P_FIRSTDATAKEY(opaque);
}
else if (!P_ISLEAF(opaque) &&
@@ -2027,6 +2102,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
*/
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
OffsetNumberNext(P_FIRSTDATAKEY(opaque)));
+ rightoffset = OffsetNumberNext(P_FIRSTDATAKEY(opaque));
}
else
{
@@ -2046,9 +2122,19 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/*
* Return first real item scankey. Note that this relies on right page
* memory remaining allocated.
+ *
+ * On a BTP_PREFIX leaf the first data item is prefix-coded, so decode it;
+ * because the returned scankey (built by _bt_mkscankey) references the
+ * tuple's own storage, and _bt_getitem decodes into a buffer local to
+ * this function, copy the decoded tuple into the caller-reset memory
+ * context so it outlives the call. A pivot or non-prefix item is
+ * returned by _bt_getitem unchanged (it lives in the right page, which
+ * remains allocated), so no copy is needed then.
*/
- firstitup = (IndexTuple) PageGetItem(rightpage, rightitem);
- return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ firstitup = _bt_getitem(rightpage, rightoffset, &firstbuf);
+ if (firstitup == (IndexTuple) PageGetItem(rightpage, rightitem))
+ return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ return bt_mkscankey_pivotsearch(state->rel, CopyIndexTuple(firstitup));
}
/*
@@ -3095,14 +3181,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);
@@ -3122,11 +3207,12 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key,
{
BTPageOpaque topaque;
IndexTuple ritup;
+ BTItemBuf ritupbuf;
int uppnkeyatts;
ItemPointer rheaptid;
bool nonpivot;
- ritup = (IndexTuple) PageGetItem(state->target, itemid);
+ ritup = _bt_getitem(state->target, upperbound, &ritupbuf);
topaque = BTPageGetOpaque(state->target);
nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque);
@@ -3218,14 +3304,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 */
@@ -3236,12 +3321,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key,
if (cmp == 0)
{
IndexTuple child;
+ BTItemBuf childbuf;
int uppnkeyatts;
ItemPointer childheaptid;
BTPageOpaque copaque;
bool nonpivot;
- child = (IndexTuple) PageGetItem(nontarget, itemid);
+ child = _bt_getitem(nontarget, upperbound, &childbuf);
copaque = BTPageGetOpaque(nontarget);
nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque);
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..7e66765c3d3 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -477,6 +477,41 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
</note>
</listitem>
</varlistentry>
+
+ <varlistentry id="index-reloption-prefix-compression" xreflabel="prefix_compression">
+ <term><literal>prefix_compression</literal> (<type>boolean</type>)
+ <indexterm>
+ <primary><varname>prefix_compression</varname> storage parameter</primary>
+ </indexterm>
+ </term>
+ <listitem>
+ <para>
+ Controls leaf key prefix compression. When set to
+ <literal>ON</literal>, and the index's first key column is of a
+ variable-length type, a leaf page may store a shared prefix of that
+ column once and record only each tuple's unshared remainder, which can
+ substantially shrink indexes on keys that share long leading bytes
+ (for example URLs or <type>jsonb</type> keys with common paths). Set
+ to <literal>ON</literal> or <literal>OFF</literal> to enable or disable
+ the optimization. The default is <literal>OFF</literal>.
+ </para>
+
+ <note>
+ <para>
+ 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 adopts a prefix only when
+ it is written as a whole (during an index build, or either half of a
+ page split), so turning <literal>prefix_compression</literal> on or off
+ via <command>ALTER INDEX</command> does not rewrite the index: existing
+ pages are left as they are and are converted the next time they are
+ written whole. No <command>REINDEX</command> is required, and indexes
+ upgraded with <application>pg_upgrade</application> keep working
+ unchanged.
+ </para>
+ </note>
+ </listitem>
+ </varlistentry>
</variablelist>
<para>
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..596e595f20b 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1081,3 +1081,100 @@ 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
+front-code the first key column of each of its data tuples against a per-page
+prefix, which shrinks indexes on keys that share long leading bytes (URLs,
+jsonb keys with common paths, zero-padded codes). Like deduplication, this
+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.
+
+A leaf that uses the technique carries the BTP_PREFIX page flag and one extra
+item, the PREFIX item, 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. The PREFIX item is an
+IndexTupleData header followed by 1..BTREE_PREFIX_MAX prefix bytes, shaped so
+that code which walks a page's items by IndexTupleSize (such as
+_bt_restore_page) steps over it. On such a page every non-pivot tuple (plain
+or posting) whose first key column is not NULL stores that column as a varlena
+whose payload begins with a one-byte count of how many leading bytes it shares
+with the page prefix, followed by the unshared remainder (or, for a value whose
+header is not the form index_form_tuple would reproduce, the whole value after
+a sentinel count). Trailing key columns, INCLUDE columns, a posting list and
+alignment padding follow, moved to preserve alignment; padding that is only the
+key's closing zeros is dropped and restored on decode. Pivots, NULL first
+columns and the high key are always stored uncompressed. Coding and decoding
+depend only on the page prefix and the tuple, never the catalog, so WAL redo
+reproduces them and decoding yields the original tuple byte for byte. All of
+nbtprefix.c implements this; _bt_getitem() is the one accessor through which
+readers obtain a decoded leaf data tuple.
+
+A page adopts a prefix only when it is written as a whole: a finished leaf
+during an index build, or either half of a leaf page split (built coded from
+the start by _bt_split_leaf_half, since a compressed leaf can hold more logical
+data than fits on a plain page, choosing each half's own prefix and falling
+back to a plain layout or the original page's prefix if its own does not fit).
+Crucially, no insert, VACUUM, deduplication or change of a page's key range
+ever rewrites a page to keep its prefix valid: a tuple that happens to share
+fewer bytes simply records a smaller shared count. 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 physical/logical replication keep
+working and existing indexes continue to work with no REINDEX. Turning the
+parameter on with ALTER INDEX does not rewrite anything; 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 (the flag is cleared
+only when a page is split, emptied or dedup-rewritten) and are still read back
+correctly with the parameter off, since every reader decodes based on the page
+flag, not on the current reloption. Scan-time workspace that must hold decoded
+tuples (the index-only-scan currTuples/markTuples buffers, see
+BTTupleStorageSize) is therefore sized for a decoded page on every btree index,
+not just those whose parameter is currently on.
+
+Leaf changes to a prefix page are replayed without a full-page image in the
+common case. Redo cannot run the prefix-choosing heuristic (it has no
+relcache), so the primary logs the prefix it chose and both sides lay the
+page out through one Relation-free routine, _bt_split_leaf_layout(), fed by
+one item-collection routine, _bt_split_leaf_collect(), that the primary
+(_bt_split) and redo (btree_xlog_split) both call. Because the two derive
+the plain item sequence the same way and code it against the same logged
+prefix, they produce byte-identical pages. An in-place insert logs the coded
+tuple (standard insert redo adds it verbatim); a split logs each half's
+chosen prefix plus the right half's plain items; a deduplication logs the
+chosen prefix plus the deduped plain items. A bounded fallback forces a
+full-page image only when a pathologically compressible page's plain payload
+would exceed BLCKSZ (so the logged payload can never exceed one block); redo
+then restores the image via BLK_RESTORED. When VACUUM or page deletion
+empties a prefix leaf, the PREFIX item is removed and the flag cleared so that
+the invariant that an emptied leaf holds only its high key (relied on by page
+deletion) still holds.
+
+Deduplication and bottom-up index deletion both run on BTP_PREFIX pages.
+Bottom-up deletion only removes items and never rewrites survivors, so it is
+safe on coded pages unchanged. A deduplication pass decodes the page's items,
+merges duplicates into posting lists and re-compresses the result as a whole;
+because a coded page can hold more logical data than fits plain, if the
+decoded set would overflow a plain page the pass aborts cleanly and the page
+splits instead, so no in-place rewrite is ever forced to fail. An index build
+deduplicates before coding the finished page, so a duplicate-heavy prefix
+index is no larger than a plain one.
+
+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 byte 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 (deduplicate first, then code the
+survivors), which is why a build applies both. Reusing the posting-list
+format to carry a shared prefix was considered and rejected: a posting list
+stores one key plus many TIDs, whereas prefix coding stores many keys that
+share a prefix, so the two would need different per-item layouts anyway, and
+overloading posting lists would entangle the deduplication and compression
+paths that are kept separate here on purpose.
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..12aa252aa05 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -19,6 +19,7 @@
#include "access/tableam.h"
#include "access/xloginsert.h"
#include "miscadmin.h"
+#include "utils/injection_point.h"
#include "utils/rel.h"
static void _bt_bottomupdel_finish_pending(Page page, BTDedupState state,
@@ -69,7 +70,19 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Size pagesaving PG_USED_FOR_ASSERTS_ONLY = 0;
bool singlevalstrat = false;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
XLogRecPtr recptr;
+ IndexTuple *dedup_plain = NULL; /* deduped plain items, for prefix WAL */
+ int dedup_nplain = 0;
+ IndexTuple dedup_hikey = NULL;
+ char dedup_prefix[BTREE_PREFIX_MAX];
+ Size dedup_prefixlen = 0;
+ bool dedup_islogprefix = false; /* page ended up prefix-coded */
+ char *dedup_walbuf = NULL; /* packed prefix-dedup WAL payload */
+ Size dedup_walbuflen = 0;
+ bool dedup_rfpi = false; /* fall back to a forced full-page image */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -120,7 +133,16 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
newpage = PageGetTempPageCopySpecial(page);
PageSetLSN(newpage, PageGetLSN(page));
- /* Copy high key, if any */
+ /*
+ * The rebuilt page is laid out plain here (clear any inherited BTP_PREFIX;
+ * PageGetTempPageCopySpecial copies the special area but not the PREFIX
+ * item). If the page was prefix-compressed it is re-compressed as a whole
+ * below, after deduplication, honouring Strategy 1.
+ */
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
+ /* Copy high key, if any (a pivot, stored plain on any page) */
if (!P_RIGHTMOST(opaque))
{
ItemId hitemid = PageGetItemId(page, P_HIKEY);
@@ -140,12 +162,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Assert(!ItemIdIsDead(itemid));
+ /* Decode on a prefix page so keys/posting lists are the real ones. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/*
* No previous/base tuple for the data item -- use the data item
- * as base tuple of pending posting list
+ * as base tuple of pending posting list. On a prefix page the
+ * base must survive the whole merge run (it is kept as a pointer
+ * in state and read by _bt_dedup_finish_pending), so keep it in a
+ * dedicated buffer rather than the per-iteration curbuf.
*/
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (state->deduplicate &&
@@ -170,7 +202,20 @@ _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);
+ /*
+ * On a prefix page the deduped items are laid out plain here; the
+ * decoded set can exceed one page even when the coded form fit, so
+ * tolerate an overflow and abort the whole pass (the caller then
+ * splits the page instead of deduplicating it).
+ */
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state,
+ pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
if (singlevalstrat)
{
@@ -198,12 +243,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
}
/* itup starts new pending posting list */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page,
+ (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
/* Handle the last item */
- pagesaving += _bt_dedup_finish_pending(newpage, state);
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state, pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
/*
* If no items suitable for deduplication were found, newpage must be
@@ -238,6 +293,110 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
nopaque->btpo_flags &= ~BTP_HAS_GARBAGE;
}
+ /*
+ * Re-apply prefix compression to the rebuilt (plain) page as a whole
+ * (Strategy 1), before the critical section since it allocates a scratch
+ * page. First capture the deduped plain items and high key: if the page
+ * does become prefix-compressed, redo rebuilds its coded bytes from these
+ * plus the chosen prefix (via _bt_split_leaf_layout), the same way a prefix
+ * leaf split is replayed -- no full-page image needed.
+ */
+ if (pageprefix)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(newpage);
+ OffsetNumber nmin = P_FIRSTDATAKEY(nopq);
+ OffsetNumber nmax = PageGetMaxOffsetNumber(newpage);
+
+ dedup_nplain = (nmax >= nmin) ? (int) nmax - (int) nmin + 1 : 0;
+ if (dedup_nplain > 0)
+ {
+ dedup_plain = palloc(dedup_nplain * sizeof(IndexTuple));
+ for (int i = 0; i < dedup_nplain; i++)
+ dedup_plain[i] = CopyIndexTuple((IndexTuple)
+ PageGetItem(newpage,
+ PageGetItemId(newpage, nmin + i)));
+ }
+ if (!P_RIGHTMOST(nopq))
+ {
+ ItemId hii = PageGetItemId(newpage, P_HIKEY);
+
+ dedup_hikey = CopyIndexTuple((IndexTuple) PageGetItem(newpage, hii));
+ }
+
+ _bt_prefix_compress_page(rel, newpage);
+
+ /* Did it actually compress? (It gives up if a coded item won't fit.) */
+ if (P_HASPREFIX(BTPageGetOpaque(newpage)))
+ {
+ const char *pfx = _bt_page_get_prefix(newpage, &dedup_prefixlen);
+
+ if (dedup_prefixlen > 0)
+ memcpy(dedup_prefix, pfx, dedup_prefixlen);
+ dedup_islogprefix = true;
+ }
+ }
+
+ /*
+ * Pack the prefix-dedup WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the chosen prefix bytes, then the high
+ * key (unless rightmost), then the deduped plain items, each MAXALIGN'd,
+ * in one contiguous buffer logged with a single rdata slot.
+ */
+ if (dedup_islogprefix)
+ {
+ Size off = dedup_prefixlen;
+
+ if (dedup_hikey != NULL)
+ off += MAXALIGN(IndexTupleSize(dedup_hikey));
+ for (int i = 0; i < dedup_nplain; i++)
+ off += MAXALIGN(IndexTupleSize(dedup_plain[i]));
+
+ /*
+ * A pathologically compressible page can hold more plain data than
+ * fits in one page; if the payload would be that large, fall back to
+ * a forced full-page image of the coded page (bounded at BLCKSZ).
+ */
+ if (off > BLCKSZ)
+ dedup_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ /* Let a test force the otherwise-rare full-page-image fallback. */
+ if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ dedup_rfpi = true;
+#endif
+ if (dedup_rfpi)
+ ; /* image forced; no payload buffer built */
+ else
+ {
+ dedup_walbuflen = off;
+ dedup_walbuf = palloc(Max(off, 1));
+
+ off = 0;
+ if (dedup_prefixlen > 0)
+ {
+ memcpy(dedup_walbuf, dedup_prefix, dedup_prefixlen);
+ off += dedup_prefixlen;
+ }
+ if (dedup_hikey != NULL)
+ {
+ Size hsz = IndexTupleSize(dedup_hikey);
+
+ memcpy(dedup_walbuf + off, dedup_hikey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(dedup_walbuf + off + hsz, 0, MAXALIGN(hsz) - hsz);
+ off += MAXALIGN(hsz);
+ }
+ for (int i = 0; i < dedup_nplain; i++)
+ {
+ Size sz = IndexTupleSize(dedup_plain[i]);
+
+ memcpy(dedup_walbuf + off, dedup_plain[i], sz);
+ if (MAXALIGN(sz) != sz)
+ memset(dedup_walbuf + off + sz, 0, MAXALIGN(sz) - sz);
+ off += MAXALIGN(sz);
+ }
+ }
+ }
+
START_CRIT_SECTION();
PageRestoreTempPage(newpage, page);
@@ -248,19 +407,38 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
{
xl_btree_dedup xlrec_dedup;
- xlrec_dedup.nintervals = state->nintervals;
+ xlrec_dedup.nintervals = dedup_islogprefix ? 0 : state->nintervals;
+ xlrec_dedup.prefixlen = (uint8) dedup_prefixlen;
+ xlrec_dedup.flags = (dedup_hikey != NULL) ? XLH_DEDUP_NOT_RIGHTMOST : 0;
+ if (dedup_rfpi)
+ xlrec_dedup.flags |= XLH_DEDUP_RFPI;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ (dedup_rfpi ? REGBUF_FORCE_IMAGE : 0));
XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
- /*
- * The intervals array is not in the buffer, but pretend that it is.
- * When XLogInsert stores the whole buffer, the array need not be
- * stored too.
- */
- XLogRegisterBufData(0, state->intervals,
- state->nintervals * sizeof(BTDedupInterval));
+ if (dedup_islogprefix)
+ {
+ /*
+ * Prefix-dedup: carry the chosen prefix and the deduped plain
+ * items so redo can lay out the coded page; no full-page image
+ * (unless the payload was too large, handled by the forced image).
+ */
+ if (!dedup_rfpi && dedup_walbuflen > 0)
+ XLogRegisterBufData(0, dedup_walbuf, dedup_walbuflen);
+ }
+ else
+ {
+ /*
+ * The intervals array is not in the buffer, but pretend that it
+ * is. When XLogInsert stores the whole buffer, the array need not
+ * be stored too.
+ */
+ XLogRegisterBufData(0, state->intervals,
+ state->nintervals * sizeof(BTDedupInterval));
+ }
recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DEDUP);
}
@@ -277,6 +455,20 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
/* cannot leak memory here */
pfree(state->htids);
pfree(state);
+ return;
+
+dedup_overflow:
+
+ /*
+ * A prefix page's deduped items did not fit when laid out plain. Leave
+ * the original page untouched and return without writing anything; the
+ * caller falls through to split the page. No critical section was
+ * entered and the buffer was not marked dirty, so the page is unchanged.
+ */
+ Assert(pageprefix);
+ pfree(newpage);
+ pfree(state->htids);
+ pfree(state);
}
/*
@@ -318,6 +510,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
TM_IndexDeleteOp delstate;
bool neverdedup;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -371,9 +566,24 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
Assert(!ItemIdIsDead(itemid));
+ /*
+ * On a prefix-compressed page decode the item so _bt_keep_natts_fast
+ * sees the real key. The base of a pending interval must survive the
+ * whole run (it is compared against each following item), so keep it
+ * in its own buffer; the current item uses a reused buffer. Heap TIDs
+ * and posting lists are preserved by coding, so _bt_dedup_start_pending
+ * and _bt_bottomupdel_finish_pending (which read only TIDs/sizes, by
+ * page offset) need no decode.
+ */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/* itup starts first pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
@@ -387,6 +597,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
_bt_bottomupdel_finish_pending(page, state, &delstate);
/* itup starts new pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
@@ -554,7 +767,7 @@ _bt_dedup_save_htid(BTDedupState state, IndexTuple itup)
* where no deduplication was possible.
*/
Size
-_bt_dedup_finish_pending(Page newpage, BTDedupState state)
+_bt_dedup_finish_pending(Page newpage, BTDedupState state, bool overflowok)
{
OffsetNumber tupoff;
Size tuplesz;
@@ -572,7 +785,11 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
spacesaving = 0;
}
@@ -591,13 +808,26 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(final)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ pfree(final);
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
pfree(final);
spacesaving = state->phystupsize - (tuplesz + sizeof(ItemIdData));
/* Increment nintervals, since we wrote a new posting list tuple */
state->nintervals++;
- Assert(spacesaving > 0 && spacesaving < BLCKSZ);
+
+ /*
+ * On an ordinary page the merged interval's tuples were all physically
+ * present on one page, so the saving is bounded by BLCKSZ. On a prefix
+ * page (overflowok) phystupsize is the DECODED size of those tuples,
+ * which can exceed a page (a coded page holds more logical data than
+ * fits plain), so only require the saving to be positive there.
+ */
+ Assert(spacesaving > 0 && (overflowok || spacesaving < BLCKSZ));
}
/* Reset state for next pending posting list */
@@ -783,16 +1013,22 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state,
OffsetNumber minoff, IndexTuple newitem)
{
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(BTPageGetOpaque(page));
ItemId itemid;
IndexTuple itup;
+ BTItemBuf buf;
itemid = PageGetItemId(page, minoff);
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &buf);
if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts)
{
itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page));
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &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..dfd980a6a2a 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -348,8 +348,8 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate)
P_ISLEAF(opaque) &&
!P_IGNORE(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
@@ -1135,6 +1135,11 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple codeditup; /* itup coded for the page, or itup */
+ Size codeditemsz; /* its MAXALIGNed size */
+ IndexTuple codednposting = NULL; /* nposting coded for the page */
+ BTItemBuf opostbuf; /* decode workspace for an existing posting */
+ bool pageprefix; /* target page is BTP_PREFIX */
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1143,6 +1148,7 @@ _bt_insertonpg(Relation rel,
isroot = P_ISROOT(opaque);
isrightmost = P_RIGHTMOST(opaque);
isonly = P_LEFTMOST(opaque) && P_RIGHTMOST(opaque);
+ pageprefix = P_HASPREFIX(opaque);
/* child buffer must be given iff inserting on an internal page */
Assert(isleaf == !BufferIsValid(cbuf));
@@ -1182,6 +1188,9 @@ _bt_insertonpg(Relation rel,
*/
Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage);
oposting = (IndexTuple) PageGetItem(page, itemid);
+ /* On a prefix page the existing posting is coded; decode for the swap. */
+ if (pageprefix)
+ oposting = _bt_prefix_decode(page, oposting, &opostbuf);
/*
* postingoff value comes from earlier call to _bt_binsrch_posting().
@@ -1213,19 +1222,50 @@ _bt_insertonpg(Relation rel,
newitemoff = OffsetNumberNext(newitemoff);
}
+ /*
+ * On a prefix-compressed page the new item is stored coded, which is at
+ * most MAXALIGN larger than plain and usually much smaller. Size the fit
+ * test and the placement by the coded size. (Split is handled on plain
+ * tuples; _bt_split re-compresses each half itself.)
+ */
+ if (pageprefix)
+ {
+ codeditup = _bt_prefix_encode(page, itup);
+ codeditemsz = MAXALIGN(IndexTupleSize(codeditup));
+ /*
+ * A posting-list split replaces the existing coded posting with a
+ * re-coded one; encode it here, before the critical section, since
+ * _bt_prefix_encode() allocates.
+ */
+ if (nposting != NULL)
+ codednposting = _bt_prefix_encode(page, nposting);
+ }
+ else
+ {
+ codeditup = itup;
+ codeditemsz = itemsz;
+ }
+
/*
* Do we need to split the page to fit the item on it?
*
* 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.
+ * only for the item and not for its line pointer. The coded item size is
+ * used; a posting-list split additionally changes the posting tuple's
+ * size, but that can only shrink or keep free space (the new posting has
+ * one fewer TID's worth of room reused by newitem), so the item fit test
+ * remains a safe upper bound.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < codeditemsz)
{
Buffer rbuf;
Assert(!split_only_page);
+ if (codeditup != itup)
+ pfree(codeditup);
+
/* split the buffer into left and right halves */
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
@@ -1295,9 +1335,25 @@ _bt_insertonpg(Relation rel,
START_CRIT_SECTION();
if (postingoff != 0)
- memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ {
+ if (pageprefix)
+ {
+ /*
+ * The existing posting is stored coded; replace its slot (at
+ * the offset just before newitemoff, since newitemoff was
+ * advanced past it above) with the coded new posting (encoded
+ * before the critical section), whose size may differ.
+ */
+ if (!PageIndexTupleOverwrite(page, OffsetNumberPrev(newitemoff),
+ codednposting, IndexTupleSize(codednposting)))
+ elog(PANIC, "failed to overwrite posting list in block %u in index \"%s\"",
+ BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+ }
+ else
+ memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ }
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, codeditup, codeditemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1381,8 +1437,30 @@ _bt_insertonpg(Relation rel,
}
}
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- if (postingoff == 0)
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ ((pageprefix && postingoff != 0) ? REGBUF_FORCE_IMAGE : 0));
+ if (pageprefix && postingoff != 0)
+ {
+ /*
+ * A posting-list split on a prefix page rewrites an existing
+ * coded posting and adds a coded item; rather than teach redo to
+ * re-code both, force a full-page image (this is a rare case).
+ * postingoff is still recorded for the record format.
+ */
+ upostingoff = postingoff;
+ XLogRegisterBufData(0, &upostingoff, sizeof(uint16));
+ }
+ else if (pageprefix)
+ {
+ /*
+ * Simple insert on a prefix page: log the CODED item bytes as
+ * they were placed on the page, so redo adds them verbatim at
+ * newitemoff -- no full-page image needed, and byte-identical.
+ */
+ XLogRegisterBufData(0, (char *) codeditup, IndexTupleSize(codeditup));
+ }
+ else if (postingoff == 0)
{
/* Just log itup from caller */
XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -1418,6 +1496,11 @@ _bt_insertonpg(Relation rel,
END_CRIT_SECTION();
+ if (codeditup != itup)
+ pfree(codeditup);
+ if (codednposting != NULL && codednposting != nposting)
+ pfree(codednposting);
+
/* Release subsidiary buffers */
if (BufferIsValid(metabuf))
_bt_relbuf(rel, metabuf);
@@ -1506,6 +1589,22 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
ItemId itemid;
IndexTuple firstright,
lefthighkey;
+ BTItemBuf frbuf; /* decode scratch for firstright */
+ BTItemBuf llbuf; /* decode scratch for lastleft */
+ IndexTuple *leftitems = NULL; /* leaf: plain items for the left half */
+ IndexTuple *rightitems = NULL; /* leaf: plain items for the right half */
+ int nleft = 0;
+ int nright = 0;
+ IndexTuple righthighkey = NULL; /* leaf right half's high key (NULL=rightmost) */
+ const char *origprefix = NULL;
+ Size origprefixlen = 0;
+ char leftprefix[BTREE_PREFIX_MAX]; /* chosen prefix of each leaf half */
+ char rightprefix[BTREE_PREFIX_MAX];
+ Size leftprefixlen = 0;
+ Size rightprefixlen = 0;
+ char *rightwalbuf = NULL; /* packed right-page WAL payload */
+ Size rightwalbuflen = 0;
+ bool force_rfpi = false; /* carry the right page as a forced image */
OffsetNumber firstrightoff;
OffsetNumber afterleftoff,
afterrightoff,
@@ -1575,7 +1674,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
lopaque->btpo_flags = oopaque->btpo_flags;
- lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
/* set flag in leftpage indicating that rightpage has no downlink yet */
lopaque->btpo_flags |= BTP_INCOMPLETE_SPLIT;
lopaque->btpo_prev = oopaque->btpo_prev;
@@ -1650,6 +1749,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
itemid = PageGetItemId(origpage, firstrightoff);
itemsz = ItemIdGetLength(itemid);
firstright = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ firstright = _bt_prefix_decode(origpage, firstright, &frbuf);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
}
@@ -1673,6 +1774,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque));
itemid = PageGetItemId(origpage, lastleftoff);
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ lastleft = _bt_prefix_decode(origpage, lastleft, &llbuf);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
}
@@ -1712,7 +1815,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
/*
- * Add new high key to leftpage
+ * Add new high key to leftpage. For a leaf split the left half is built
+ * as a whole below (so it can be prefix-compressed from the start, which
+ * a plain incremental build cannot represent when the page holds more
+ * logical data than fits plain); the high key is passed to the builder
+ * then. Internal halves are built incrementally here as before.
*/
afterleftoff = P_HIKEY;
@@ -1720,11 +1827,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(BTreeTupleGetNAtts(lefthighkey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(itemsz == MAXALIGN(IndexTupleSize(lefthighkey)));
- if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
- elog(ERROR, "failed to add high key to the left sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
- afterleftoff = OffsetNumberNext(afterleftoff);
+ if (isleaf)
+ {
+ /* The builder needs a stable copy; lefthighkey points into workspace. */
+ lefthighkey = CopyIndexTuple(lefthighkey);
+ leftitems = palloc_array(IndexTuple, maxoff + 2);
+ rightitems = palloc_array(IndexTuple, maxoff + 2);
+ if (P_HASPREFIX(oopaque))
+ origprefix = _bt_page_get_prefix(origpage, &origprefixlen);
+ }
+ else
+ {
+ if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the left sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterleftoff = OffsetNumberNext(afterleftoff);
+ }
/*
* Acquire a new right page to split into, now that left page has a new
@@ -1765,7 +1884,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
ropaque->btpo_flags = oopaque->btpo_flags;
- ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = oopaque->btpo_next;
ropaque->btpo_level = oopaque->btpo_level;
@@ -1782,21 +1901,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (!isrightmost)
{
- IndexTuple righthighkey;
+ ItemId hiitemid = PageGetItemId(origpage, P_HIKEY);
+ Size hiitemsz = ItemIdGetLength(hiitemid);
+ IndexTuple hikey = (IndexTuple) PageGetItem(origpage, hiitemid);
- itemid = PageGetItemId(origpage, P_HIKEY);
- itemsz = ItemIdGetLength(itemid);
- righthighkey = (IndexTuple) PageGetItem(origpage, itemid);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) > 0);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) <=
+ Assert(BTreeTupleGetNAtts(hikey, rel) > 0);
+ Assert(BTreeTupleGetNAtts(hikey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
- if (PageAddItem(rightpage, righthighkey, itemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ if (isleaf)
+ righthighkey = CopyIndexTuple(hikey); /* stable: origpage is
+ * overwritten by the left
+ * half before WAL logging */
+ else
{
- elog(ERROR, "failed to add high key to the right sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
+ if (PageAddItem(rightpage, hikey, hiitemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the right sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterrightoff = OffsetNumberNext(afterrightoff);
}
- afterrightoff = OffsetNumberNext(afterrightoff);
}
/*
@@ -1813,7 +1936,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*
* Note: we *must* insert at least the right page's items in item-number
* order, for the benefit of _bt_restore_page().
+ *
+ * A leaf split builds each half as a whole page below (so it can be
+ * prefix-compressed from the start), which redo must reproduce byte for
+ * byte. To keep the primary and redo in lock step, both derive the
+ * split's plain item arrays from the original page through the single
+ * shared routine _bt_split_leaf_collect(); the incremental loop here
+ * handles only the internal (!isleaf) case.
*/
+ if (isleaf)
+ {
+ _bt_split_leaf_collect(origpage, firstrightoff, newitemoff, newitem,
+ newitemonleft, origpagepostingoff, nposting,
+ leftitems, &nleft, rightitems, &nright);
+ }
+ else
for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
{
IndexTuple dataitem;
@@ -1837,24 +1974,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
{
Assert(newitemoff <= firstrightoff);
if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
- false))
- {
+ false))
elog(ERROR, "failed to add new item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
Assert(newitemoff >= firstrightoff);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1863,28 +1996,24 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (i < firstrightoff)
{
if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
- {
elog(ERROR, "failed to add old item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add old item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
- /* Handle case where newitem goes at the end of rightpage */
- if (i <= newitemoff)
+ /* Handle case where newitem goes at the end of rightpage (internal only) */
+ if (!isleaf && i <= newitemoff)
{
/*
* Can't have newitemonleft here; that would imply we were told to put
@@ -1893,15 +2022,90 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*/
Assert(!newitemonleft && newitemoff == maxoff + 1);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
+ /*
+ * Leaf halves are built as whole pages now, so they can be prefix-
+ * compressed from the start (Strategy 1: a page is faulted into the
+ * BTP_PREFIX format only when it is written whole). The plain item arrays
+ * were collected above by _bt_split_leaf_collect(), the same routine redo
+ * uses, so the primary and redo lay out identical bytes.
+ */
+ if (isleaf)
+ {
+ _bt_split_leaf_half(rel, leftpage, lefthighkey, leftitems, nleft,
+ origprefix, origprefixlen,
+ leftprefix, &leftprefixlen);
+ _bt_split_leaf_half(rel, rightpage, righthighkey, rightitems, nright,
+ origprefix, origprefixlen,
+ rightprefix, &rightprefixlen);
+
+ /*
+ * Build the right page's WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the right prefix bytes, then the
+ * right high key (unless rightmost), then the right half's plain items,
+ * each MAXALIGN'd, in one contiguous buffer logged with a single rdata
+ * slot. A very compressible right half whose plain items would exceed
+ * a page is instead carried as a full-page image (set below), so this
+ * buffer is bounded.
+ */
+ if (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0)
+ {
+ Size roff = rightprefixlen;
+
+ if (righthighkey != NULL)
+ roff += MAXALIGN(IndexTupleSize(righthighkey));
+ for (int ri = 0; ri < nright; ri++)
+ roff += MAXALIGN(IndexTupleSize(rightitems[ri]));
+ rightwalbuf = palloc(Max(roff, 1));
+ rightwalbuflen = roff;
+
+ /*
+ * Decide (before the critical section, since the injection-point
+ * cache load can allocate) whether to carry the right page as a
+ * forced full-page image: normally only when its plain payload
+ * would exceed a block, but a test can force the otherwise-rare
+ * path with the nbtree-prefix-force-rfpi injection point.
+ */
+ if (rightwalbuflen > BLCKSZ)
+ force_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ else if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ force_rfpi = true;
+#endif
+
+ roff = 0;
+ if (rightprefixlen > 0)
+ {
+ memcpy(rightwalbuf, rightprefix, rightprefixlen);
+ roff += rightprefixlen;
+ }
+ if (righthighkey != NULL)
+ {
+ Size hsz = IndexTupleSize(righthighkey);
+
+ memcpy(rightwalbuf + roff, righthighkey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(rightwalbuf + roff + hsz, 0, MAXALIGN(hsz) - hsz);
+ roff += MAXALIGN(hsz);
+ }
+ for (int ri = 0; ri < nright; ri++)
+ {
+ Size rsz = IndexTupleSize(rightitems[ri]);
+
+ memcpy(rightwalbuf + roff, rightitems[ri], rsz);
+ if (MAXALIGN(rsz) != rsz)
+ memset(rightwalbuf + roff + rsz, 0, MAXALIGN(rsz) - rsz);
+ roff += MAXALIGN(rsz);
+ }
+ }
+ }
+
/*
* We have to grab the original right sibling (if any) and update its prev
* link. We are guaranteed that this is deadlock-free, since we couple
@@ -2005,17 +2209,71 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /*
+ * A leaf split that involves prefix compression (either half ends up
+ * compressed, or the pre-split page was compressed so its items are
+ * coded) is replayed by rebuilding both halves from the pre-split page,
+ * exactly as the primary did, rather than by replaying logged tuples --
+ * the coded physical layout would not match _bt_restore_page. No
+ * full-page image is needed; redo reconstructs the plain items from the
+ * original page (block 0) and lays each half out coded against the
+ * chosen prefix, whose length and bytes are carried in the record.
+ */
+ xlrec.flags = 0;
+ xlrec.leftprefixlen = 0;
+ xlrec.rightprefixlen = 0;
+ if (isleaf && (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0))
+ {
+ xlrec.flags |= XLH_SPLIT_LEAFPREFIX;
+ xlrec.leftprefixlen = (uint8) leftprefixlen;
+ xlrec.rightprefixlen = (uint8) rightprefixlen;
+
+ /*
+ * The right half is normally carried as its packed plain payload
+ * (redo lays it out coded), far cheaper than a full-page image.
+ * But a very compressible page can hold more plain data than fits
+ * in one page; force_rfpi (decided before the critical section)
+ * carries the coded right page as a bounded full-page image instead.
+ */
+ if (force_rfpi)
+ xlrec.flags |= XLH_SPLIT_RFPI;
+ }
+
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
+ /*
+ * The right page is rebuilt from scratch in redo. Ordinarily that is
+ * from the logged tuple region (REGBUF_WILL_INIT). A prefix-leaf split
+ * instead rebuilds it from its logged plain items, unless those would
+ * overflow the record (XLH_SPLIT_RFPI), in which case the coded page is
+ * carried as a forced full-page image.
+ */
+ XLogRegisterBuffer(1, rbuf,
+ (xlrec.flags & XLH_SPLIT_RFPI) ?
+ (REGBUF_STANDARD | REGBUF_FORCE_IMAGE) : REGBUF_WILL_INIT);
/* Log original right sibling, since we've changed its prev-pointer */
if (!isrightmost)
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
if (!isleaf)
XLogRegisterBuffer(3, cbuf, REGBUF_STANDARD);
+ /*
+ * For a prefix leaf split, carry each half's chosen prefix bytes, and
+ * (unless the right page is carried as a full-page image) the right
+ * high key and the right half's PLAIN items on block 1, so redo can lay
+ * the right page out coded without depending on block 0's state (block
+ * 0 may be restored from an automatic full-page image). The left half
+ * is rebuilt in redo from the original page (block 0) when it needs
+ * redo, or from its own FPI otherwise, so the left items are not logged.
+ */
+ if (xlrec.flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ if (!(xlrec.flags & XLH_SPLIT_RFPI) && rightwalbuflen > 0)
+ XLogRegisterBufData(1, rightwalbuf, rightwalbuflen);
+ }
+
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
@@ -2063,6 +2321,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
XLogRegisterBufData(0, lefthighkey,
MAXALIGN(IndexTupleSize(lefthighkey)));
+ /*
+ * For a prefix leaf split, the left half's chosen prefix bytes follow
+ * the high key on block 0 (redo reads newitem/orignewitem, then the
+ * high key, then this prefix).
+ */
+ if ((xlrec.flags & XLH_SPLIT_LEAFPREFIX) && leftprefixlen > 0)
+ XLogRegisterBufData(0, leftprefix, leftprefixlen);
+
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). The whole right page will be recreated.
@@ -2071,11 +2337,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the line pointers can be reconstructed. See comments for
- * _bt_restore_page().
+ * _bt_restore_page(). A prefix-compressed leaf split instead rebuilds
+ * the right page from the original page in redo (see XLH_SPLIT_LEAFPREFIX),
+ * so its tuple region is not logged.
*/
- XLogRegisterBufData(1,
- (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
- ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
+ if (!(xlrec.flags & XLH_SPLIT_LEAFPREFIX))
+ XLogRegisterBufData(1,
+ (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
+ ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
xlinfo = newitemonleft ? XLOG_BTREE_SPLIT_L : XLOG_BTREE_SPLIT_R;
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
@@ -2820,7 +3089,15 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
- /* Perform deduplication pass (when enabled and index-is-allequalimage) */
+ /*
+ * Perform deduplication pass (when enabled and index-is-allequalimage).
+ *
+ * On a prefix-compressed page the pass decodes items and lays the deduped
+ * result out plain before re-coding it; the decoded set can exceed one
+ * page even when the coded form fit, in which case the pass aborts without
+ * touching the page and we fall through to split it. When it does fit, the
+ * recompressed page's WAL replays without a full-page image.
+ */
if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
_bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz,
(indexUnchanged || uniquedup));
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f81..c755efc81b0 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1212,11 +1212,26 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * If VACUUM emptied a prefix-compressed leaf of all its data items, drop
+ * the PREFIX item too and clear BTP_PREFIX: an empty page carries no
+ * prefix, and page deletion relies on an emptied leaf holding only its
+ * high key (nbtpage.c's _bt_mark_page_halfdead). Recorded in the vacuum
+ * record so redo does the same.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
/*
@@ -2245,6 +2260,20 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
opaque = BTPageGetOpaque(page);
opaque->btpo_flags |= BTP_HALF_DEAD;
+ /*
+ * A prefix-compressed leaf being deleted may still carry its PREFIX item
+ * (e.g. emptied by simple deletion, which does not drop it). Remove it
+ * and clear BTP_PREFIX so the page holds only its high key, as the
+ * half-dead transition below requires. Redo (btree_xlog_mark_page_halfdead)
+ * reinitializes the page, so it needs no separate handling.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
MemSet(&trunctuple, 0, sizeof(IndexTupleData));
trunctuple.t_info = sizeof(IndexTupleData);
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 00000000000..c725f75cd92
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,814 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Leaf key prefix compression for Postgres btrees.
+ *
+ * A leaf page of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page front-codes the first key column of each non-pivot tuple against a
+ * per-page PREFIX item, so that a long key that repeats on a page costs
+ * little more than its unshared tail. The on-disk format is documented at
+ * BTP_PREFIX in nbtree.h; this file is the encoder, decoder, and the policy
+ * that chooses a page's prefix.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo repeats them, and decoding reproduces the plain
+ * tuple byte for byte. A tuple inserted later that shares less of the prefix
+ * just stores a smaller `shared' count, so no insert, VACUUM, deduplication
+ * or change of the page's key range ever has to rewrite the page to keep the
+ * prefix valid. The prefix is chosen only when a page is written as a whole
+ * (CREATE INDEX, or a half of a split).
+ *
+ * This is modeled on the leaf prefix compression of the BARK access method
+ * (src/backend/access/bark/barkutils.c).
+ *
+ *
+ * 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 "storage/bufpage.h"
+#include "utils/rel.h"
+
+/*
+ * Where each part of a tuple goes in its coded form. Coding and decoding
+ * both lay the tuple out from this, so the two cannot disagree.
+ */
+typedef struct BTPrefixPlan
+{
+ Size hoff; /* data offset: header and null bitmap */
+ Size c1; /* plain first column's size, with header */
+ Size keyend; /* end of the plain key (posting offset) */
+ uint8 code; /* first payload byte of the coded column */
+ const char *suffix; /* bytes stored after it */
+ Size suffixlen;
+ Size vsize; /* coded column's size, with header */
+ Size codedkeyend; /* end of the coded key */
+ Size total; /* size of the coded tuple */
+} BTPrefixPlan;
+
+/*
+ * Is `itup' stored coded on a BTP_PREFIX page? Only a non-pivot tuple (plain
+ * or posting) whose first column is not NULL; a pivot (high key, downlink)
+ * is stored as it is.
+ */
+static bool
+_bt_prefix_codable(IndexTuple itup)
+{
+ if (BTreeTupleIsPivot(itup))
+ return false;
+ return !IndexTupleHasNulls(itup) ||
+ !att_isnull(0, (uint8 *) itup + sizeof(IndexTupleData));
+}
+
+/*
+ * Does the varlena `val' have the header decoding would give it back: the
+ * 1-byte header if its data fit one, else an uncompressed 4-byte header?
+ */
+static bool
+_bt_prefix_canonical(const char *val)
+{
+ if (VARATT_IS_SHORT(val))
+ return !VARATT_IS_EXTERNAL(val);
+ return !VARATT_IS_EXTENDED(val) &&
+ VARSIZE(val) - VARHDRSZ + VARHDRSZ_SHORT > VARATT_SHORT_MAX;
+}
+
+/*
+ * End of a leaf tuple's key: the posting-list offset for a posting tuple, the
+ * whole tuple size otherwise. The bytes after the key (a posting list) are
+ * moved as they are.
+ */
+static Size
+_bt_prefix_keyend(IndexTuple itup)
+{
+ if (BTreeTupleIsPosting(itup))
+ return BTreeTupleGetPostingOffset(itup);
+ return IndexTupleSize(itup);
+}
+
+/*
+ * The size of a coded first column whose payload is `payload' bytes: a
+ * varlena with a 1-byte header when it fits one.
+ */
+static inline Size
+_bt_prefix_vsize(Size payload)
+{
+ if (payload + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ return payload + VARHDRSZ_SHORT;
+ return payload + VARHDRSZ;
+}
+
+/*
+ * Offset at which the bytes after the first column go in a coded tuple whose
+ * coded column ends at `vend', when in the plain tuple they start at
+ * `restorig': the first offset at or after `vend' that is the same distance
+ * past a MAXALIGN boundary.
+ */
+static inline Size
+_bt_prefix_restoff(Size vend, Size restorig)
+{
+ return vend + (MAXIMUM_ALIGNOF + restorig % MAXIMUM_ALIGNOF -
+ vend % MAXIMUM_ALIGNOF) % MAXIMUM_ALIGNOF;
+}
+
+/* Plan the coding of the codable tuple `itup' against `prefix'. */
+static void
+_bt_prefix_plan(const char *prefix, Size prefixlen, IndexTuple itup,
+ BTPrefixPlan *plan)
+{
+ const char *val;
+ Size restorig;
+ bool storerest;
+
+ plan->hoff = IndexInfoFindDataOffset(itup->t_info);
+ val = (const char *) itup + plan->hoff;
+ plan->c1 = VARSIZE_ANY(val);
+ plan->keyend = _bt_prefix_keyend(itup);
+
+ if (_bt_prefix_canonical(val))
+ {
+ const char *data = VARDATA_ANY(val);
+ Size datalen = VARSIZE_ANY_EXHDR(val);
+ Size limit = Min(prefixlen, datalen);
+ Size shared = 0;
+
+ while (shared < limit && prefix[shared] == data[shared])
+ shared++;
+ plan->code = (uint8) shared;
+ plan->suffix = data + shared;
+ plan->suffixlen = datalen - shared;
+ }
+ else
+ {
+ plan->code = BTREE_PREFIX_RAW;
+ plan->suffix = val;
+ plan->suffixlen = plan->c1;
+ }
+ plan->vsize = _bt_prefix_vsize(1 + plan->suffixlen);
+
+ /*
+ * The bytes after the first column can be left out when they are just the
+ * zero padding that ends the key.
+ */
+ restorig = plan->hoff + plan->c1;
+ storerest = plan->keyend != MAXALIGN(restorig);
+ for (Size i = restorig; !storerest && i < plan->keyend; i++)
+ storerest = ((const char *) itup)[i] != 0;
+
+ if (storerest)
+ {
+ plan->code |= BTREE_PREFIX_REST;
+ plan->codedkeyend = _bt_prefix_restoff(plan->hoff + plan->vsize,
+ restorig) +
+ (plan->keyend - restorig);
+ }
+ else
+ plan->codedkeyend = MAXALIGN(plan->hoff + plan->vsize);
+ plan->total = plan->codedkeyend + (IndexTupleSize(itup) - plan->keyend);
+}
+
+/*
+ * Copy n bytes, as memcpy. Decoding moves a few short runs of bytes per
+ * tuple; eight bytes at a time is several times faster than a libc memcpy
+ * for runs this short (the string-move startup cost dominates otherwise).
+ */
+static inline void
+_bt_prefix_copy(char *dst, const char *src, Size n)
+{
+ while (n >= sizeof(uint64))
+ {
+ memcpy(dst, src, sizeof(uint64));
+ dst += sizeof(uint64);
+ src += sizeof(uint64);
+ n -= sizeof(uint64);
+ }
+ while (n-- > 0)
+ *dst++ = *src++;
+}
+
+/* The prefix of a BTP_PREFIX page, and its length in *len. */
+const char *
+_bt_page_get_prefix(Page page, Size *len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ IndexTuple item;
+
+ Assert(P_HASPREFIX(opaque));
+ item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page,
+ BTPageGetPrefixOff(opaque)));
+ *len = IndexTupleSize(item) - sizeof(IndexTupleData);
+ return (const char *) item + sizeof(IndexTupleData);
+}
+
+/*
+ * Give `page', a leaf holding nothing yet but its high key (if it has one),
+ * the prefix `prefix' of `len' bytes: add the PREFIX item and set BTP_PREFIX.
+ * Its padding comes from the page's free space, which _bt_pageinit zeroed, so
+ * a page rebuilt in redo is the same.
+ */
+void
+_bt_page_set_prefix(Page page, const char *prefix, Size len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ union
+ {
+ IndexTupleData hdr;
+ char data[sizeof(IndexTupleData) + BTREE_PREFIX_MAX];
+ } item;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+ Assert(len >= 1 && len <= BTREE_PREFIX_MAX);
+ Assert(PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque) - 1);
+
+ memset(&item.hdr, 0, sizeof(IndexTupleData));
+ item.hdr.t_info = (unsigned short) (sizeof(IndexTupleData) + len);
+ memcpy(item.data + sizeof(IndexTupleData), prefix, len);
+ if (PageAddItem(page, item.data, sizeof(IndexTupleData) + len,
+ BTPageGetPrefixOff(opaque), false, false) ==
+ InvalidOffsetNumber)
+ elog(ERROR, "failed to add prefix item to btree page");
+ opaque->btpo_flags |= BTP_PREFIX;
+}
+
+/*
+ * The coded form of the leaf tuple `itup' for `page', palloc'd; `itup' itself
+ * when the page has no prefix or the tuple is stored plain.
+ */
+IndexTuple
+_bt_prefix_encode(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+ char *coded;
+ char *v;
+ char *payload;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ Assert(plan.total <= INDEX_SIZE_MASK);
+
+ coded = palloc0(plan.total);
+ memcpy(coded, itup, plan.hoff);
+ v = coded + plan.hoff;
+ if (plan.vsize <= VARATT_SHORT_MAX)
+ {
+ SET_VARSIZE_SHORT(v, plan.vsize);
+ payload = v + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ SET_VARSIZE(v, plan.vsize);
+ payload = v + VARHDRSZ;
+ }
+ payload[0] = (char) plan.code;
+ memcpy(payload + 1, plan.suffix, plan.suffixlen);
+ if (plan.code & BTREE_PREFIX_REST)
+ {
+ Size restorig = plan.hoff + plan.c1;
+
+ memcpy(coded + _bt_prefix_restoff(plan.hoff + plan.vsize, restorig),
+ (char *) itup + restorig, plan.keyend - restorig);
+ }
+ memcpy(coded + plan.codedkeyend, (char *) itup + plan.keyend,
+ IndexTupleSize(itup) - plan.keyend);
+
+ ((IndexTuple) coded)->t_info =
+ (itup->t_info & ~INDEX_SIZE_MASK) | (unsigned short) plan.total;
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) coded, BTreeTupleGetNPosting(itup),
+ plan.codedkeyend);
+
+#ifdef USE_ASSERT_CHECKING
+ {
+ BTItemBuf check;
+ IndexTuple decoded = _bt_prefix_decode(page, (IndexTuple) coded,
+ &check);
+
+ Assert(IndexTupleSize(decoded) == IndexTupleSize(itup) &&
+ memcmp(decoded, itup, IndexTupleSize(itup)) == 0);
+ }
+#endif
+
+ return (IndexTuple) coded;
+}
+
+/* The MAXALIGNed size `itup' takes on `page', coded if the page codes it. */
+Size
+_bt_prefix_coded_size(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return MAXALIGN(IndexTupleSize(itup));
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ return MAXALIGN(plan.total);
+}
+
+/*
+ * Decode the item `itup' of the BTP_PREFIX page `page' into `buf', and return
+ * it; an item stored plain is returned as it is. The inverse of
+ * _bt_prefix_encode, byte for byte. An item that does not decode within its
+ * own bounds, or claims more of the prefix than the page has, is reported as
+ * corruption.
+ */
+IndexTuple
+_bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf)
+{
+ const char *prefix;
+ Size prefixlen;
+ Size size = IndexTupleSize(itup);
+ Size hoff;
+ Size codedkeyend;
+ const char *v;
+ Size vsize = 0;
+ const char *payload;
+ Size paylen = 0;
+ uint8 code;
+ Size shared;
+ char *out = buf->data;
+ char *val = NULL;
+ Size c1;
+ Size restorig;
+ Size keyend;
+
+ if (!_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ hoff = IndexInfoFindDataOffset(itup->t_info);
+ codedkeyend = _bt_prefix_keyend(itup);
+ v = (const char *) itup + hoff;
+ if (hoff >= codedkeyend || codedkeyend > size ||
+ (vsize = VARSIZE_ANY(v)) > codedkeyend - hoff ||
+ (paylen = VARSIZE_ANY_EXHDR(v)) < 1)
+ goto corrupt;
+ payload = VARDATA_ANY(v);
+ code = (uint8) payload[0];
+ shared = code & BTREE_PREFIX_SHARED_MASK;
+
+ _bt_prefix_copy(out, (const char *) itup, hoff);
+ val = out + hoff;
+ if (shared == BTREE_PREFIX_RAW)
+ {
+ c1 = paylen - 1;
+ _bt_prefix_copy(val, payload + 1, c1);
+ if (c1 < VARHDRSZ_SHORT || VARSIZE_ANY(val) != c1)
+ goto corrupt;
+ }
+ else
+ {
+ Size datalen = shared + paylen - 1;
+ char *data;
+
+ if (hoff + VARHDRSZ + datalen > sizeof(BTItemBuf))
+ goto corrupt;
+ if (shared > prefixlen)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple shares %zu bytes of a %zu-byte page prefix",
+ shared, prefixlen)));
+ if (datalen + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ {
+ c1 = datalen + VARHDRSZ_SHORT;
+ SET_VARSIZE_SHORT(val, c1);
+ data = val + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ c1 = datalen + VARHDRSZ;
+ SET_VARSIZE(val, c1);
+ data = val + VARHDRSZ;
+ }
+ _bt_prefix_copy(data, prefix, shared);
+ _bt_prefix_copy(data + shared, payload + 1, paylen - 1);
+ }
+
+ restorig = hoff + c1;
+ if (code & BTREE_PREFIX_REST)
+ {
+ Size restoff = _bt_prefix_restoff(hoff + vsize, restorig);
+
+ if (restoff > codedkeyend)
+ goto corrupt;
+ keyend = restorig + (codedkeyend - restoff);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ _bt_prefix_copy(out + restorig, (const char *) itup + restoff,
+ codedkeyend - restoff);
+ }
+ else
+ {
+ keyend = MAXALIGN(restorig);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ memset(out + restorig, 0, keyend - restorig);
+ }
+ _bt_prefix_copy(out + keyend, (const char *) itup + codedkeyend,
+ size - codedkeyend);
+
+ ((IndexTuple) out)->t_info = (itup->t_info & ~INDEX_SIZE_MASK) |
+ (unsigned short) (keyend + (size - codedkeyend));
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) out, BTreeTupleGetNPosting(itup),
+ keyend);
+ return (IndexTuple) out;
+
+corrupt:
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple is not a valid prefix-coded tuple")));
+ return NULL; /* keep compiler quiet */
+}
+
+/*
+ * The bytes of `itup''s first column a page prefix may be taken from, and how
+ * many (at most BTREE_PREFIX_MAX) in *len; NULL when the tuple has none: it
+ * is not codable, its value is empty, or it would be stored raw.
+ */
+const char *
+_bt_prefix_candidate(IndexTuple itup, Size *len)
+{
+ const char *val;
+
+ if (!_bt_prefix_codable(itup))
+ return NULL;
+ val = (const char *) itup + IndexInfoFindDataOffset(itup->t_info);
+ if (!_bt_prefix_canonical(val) || VARSIZE_ANY_EXHDR(val) == 0)
+ return NULL;
+ *len = Min(VARSIZE_ANY_EXHDR(val), BTREE_PREFIX_MAX);
+ return VARDATA_ANY(val);
+}
+
+/*
+ * How many bytes of `prefix' the first column of `itup' would share on a page
+ * with that prefix: -1 when the tuple would not be coded, 0 when it would be
+ * stored raw.
+ */
+int
+_bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup)
+{
+ BTPrefixPlan plan;
+
+ if (!_bt_prefix_codable(itup))
+ return -1;
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ if ((plan.code & BTREE_PREFIX_SHARED_MASK) == BTREE_PREFIX_RAW)
+ return 0;
+ return plan.code & BTREE_PREFIX_SHARED_MASK;
+}
+
+/*
+ * May the leaves of `index' have a prefix? Only when the prefix_compression
+ * reloption is on and the first key column is of a varlena type.
+ */
+bool
+_bt_prefix_enabled(Relation index)
+{
+ return BTGetPrefixCompression(index) &&
+ TupleDescAttr(RelationGetDescr(index), 0)->attlen == -1;
+}
+
+/*
+ * Choose the prefix for a leaf about to be built from the `n' tuples `items',
+ * in key order: the first tuple's first-column bytes, cut to the longest run
+ * any later tuple shares, provided the tuples share at least
+ * BTREE_PREFIX_MIN_SHARED bytes of it on average. Returns the prefix's
+ * length, with *prefix pointing into items[0], or 0 for no prefix.
+ */
+Size
+_bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix)
+{
+ const char *cand;
+ Size candlen;
+ Size longest = 0;
+ Size total = 0;
+ int ncoded = 0;
+
+ if (!_bt_prefix_enabled(index) ||
+ (cand = _bt_prefix_candidate(items[0], &candlen)) == NULL)
+ return 0;
+ if (n == 1)
+ {
+ *prefix = cand;
+ return candlen;
+ }
+ for (int i = 1; i < n; i++)
+ {
+ int shared = _bt_prefix_shared(cand, candlen, items[i]);
+
+ if (shared < 0)
+ continue;
+ total += shared;
+ ncoded++;
+ longest = Max(longest, (Size) shared);
+ }
+ if (longest == 0 || total < (Size) BTREE_PREFIX_MIN_SHARED * ncoded)
+ return 0;
+ *prefix = cand;
+ return longest;
+}
+
+/*
+ * Fault a freshly finished leaf `page' forward into the BTP_PREFIX format if
+ * a prefix pays off (Strategy 1: compress a page only when it is written as a
+ * whole -- CREATE INDEX page finish, a split half, a dedup rewrite -- so that
+ * no insert, VACUUM or key-range change ever forces a page rewrite, and an
+ * old-format page is converted only when it is next written whole).
+ *
+ * On entry `page' is a complete leaf: its high key at P_HIKEY if it is not
+ * rightmost, then its data items, none of them coded (the page does not yet
+ * have BTP_PREFIX). If the data items share enough of a common first-column
+ * prefix, the page is rebuilt in place with the PREFIX item inserted after
+ * the high key and every data item coded against it; otherwise the page is
+ * left unchanged. Item order and the high key are preserved.
+ *
+ * Caller must hold the page's buffer exclusively (or own the page outright,
+ * as the build and split paths do with a temp page). No WAL is written here:
+ * callers log the resulting page (a build writes it whole; a split logs each
+ * half whole or by full-page image), and redo reaches the same bytes by
+ * running _bt_prefix_encode against the same prefix.
+ */
+void
+_bt_prefix_compress_page(Relation index, Page page)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber minoff;
+ OffsetNumber maxoff;
+ int n;
+ IndexTuple *items;
+ Size prefixlen;
+ const char *prefix;
+ Page tmp;
+ OffsetNumber o;
+ IndexTuple hikey = NULL;
+ Size hikeysz = 0;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+
+ if (!_bt_prefix_enabled(index))
+ return;
+
+ minoff = P_FIRSTDATAKEY(opaque);
+ maxoff = PageGetMaxOffsetNumber(page);
+ n = (int) maxoff - (int) minoff + 1;
+ if (n <= 0)
+ return;
+
+ items = (IndexTuple *) palloc(n * sizeof(IndexTuple));
+ for (int i = 0; i < n; i++)
+ items[i] = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, minoff + i));
+
+ prefixlen = _bt_prefix_choose(index, items, n, &prefix);
+ if (prefixlen == 0)
+ {
+ pfree(items);
+ return;
+ }
+
+ /* The high key (if any) stays at P_HIKEY, uncoded. */
+ if (!P_RIGHTMOST(opaque))
+ {
+ ItemId hii = PageGetItemId(page, P_HIKEY);
+
+ hikeysz = ItemIdGetLength(hii);
+ hikey = (IndexTuple) PageGetItem(page, hii);
+ }
+
+ /*
+ * Rebuild into a temp page: high key (if any), PREFIX item, then each
+ * data item coded. The coded form can be up to MAXALIGN larger than the
+ * plain one, but the PREFIX item replaces far more shared bytes than it
+ * adds, so a page that was not overfull stays within bounds; if a coded
+ * item somehow does not fit, leave the page uncompressed (always valid).
+ */
+ tmp = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(tmp)->btpo_flags &= ~BTP_PREFIX;
+ if (hikey != NULL &&
+ PageAddItem(tmp, (char *) hikey, hikeysz, P_HIKEY, false, false) ==
+ InvalidOffsetNumber)
+ goto giveup;
+ _bt_page_set_prefix(tmp, prefix, prefixlen);
+ o = OffsetNumberNext(PageGetMaxOffsetNumber(tmp));
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(tmp, items[i]);
+ bool ok;
+
+ ok = PageAddItem(tmp, (char *) coded, IndexTupleSize(coded), o++,
+ false, false) != InvalidOffsetNumber;
+ if (coded != items[i])
+ pfree(coded);
+ if (!ok)
+ goto giveup;
+ }
+
+ memcpy(page, tmp, BLCKSZ);
+ pfree(tmp);
+ pfree(items);
+ return;
+
+giveup:
+ pfree(tmp);
+ pfree(items);
+}
+
+/*
+ * Lay out one half of a leaf split on `page', which the caller has
+ * initialized (flags, links) and left empty: the high key `hikey' (NULL for
+ * a rightmost half), then the `n' plain tuples `items' in key order.
+ *
+ * The half is formed as a whole, so it may take a prefix of its own: its
+ * first item's leading bytes, if the items share enough (_bt_prefix_choose).
+ * Because a prefix-compressed leaf can hold more logical data than fits in a
+ * plain page, the half is laid out coded from the start rather than built
+ * plain and compressed afterwards. When the half does not fit with its own
+ * prefix (or shares too little to take one) it is laid out plain, and if that
+ * does not fit either, with `origprefix' (the splitting page's prefix, zero
+ * length when it had none), against which it fits by construction: the items
+ * keep the shares they had on the original page.
+ *
+ * Mirrors BARK's bark_split_leaf_half. Callers: _bt_split (both halves).
+ */
+void
+_bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen)
+{
+ BTPageOpaqueData saved = *BTPageGetOpaque(page);
+ const char *prefix[3];
+ Size prefixlen[3];
+ int ntries = 0;
+
+ Assert(P_ISLEAF(&saved) && !P_HASPREFIX(&saved));
+
+ /* Candidate prefixes to try, in order: own, none, the orig page's. */
+ if (_bt_prefix_enabled(index))
+ {
+ prefixlen[ntries] = _bt_prefix_choose(index, items, n, &prefix[ntries]);
+ if (prefixlen[ntries] > 0)
+ ntries++;
+ }
+ prefix[ntries] = NULL;
+ prefixlen[ntries++] = 0;
+ if (origprefixlen > 0)
+ {
+ prefix[ntries] = origprefix;
+ prefixlen[ntries++] = origprefixlen;
+ }
+
+ for (int t = 0; t < ntries; t++)
+ {
+ if (_bt_split_leaf_layout(page, hikey, items, n, prefix[t], prefixlen[t]))
+ {
+ /* Report the prefix that won, so the split can log it for redo. */
+ if (chosenprefixlen != NULL)
+ {
+ *chosenprefixlen = prefixlen[t];
+ if (prefixlen[t] > 0)
+ memcpy(chosenprefix, prefix[t], prefixlen[t]);
+ }
+ return;
+ }
+ /* restore the empty-page state for the next attempt */
+ _bt_pageinit(page, BLCKSZ);
+ *BTPageGetOpaque(page) = saved;
+ }
+ elog(ERROR, "failed to lay out a split half of index \"%s\"",
+ RelationGetRelationName(index));
+}
+
+/*
+ * Lay out one leaf split half on the caller-initialized empty `page' using a
+ * GIVEN prefix (`prefixlen' 0 for none): the high key `hikey' (NULL for a
+ * rightmost half), the PREFIX item, then the `n' plain tuples `items' coded
+ * against the prefix. Returns false if they do not fit (caller tries another
+ * prefix). This is Relation-free and deterministic in (hikey, items, prefix),
+ * so WAL redo calls it with the logged prefix to reproduce the primary's page
+ * byte for byte.
+ */
+bool
+_bt_split_leaf_layout(Page page, IndexTuple hikey, IndexTuple *items, int n,
+ const char *prefix, Size prefixlen)
+{
+ OffsetNumber o = P_HIKEY;
+ bool fit = true;
+
+ Assert(P_ISLEAF(BTPageGetOpaque(page)) && !P_HASPREFIX(BTPageGetOpaque(page)));
+ if (hikey != NULL &&
+ PageAddItem(page, (char *) hikey, MAXALIGN(IndexTupleSize(hikey)),
+ o++, false, false) == InvalidOffsetNumber)
+ return false;
+ if (prefixlen > 0)
+ {
+ _bt_page_set_prefix(page, prefix, prefixlen);
+ o++;
+ }
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(page, items[i]);
+
+ if (PageAddItem(page, (char *) coded, IndexTupleSize(coded),
+ o++, false, false) == InvalidOffsetNumber)
+ fit = false;
+ if (coded != items[i])
+ pfree(coded);
+ if (!fit)
+ return false;
+ }
+#ifdef USE_ASSERT_CHECKING
+ if (P_HASPREFIX(BTPageGetOpaque(page)))
+ {
+ OffsetNumber mx = PageGetMaxOffsetNumber(page);
+ OffsetNumber off;
+
+ for (off = P_FIRSTDATAKEY(BTPageGetOpaque(page)); off <= mx; off++)
+ {
+ BTItemBuf vb;
+
+ (void) _bt_getitem(page, off, &vb);
+ }
+ }
+#endif
+ return true;
+}
+
+/*
+ * Collect the plain (decoded) data items of a leaf split into the left and
+ * right arrays, in the exact order and with the exact substitutions that the
+ * split performs: the original page's data items split at `firstrightoff',
+ * with `newitem' inserted at `newitemoff' on the side given by `newitemonleft'
+ * and, for a posting-list split, the posting at `origpagepostingoff' replaced
+ * by `nposting'. `origpage' is the pre-split page (its items are decoded when
+ * it is a BTP_PREFIX page). The arrays receive pointers that are valid until
+ * the caller is done laying out the halves; items taken from the page are
+ * copied, so the caller owns and should reset the surrounding memory context.
+ *
+ * Both the primary (_bt_split) and WAL redo (btree_xlog_split) collect items
+ * this way and then lay out each half with _bt_split_leaf_half, so the two
+ * produce byte-identical coded pages without logging a full-page image.
+ */
+void
+_bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft, OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright)
+{
+ BTPageOpaque oopaque = BTPageGetOpaque(origpage);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(origpage);
+ bool hasprefix = P_HASPREFIX(oopaque);
+ OffsetNumber i;
+
+ *nleft = 0;
+ *nright = 0;
+ for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
+ {
+ ItemId itemid = PageGetItemId(origpage, i);
+ IndexTuple dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ BTItemBuf dibuf;
+
+ if (hasprefix)
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+
+ if (i == origpagepostingoff)
+ dataitem = nposting;
+ else if (i == newitemoff)
+ {
+ if (newitemonleft)
+ leftitems[(*nleft)++] = newitem;
+ else
+ rightitems[(*nright)++] = newitem;
+ }
+
+ if (i < firstrightoff)
+ leftitems[(*nleft)++] = CopyIndexTuple(dataitem);
+ else
+ rightitems[(*nright)++] = CopyIndexTuple(dataitem);
+ }
+ if (i <= newitemoff)
+ rightitems[(*nright)++] = newitem;
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..ffc5846e4c9 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -145,6 +145,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ BTItemBuf itupbuf; /* decode workspace for a loop's current item */
+ BTItemBuf finalbuf; /* decode workspace for a backward finaltup */
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -252,7 +254,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
continue;
}
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -353,9 +355,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,7 +415,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
else
tuple_alive = true;
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -596,9 +596,10 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
BTScanOpaque so = (BTScanOpaque) scan->opaque;
Relation rel = scan->indexRelation;
TupleDesc tupdesc = RelationGetDescr(rel);
- ItemId iid;
IndexTuple firsttup,
lasttup;
+ BTItemBuf firstbuf,
+ lastbuf;
int startikey = 0,
arrayidx = 0,
firstchangingattnum;
@@ -616,12 +617,10 @@ _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);
+ 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 +2052,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
ScanDirection dir = pstate->dir;
OffsetNumber aheadoffnum;
IndexTuple ahead;
+ BTItemBuf aheadbuf; /* decode workspace for the look-ahead tuple */
Assert(!pstate->forcenonrequired);
@@ -2088,8 +2088,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..b8b8ec018f3 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -435,8 +435,10 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ Size half = BTTupleStorageSize(scan->indexRelation);
+
+ so->currTuples = (char *) palloc(half * 2);
+ so->markTuples = so->currTuples + half;
}
/*
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..f932ca33c3a 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -696,6 +696,7 @@ _bt_compare(Relation rel,
TupleDesc itupdesc = RelationGetDescr(rel);
BTPageOpaque opaque = BTPageGetOpaque(page);
IndexTuple itup;
+ BTItemBuf itupbuf;
ItemPointer heapTid;
ScanKey scankey;
int ncmpkey;
@@ -713,7 +714,7 @@ _bt_compare(Relation rel,
if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque))
return 1;
- itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ itup = _bt_getitem(page, offnum, &itupbuf);
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dcf4..9a8b372033e 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -241,6 +241,10 @@ typedef struct BTPageState
Size btps_lastextra; /* last item's extra posting list space */
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
+ /* Leaf prefix compression: the candidate prefix of the page being filled */
+ const char *btps_prefix; /* into the page's first data item, or NULL */
+ Size btps_prefixlen; /* its length (0 = no candidate) */
+ Size btps_codedsaved; /* bytes the page's items would save coded */
struct BTPageState *btps_next; /* link to parent level, if any */
} BTPageState;
@@ -856,7 +860,8 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
*/
Assert(last_truncextra == 0 || isleaf);
if (pgspc < itupsz + (isleaf ? MAXALIGN(sizeof(ItemPointerData)) : 0) ||
- (pgspc + last_truncextra < state->btps_full && last_off > P_FIRSTKEY))
+ (pgspc + last_truncextra + state->btps_codedsaved < state->btps_full &&
+ last_off > P_FIRSTKEY))
{
/*
* Finish off the page and write it out.
@@ -980,6 +985,15 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
nopaque->btpo_next = P_NONE; /* redundant */
}
+ /*
+ * The old leaf page is complete: fault it forward into the
+ * BTP_PREFIX format if a prefix pays off. Done after btps_lowkey
+ * (the downlink, a copy of the uncoded high key) is saved, since
+ * compression rewrites opage in place.
+ */
+ if (isleaf)
+ _bt_prefix_compress_page(wstate->index, opage);
+
/*
* Write out the old page. _bt_blwritepage takes ownership of the
* 'opage' buffer.
@@ -990,6 +1004,11 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
* Reset last_off to point to new page
*/
last_off = P_FIRSTKEY;
+
+ /* The new page starts with no prefix candidate and no savings yet. */
+ state->btps_prefix = NULL;
+ state->btps_prefixlen = 0;
+ state->btps_codedsaved = 0;
}
/*
@@ -1017,6 +1036,34 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_sortaddtup(npage, itupsz, itup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Leaf prefix compression accounting (Strategy 1): the page's eventual
+ * prefix is its first data item's first-column bytes; estimate how much
+ * room coding this page's items would free, so the fillfactor test above
+ * can pack more plain items onto a page that will shrink when it is
+ * compressed at finish. Items are still placed plain here; the estimate
+ * only relaxes the soft limit, never the hard fit check.
+ */
+ if (isleaf && _bt_prefix_enabled(wstate->index))
+ {
+ if (state->btps_prefix == NULL)
+ state->btps_prefix = _bt_prefix_candidate(itup,
+ &state->btps_prefixlen);
+ else
+ {
+ int shared = _bt_prefix_shared(state->btps_prefix,
+ state->btps_prefixlen, itup);
+
+ /*
+ * A coded item drops `shared' value bytes but keeps a 1-byte
+ * share count; count the net MAXALIGN-rounded saving.
+ */
+ if (shared > 1)
+ state->btps_codedsaved +=
+ MAXALIGN(itupsz) - MAXALIGN(itupsz - (shared - 1));
+ }
+ }
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1115,6 +1162,11 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
* back one slot. Then we can dump out the page.
*/
_bt_slideleft((Page) s->btps_buf);
+
+ /* Fault the finished rightmost leaf forward into BTP_PREFIX if it pays. */
+ if (s->btps_level == 0)
+ _bt_prefix_compress_page(wstate->index, (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/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8b2..14bee568e45 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -52,6 +52,9 @@ typedef struct
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
+ bool hasprefix; /* origpage is prefix-compressed (BTP_PREFIX) */
+ BTItemBuf lbuf; /* decode scratch for _bt_split_lastleft */
+ BTItemBuf rbuf; /* decode scratch for _bt_split_firstright */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
@@ -172,6 +175,23 @@ _bt_findsplitloc(Relation rel,
olddataitemstotal = rightspace - (int) PageGetExactFreeSpace(origpage);
leaffillfactor = BTGetFillFactor(rel);
+ /*
+ * On a prefix-compressed leaf the page carries an extra PREFIX item that
+ * is not a data item. The split is reasoned about in CODED (on-page)
+ * bytes -- the physical reality of the page and of each recompressed half
+ * -- so subtract only the PREFIX item's space from olddataitemstotal and
+ * use the on-page (coded) item sizes in the scan below. (Plain-size
+ * accounting is not usable here: a compressed leaf can hold far more
+ * logical data than two plain pages, so a plain split can be infeasible.)
+ */
+ if (P_HASPREFIX(opaque))
+ {
+ ItemId pii = PageGetItemId(origpage, BTPageGetPrefixOff(opaque));
+
+ olddataitemstotal -= (int) (MAXALIGN(ItemIdGetLength(pii)) +
+ sizeof(ItemIdData));
+ }
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
state.rel = rel;
@@ -185,6 +205,7 @@ _bt_findsplitloc(Relation rel,
state.olddataitemstotal = olddataitemstotal;
state.minfirstrightsz = SIZE_MAX;
state.newitemoff = newitemoff;
+ state.hasprefix = P_HASPREFIX(opaque);
/* newitem cannot be a posting list item */
Assert(!BTreeTupleIsPosting(newitem));
@@ -463,6 +484,19 @@ _bt_recsplitloc(FindSplitData *state,
newitemisfirstright = (firstrightoff == state->newitemoff &&
!newitemonleft);
+ /*
+ * Reject a split that would leave the left page with no data items at all
+ * (firstrightoff at the first data key, with newitem not on the left).
+ * Upstream's split-point heuristics never choose such a point for a plain
+ * page; on a prefix-compressed page the coded-size accounting can make it
+ * look attractive, and _bt_split cannot form a left high key from a
+ * nonexistent lastleft.
+ */
+ if (state->hasprefix &&
+ firstrightoff == P_FIRSTDATAKEY(BTPageGetOpaque(state->origpage)) &&
+ !newitemonleft)
+ return;
+
if (newitemisfirstright)
firstrightsz = state->newitemsz;
else
@@ -686,8 +720,12 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
if (state->newitemoff > maxoff)
{
+ BTItemBuf tbuf;
+
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ if (state->hasprefix)
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
@@ -714,12 +752,20 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
+ /* Do cheaper test first (t_tid and the posting bit survive coding) */
if (BTreeTupleIsPosting(tup) ||
!_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
return false;
/* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ if (state->hasprefix)
+ {
+ BTItemBuf tbuf;
+
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ }
+ else
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -1160,12 +1206,38 @@ static inline IndexTuple
_bt_split_lastleft(FindSplitData *state, SplitPoint *split)
{
ItemId itemid;
+ OffsetNumber off;
if (split->newitemonleft && split->firstrightoff == state->newitemoff)
return state->newitem;
- itemid = PageGetItemId(state->origpage,
- OffsetNumberPrev(split->firstrightoff));
+ off = OffsetNumberPrev(split->firstrightoff);
+
+ /*
+ * On a prefix-compressed leaf the PREFIX item sits just before the first
+ * data item. When the split boundary is at the first data key, the data
+ * predecessor is the high key on a plain page (a pivot, read plain); a
+ * rightmost page has none, so fall back to the first data item (this only
+ * feeds penalty estimation, never placement). Never read the PREFIX item
+ * as a data tuple.
+ */
+ if (state->hasprefix)
+ {
+ BTPageOpaque op = BTPageGetOpaque(state->origpage);
+
+ if (off == BTPageGetPrefixOff(op))
+ {
+ if (!P_RIGHTMOST(op))
+ return (IndexTuple) PageGetItem(state->origpage,
+ PageGetItemId(state->origpage, P_HIKEY));
+ off = P_FIRSTDATAKEY(op);
+ }
+ itemid = PageGetItemId(state->origpage, off);
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->lbuf);
+ }
+ itemid = PageGetItemId(state->origpage, off);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
@@ -1181,5 +1253,9 @@ _bt_split_firstright(FindSplitData *state, SplitPoint *split)
return state->newitem;
itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ if (state->hasprefix)
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->rbuf);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
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..41bda6aba06 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -243,6 +243,211 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting,
_bt_restore_meta(record, 2);
}
+/*
+ * Replay a prefix-compressed leaf page split.
+ *
+ * Rather than replay logged tuples (whose coded physical layout
+ * _bt_restore_page could not reproduce), rebuild both halves the way the
+ * primary did: collect the split's plain items from the pre-split original
+ * page (block 0) with _bt_split_leaf_collect, then lay each half out coded
+ * against its chosen prefix (carried in the record) with _bt_split_leaf_layout.
+ * The result is byte-identical to the primary, so no full-page image is
+ * needed. The record's block-0 data carries, in order: newitem (when it went
+ * on the left, or for a posting split), the left high key, and the left half's
+ * prefix bytes; block-1 data carries the right half's prefix bytes.
+ */
+static void
+btree_xlog_split_prefix(XLogReaderState *record, BlockNumber origpagenumber,
+ BlockNumber rightpagenumber, BlockNumber spagenumber,
+ bool newitemonleft)
+{
+ XLogRecPtr lsn = record->EndRecPtr;
+ xl_btree_split *xlrec = (xl_btree_split *) XLogRecGetData(record);
+ Buffer rbuf;
+ Buffer origbuf;
+ Page origpage;
+ BTPageOpaque oopaque;
+ char *datapos;
+ Size datalen;
+ IndexTuple newitem = NULL;
+ IndexTuple left_hikey;
+ IndexTuple right_hikey = NULL;
+ IndexTuple nposting = NULL;
+ Size newitemsz = 0;
+ const char *leftprefix = NULL;
+ const char *rightprefix = NULL;
+ IndexTuple rightitems[MaxIndexTuplesPerPage];
+ int nright = 0;
+ Page rightpage;
+ BTPageOpaque ropaque;
+ XLogRedoAction origaction;
+
+ /*
+ * The right (new) page is rebuilt entirely from data logged with the
+ * record, independent of block 0, because block 0 may be restored from an
+ * automatic full-page image (so its live contents after redo are the left
+ * half, not the pre-split page). Block-1 data is, in order: the right
+ * half's chosen prefix bytes (rightprefixlen), the right high key when the
+ * split is not rightmost (spagenumber != P_NONE), then the right half's
+ * plain data items in offset order (self-describing by IndexTupleSize).
+ */
+ datapos = XLogRecGetBlockData(record, 1, &datalen);
+ if (xlrec->flags & XLH_SPLIT_RFPI)
+ {
+ /*
+ * The right half's plain items would have overflowed the WAL record,
+ * so the primary carried the coded right page as a forced full-page
+ * image. Restore it from that image.
+ */
+ if (XLogReadBufferForRedo(record, 1, &rbuf) != BLK_RESTORED)
+ elog(ERROR, "prefix leaf split right page lacks its full-page image");
+ }
+ else
+ {
+ if (xlrec->rightprefixlen > 0)
+ {
+ rightprefix = datapos;
+ datapos += xlrec->rightprefixlen;
+ datalen -= xlrec->rightprefixlen;
+ }
+ if (spagenumber != P_NONE)
+ {
+ right_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(right_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(right_hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nright < MaxIndexTuplesPerPage);
+ rightitems[nright++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ rbuf = XLogInitBufferForRedo(record, 1);
+ rightpage = BufferGetPage(rbuf);
+ _bt_pageinit(rightpage, BufferGetPageSize(rbuf));
+ ropaque = BTPageGetOpaque(rightpage);
+ ropaque->btpo_prev = origpagenumber;
+ ropaque->btpo_next = spagenumber;
+ ropaque->btpo_level = xlrec->level;
+ ropaque->btpo_flags = BTP_LEAF;
+ ropaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(rightpage, right_hikey, rightitems, nright,
+ rightprefix, xlrec->rightprefixlen))
+ elog(ERROR, "failed to replay right half of a prefix leaf split");
+ PageSetLSN(rightpage, lsn);
+ }
+ MarkBufferDirty(rbuf);
+
+ /*
+ * Rebuild the left (original) page from the pre-split page, unless it was
+ * restored from a full-page image (then it is already the final left
+ * half). Block-0 data is: [newitem (left/posting)] [left high key]
+ * [left prefix]. The left plain items come from the pre-split page.
+ */
+ origaction = XLogReadBufferForRedo(record, 0, &origbuf);
+ origpage = BufferGetPage(origbuf);
+
+ if (origaction == BLK_NEEDS_REDO)
+ {
+ IndexTuple *leftitems;
+ int nleft;
+ OffsetNumber origpagepostingoff = InvalidOffsetNumber;
+ Page leftpage;
+ BTPageOpaque lopaque;
+
+ oopaque = BTPageGetOpaque(origpage);
+
+ datapos = XLogRecGetBlockData(record, 0, &datalen);
+ if (newitemonleft || xlrec->postingoff != 0)
+ {
+ newitem = (IndexTuple) datapos;
+ newitemsz = MAXALIGN(IndexTupleSize(newitem));
+ datapos += newitemsz;
+ datalen -= newitemsz;
+ }
+ left_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(left_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(left_hikey));
+ if (xlrec->leftprefixlen > 0)
+ {
+ leftprefix = datapos;
+ datapos += xlrec->leftprefixlen;
+ datalen -= xlrec->leftprefixlen;
+ }
+ Assert(datalen == 0);
+
+ if (xlrec->postingoff != 0)
+ {
+ ItemId itemid;
+ IndexTuple oposting;
+ BTItemBuf obuf;
+
+ origpagepostingoff = OffsetNumberPrev(xlrec->newitemoff);
+ newitem = CopyIndexTuple(newitem);
+ itemid = PageGetItemId(origpage, origpagepostingoff);
+ oposting = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ oposting = _bt_prefix_decode(origpage, oposting, &obuf);
+ nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff);
+ }
+
+ leftitems = palloc_array(IndexTuple, PageGetMaxOffsetNumber(origpage) + 2);
+ rightitems[0] = NULL; /* not reused below */
+ {
+ IndexTuple dummyright[MaxIndexTuplesPerPage];
+ int dummynr;
+
+ _bt_split_leaf_collect(origpage, xlrec->firstrightoff,
+ xlrec->newitemoff, newitem, newitemonleft,
+ origpagepostingoff, nposting,
+ leftitems, &nleft, dummyright, &dummynr);
+ }
+
+ leftpage = PageGetTempPageCopySpecial(origpage);
+ lopaque = BTPageGetOpaque(leftpage);
+ lopaque->btpo_flags = BTP_LEAF | BTP_INCOMPLETE_SPLIT;
+ lopaque->btpo_prev = oopaque->btpo_prev;
+ lopaque->btpo_next = rightpagenumber;
+ lopaque->btpo_level = xlrec->level;
+ lopaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(leftpage, left_hikey, leftitems, nleft,
+ leftprefix, xlrec->leftprefixlen))
+ elog(ERROR, "failed to replay left half of a prefix leaf split");
+ PageRestoreTempPage(leftpage, origpage);
+ PageSetLSN(origpage, lsn);
+ MarkBufferDirty(origbuf);
+ pfree(leftitems);
+ }
+
+ /* Fix left-link of the page to the right of the new right sibling. */
+ if (spagenumber != P_NONE)
+ {
+ Buffer sbuf;
+
+ if (XLogReadBufferForRedo(record, 2, &sbuf) == BLK_NEEDS_REDO)
+ {
+ Page spage = BufferGetPage(sbuf);
+ BTPageOpaque spageop = BTPageGetOpaque(spage);
+
+ spageop->btpo_prev = rightpagenumber;
+ PageSetLSN(spage, lsn);
+ MarkBufferDirty(sbuf);
+ }
+ if (BufferIsValid(sbuf))
+ UnlockReleaseBuffer(sbuf);
+ }
+
+ UnlockReleaseBuffer(rbuf);
+ if (BufferIsValid(origbuf))
+ UnlockReleaseBuffer(origbuf);
+}
+
static void
btree_xlog_split(bool newitemonleft, XLogReaderState *record)
{
@@ -264,6 +469,18 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
if (!XLogRecGetBlockTagExtended(record, 2, NULL, NULL, &spagenumber, NULL))
spagenumber = P_NONE;
+ /*
+ * A prefix-compressed leaf split is replayed by rebuilding both halves
+ * from the pre-split original page, exactly as the primary did, so the
+ * coded pages come out byte-identical without a full-page image.
+ */
+ if (xlrec->flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ btree_xlog_split_prefix(record, origpagenumber, rightpagenumber,
+ spagenumber, newitemonleft);
+ return;
+ }
+
/*
* Clear the incomplete split flag on the appropriate child page one level
* down when origpage/buf is an internal page (there must have been
@@ -280,7 +497,7 @@ 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 the logged tuples. */
rbuf = XLogInitBufferForRedo(record, 1);
datapos = XLogRecGetBlockData(record, 1, &datalen);
rpage = BufferGetPage(rbuf);
@@ -468,6 +685,56 @@ btree_xlog_dedup(XLogReaderState *record)
BTDedupState state;
BTDedupInterval *intervals;
Page newpage;
+ bool pageprefix; /* source page is BTP_PREFIX */
+ BTItemBuf curbuf; /* decode scratch for the current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
+
+ if (xlrec->prefixlen > 0)
+ {
+ /*
+ * Prefix-dedup: the deduped result was carried as the chosen prefix
+ * bytes, then (unless rightmost) the high key, then the deduped
+ * plain items. Rebuild the coded page byte-for-byte the way the
+ * primary did, using the same layout routine as a prefix leaf split.
+ */
+ Size datalen;
+ char *datapos = XLogRecGetBlockData(record, 0, &datalen);
+ const char *prefix = datapos;
+ IndexTuple hikey = NULL;
+ IndexTuple items[MaxIndexTuplesPerPage];
+ int nitems = 0;
+
+ datapos += xlrec->prefixlen;
+ datalen -= xlrec->prefixlen;
+ if (xlrec->flags & XLH_DEDUP_NOT_RIGHTMOST)
+ {
+ hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(hikey));
+ datalen -= MAXALIGN(IndexTupleSize(hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nitems < MaxIndexTuplesPerPage);
+ items[nitems++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ newpage = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(newpage)->btpo_flags &= ~(BTP_PREFIX | BTP_HAS_GARBAGE);
+ if (!_bt_split_leaf_layout(newpage, hikey, items, nitems,
+ prefix, xlrec->prefixlen))
+ elog(ERROR, "failed to replay prefix deduplication");
+ PageRestoreTempPage(newpage, page);
+ PageSetLSN(page, lsn);
+ MarkBufferDirty(buf);
+ UnlockReleaseBuffer(buf);
+ return;
+ }
state = palloc_object(BTDedupStateData);
state->deduplicate = true; /* unused */
@@ -487,6 +754,19 @@ btree_xlog_dedup(XLogReaderState *record)
maxoff = PageGetMaxOffsetNumber(page);
newpage = PageGetTempPageCopySpecial(page);
+ /*
+ * The source page may be prefix-compressed even on the ordinary
+ * (non-prefix) dedup path: that happens when the primary deduplicated
+ * a prefix page but re-compression of the result declined, leaving a
+ * plain page (prefixlen == 0, no full-page image). Reproduce the
+ * primary: lay the result out plain and decode each coded data item
+ * before feeding it to the dedup state machine. The high key is a
+ * pivot and is never coded.
+ */
+ pageprefix = P_HASPREFIX(opaque);
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
if (!P_RIGHTMOST(opaque))
{
ItemId itemid = PageGetItemId(page, P_HIKEY);
@@ -505,8 +785,18 @@ btree_xlog_dedup(XLogReaderState *record)
ItemId itemid = PageGetItemId(page, offnum);
IndexTuple itup = (IndexTuple) PageGetItem(page, itemid);
+ /* Decode on a prefix source so keys/posting lists are real. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
+ {
+ /* base must survive the whole run; keep it in its own buffer */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
+ }
else if (state->nintervals < xlrec->nintervals &&
state->baseoff == intervals[state->nintervals].baseoff &&
state->nitems < intervals[state->nintervals].nitems)
@@ -516,12 +806,15 @@ btree_xlog_dedup(XLogReaderState *record)
}
else
{
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
Assert(state->nintervals == xlrec->nintervals);
Assert(memcmp(state->intervals, intervals,
state->nintervals * sizeof(BTDedupInterval)) == 0);
@@ -621,11 +914,23 @@ btree_xlog_vacuum(XLogReaderState *record)
if (xlrec->ndeleted > 0)
PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * Mirror the primary: an emptied prefix-compressed leaf drops its
+ * PREFIX item and the BTP_PREFIX flag (see _bt_delitems_vacuum).
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* Clear the vacuum cycle ID, and mark the page as not containing any
* LP_DEAD items
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..ca7e6852542 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,61 @@ 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 format and code).
+ *
+ * A leaf of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page holds one extra item, the PREFIX item, at BTPageGetPrefixOff (where
+ * its first data item would otherwise be); its data items start one offset
+ * later, so P_FIRSTDATAKEY -- which every loop over a page's data items
+ * starts from -- accounts for the flag. The PREFIX item is an
+ * IndexTupleData header (t_tid zero, t_info its size and nothing else, so
+ * that code walking a page's items by IndexTupleSize, such as split redo,
+ * steps over it) followed by 1..BTREE_PREFIX_MAX prefix bytes. The prefix is
+ * chosen only when a page is written whole (CREATE INDEX, or a half of a
+ * split) and never changes after that; a page's high key, which fixes the
+ * prefix item's offset, also changes only in a split.
+ *
+ * On a BTP_PREFIX page, every non-pivot tuple (plain or posting) whose first
+ * key column is not NULL stores that column coded, as a varlena (1-byte
+ * header when it fits one, else 4-byte) whose payload starts with a uint8
+ * `shared':
+ *
+ * shared <= BTREE_PREFIX_MAX: the value's data bytes are the page prefix's
+ * first `shared' bytes followed by the rest of the payload.
+ * The value's own varlena header is recomputed on decode (the
+ * form index_form_tuple gives it: 1-byte when it fits, else
+ * 4-byte uncompressed).
+ * shared == BTREE_PREFIX_RAW: the rest of the payload is the value exactly
+ * as stored, header included. Used for a value compressed
+ * inline, or whose header is not the canonical form above.
+ *
+ * The bytes after the first column (later key columns, INCLUDE columns, the
+ * posting list, and alignment padding) are kept as they are, moved to keep
+ * their alignment, or left out when they are only the key's closing zero
+ * padding (decode puts them back). A posting list follows the key at the new
+ * key end, and the posting offset in t_tid is changed to match. Pivots, NULL
+ * first columns, and the high key are stored as on any other page.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo can repeat them, and decoding gives back the plain
+ * tuple byte for byte.
+ */
+#define BTREE_PREFIX_MAX 126
+#define BTREE_PREFIX_RAW 127 /* shared count of a raw value */
+#define BTREE_PREFIX_SHARED_MASK 0x7F /* shared count bits */
+#define BTREE_PREFIX_REST 0x80 /* bytes after the column follow */
+
+/* A leaf whose tuples share less of its prefix than this, on average, has none. */
+#define BTREE_PREFIX_MIN_SHARED 4
+
+#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 +732,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.
@@ -1122,6 +1208,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 +1224,33 @@ 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))
+
+/*
+ * Size of one half (currTuples or markTuples) of an index-only scan's tuple
+ * storage. A plain leaf's live non-pivot tuples fit in one page, so BLCKSZ
+ * would suffice for them. A prefix-compressed (BTP_PREFIX) leaf can hold more
+ * logical data than fits plain: when its tuples are decoded for the scan they
+ * can exceed BLCKSZ. Because _bt_getitem() decodes based on the page flag,
+ * not on the current reloption, and a BTP_PREFIX page can persist after
+ * prefix_compression is turned off (the flag is cleared only when a page is
+ * split, emptied or dedup-rewritten -- never by ALTER INDEX), ANY btree index
+ * may contain such a page. The buffer must therefore always reserve the slack
+ * for a decoded page, independent of the live reloption; sizing it on the
+ * option would overflow currTuples/markTuples on a surviving prefix leaf.
+ *
+ * The bound is the most distinct index tuples a leaf can hold (each at least a
+ * line pointer plus a minimally coded tuple) times the most a single first
+ * column can regain on decode (the page prefix, at most BTREE_PREFIX_MAX
+ * bytes, MAXALIGNed).
+ */
+#define BTTupleStorageSize(relation) \
+ (BLCKSZ + (BLCKSZ / (int) MAXALIGN(sizeof(IndexTupleData) + 2)) * \
+ (int) MAXALIGN(BTREE_PREFIX_MAX))
/*
* Constant definition for progress reporting. Phase numbers must match
@@ -1202,7 +1316,8 @@ extern bool _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
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 Size _bt_dedup_finish_pending(Page newpage, BTDedupState state,
+ bool overflowok);
extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids,
int nhtids);
extern void _bt_update_posting(BTVacuumPosting vacposting);
@@ -1315,6 +1430,34 @@ 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 void _bt_page_set_prefix(Page page, const char *prefix, Size len);
+extern IndexTuple _bt_prefix_encode(Page page, IndexTuple itup);
+extern Size _bt_prefix_coded_size(Page page, IndexTuple itup);
+extern bool _bt_prefix_enabled(Relation index);
+extern const char *_bt_prefix_candidate(IndexTuple itup, Size *len);
+extern int _bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup);
+extern Size _bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix);
+extern void _bt_prefix_compress_page(Relation index, Page page);
+extern void _bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen);
+extern bool _bt_split_leaf_layout(Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *prefix, Size prefixlen);
+extern void _bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft,
+ OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright);
+
/*
* prototypes for functions in nbtvalidate.c
*/
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..285eac6f1aa 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -156,25 +156,59 @@ 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 */
+ uint16 flags; /* XLH_SPLIT_* below */
+ uint8 leftprefixlen; /* leaf: left half's prefix length (0 none) */
+ uint8 rightprefixlen; /* leaf: right half's prefix length (0 none) */
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, rightprefixlen) + sizeof(uint8))
+
+/*
+ * xl_btree_split flag bits.
+ *
+ * A prefix-compressed leaf split logs no full-page image in the common case:
+ * redo rebuilds each half the way the primary did. The left half is
+ * reconstructed from the pre-split original page (block 0) together with the
+ * logged new item and high key; the right half is reconstructed from its plain
+ * data items, which are logged as block-1 data. Each half's chosen prefix
+ * bytes are logged too (block 0 for the left half, block 1 for the right), and
+ * applied with _bt_split_leaf_layout(); redo cannot run the prefix-choosing
+ * heuristic itself, so the primary logs the prefix it chose.
+ * leftprefixlen/rightprefixlen give the lengths (0 means the half is not
+ * prefix-compressed). XLH_SPLIT_LEAFPREFIX marks a leaf split that uses this
+ * path at all (so redo rebuilds coded rather than replaying items).
+ */
+#define XLH_SPLIT_LEAFPREFIX 0x01 /* leaf split, rebuild halves coded */
+#define XLH_SPLIT_RFPI 0x08 /* right half carried by a full-page image
+ * (its plain items would overflow the WAL
+ * record); redo restores it from the image */
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
* merged together into posting list tuples.
*
- * The WAL record represents a deduplication pass for a leaf page. An array
- * of BTDedupInterval structs follows.
+ * The WAL record represents a deduplication pass for a leaf page. Normally
+ * (prefixlen == 0) an array of BTDedupInterval structs follows and redo
+ * replays them against the old page. When the page is prefix-compressed
+ * (prefixlen > 0) the deduped result is instead carried as the chosen prefix
+ * bytes (prefixlen) followed by, when not rightmost, the high key, then the
+ * deduped plain data items -- each MAXALIGN'd -- and redo lays them out coded
+ * with _bt_split_leaf_layout(), avoiding a full-page image.
*/
typedef struct xl_btree_dedup
{
uint16 nintervals;
+ uint8 prefixlen; /* >0: prefix-dedup payload, see above */
+ uint8 flags; /* XLH_DEDUP_* */
- /* DEDUPLICATION INTERVALS FOLLOW */
+ /* DEDUPLICATION INTERVALS or PREFIX PAYLOAD FOLLOW */
} xl_btree_dedup;
-#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, flags) + sizeof(uint8))
+
+/* xl_btree_dedup flag bits */
+#define XLH_DEDUP_NOT_RIGHTMOST 0x01 /* prefix payload includes a high key */
+#define XLH_DEDUP_RFPI 0x02 /* prefix page carried as a forced image */
/*
* This is what we need to know about page reuse within btree. This record
diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile
index 20a1ca6a92b..ca0779e9b57 100644
--- a/src/test/modules/nbtree/Makefile
+++ b/src/test/modules/nbtree/Makefile
@@ -3,7 +3,8 @@
EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck
REGRESS = nbtree_half_dead_pages \
- nbtree_incomplete_splits
+ nbtree_incomplete_splits \
+ nbtree_prefix_rfpi
ISOLATION = backwards-scan-concurrent-splits \
predicate-empty-index
diff --git a/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
new file mode 100644
index 00000000000..1614ad0186b
--- /dev/null
+++ b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
@@ -0,0 +1,77 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+ bt_index_parent_check
+-----------------------
+
+(1 row)
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+ forced_rfpi_ok
+----------------
+ t
+(1 row)
+
+reset enable_seqscan;
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+ injection_points_detach
+-------------------------
+
+(1 row)
+
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build
index b5dc026392e..d6a34e265be 100644
--- a/src/test/modules/nbtree/meson.build
+++ b/src/test/modules/nbtree/meson.build
@@ -12,6 +12,7 @@ tests += {
'sql': [
'nbtree_half_dead_pages',
'nbtree_incomplete_splits',
+ 'nbtree_prefix_rfpi',
],
},
'isolation': {
diff --git a/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
new file mode 100644
index 00000000000..45899e5796f
--- /dev/null
+++ b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
@@ -0,0 +1,59 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+reset enable_seqscan;
+
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out
index 7c53d598649..c1dae4b55ad 100644
--- a/src/test/regress/expected/btree_index.out
+++ b/src/test/regress/expected/btree_index.out
@@ -891,3 +891,259 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql
index 6990d7818da..6cd053a1596 100644
--- a/src/test/regress/sql/btree_index.sql
+++ b/src/test/regress/sql/btree_index.sql
@@ -520,3 +520,170 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index cfef572df80..f314d8feb04 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,7 @@ BTPageStat
BTPageState
BTParallelScanDesc
BTPendingFSM
+BTPrefixPlan
BTReadPageState
BTScanInsert
BTScanInsertData
--
2.50.1
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-10-29 16:42 ` Greg Burd <greg@burd.me>
2026-10-30 11:52 ` Re: nbtree: opt-in leaf key prefix compression Yao Feng <fengyao0087@gmail.com>
0 siblings, 1 reply; 12+ messages in thread
From: Greg Burd @ 2026-10-29 16:42 UTC (permalink / raw)
To: pgsql-hackers@lists.postgresql.org; +Cc: pg@bowt.ie
Hello,
v4 attached, rebased on current master (cfbot had gone stale on a
not-applying from the v3 window; the patch applies clean on HEAD, so
this mostly just gives the bot a fresh version to chew on). No code
changes from v3.
I did say I'd run more benchmarks for fun, so here they are. Release
builds (cassert=off), master vs the patch, c7i.4xlarge, fsync and
synchronous_commit off so the WAL numbers aren't buried under flush
latency. "off" is the patch with prefix_compression left off, to show
the default path is free.
The headline is that the size win shows up where the keys are actually
distinct-but-prefixed, which is the case the feature is for. One row at
a time into an existing URL index (37-byte shared prefix, distinct
suffixes):
workload index pages (master -> on) WAL/txn (master -> on)
---------------- ------------------------- ----------------------
insert_random 22177 -> 7506 (-66%) 292 -> 226 bytes
insert_ascending 19036 -> 5429 (-71%) 277 -> 227 bytes
TPS is within noise either way (random 138k -> 136k, ascending 134k ->
131k). WAL per insert drops because a coded tuple is smaller than the
plain one it replaces. The split/dedup redo rework from the earlier
versions is what keeps that from turning into a full-page-image tax.
CREATE INDEX on 2M rows is a more mixed bag, because how much a prefix
buys depends entirely on the key shape:
shape index pages (master -> on) build WAL (master -> on)
------------------ ------------------------- ------------------------
url (g % 1e6) 10531 -> 9538 (-9%) 80 -> 48 MB
code (ACCT-...) 7140 -> 6495 (-9%) 48 -> 32 MB
md5 (random hex) 14420 -> 13026 (-10%) 99 -> 109 MB
jsonb (tenant+path) 11636 -> 10657 (-8%) 71 -> 28 MB
int (control) 5487 -> 5487 (0%) 32 -> 32 MB
Two things worth calling out there. The build-time size wins look
smaller than the insert ones only because these build shapes use lots of
near-duplicate keys (g % 1e6) with a short shared run; deduplication
already collapses most of that, so prefix coding is adding a few percent
on top rather than doing the heavy lifting.
The insert workload above, with genuinely distinct keys, is the fairer
picture of what the feature does. And the int control is exactly
unchanged. A page whose keys share nothing is left alone, so there's no
cost to carrying the option on a table where it doesn't pay.
md5 is the one that surprised me a little: even random hex strings share
enough of a leading nibble run across a page to shave ~10% off, though
the build WAL ticked up slightly there (109 vs 99 MB), which I want to
look at. Probably the recompression churn on a shape that barely
benefits. Not a blocker, but I'd rather understand it than wave it off.
Update churn (indexUnchanged UPDATEs, 8 clients) is within noise on TPS
and WAL, and the index stays flat at 186 pages before and after on all
three variants, which is bottom-up deletion doing its job on coded pages.
Everything else stands from v3.
best.
-greg
Attachments:
v4-0001-nbtree-opt-in-leaf-key-prefix-compression.patch (text/plain, 170.0K) download
From 3ea210650efe7c70a8c37e6f2145e4e9313f462e Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 15:25:58 -0400
Subject: [PATCH v4] nbtree: opt-in leaf key prefix compression
Add leaf key prefix compression to nbtree, controlled by a new
per-index storage parameter, prefix_compression, that is off by default.
When a leaf page is written as a whole -- during an index build, as a
split half, or as the result of a deduplication pass -- and its data
items share a leading byte-prefix of their first key column, the page is
rebuilt in the BTP_PREFIX format: a single PREFIX item after the high
key holds the shared bytes, and each data item is stored "coded" (its
copy of the shared prefix replaced by a one-byte length). Reads of a
leaf data item funnel through _bt_getitem(), which decodes on demand;
high keys and internal-page downlinks are never coded and are read with
the existing PageGetItem().
The feature is deliberately a per-page opt-in with no on-disk version
change: BTREE_VERSION and the catalog version are untouched. A page is
converted only when it is next written whole, never by an insert, VACUUM
or key-range change, so compression "faults forward" on an existing
index. Old-format and new-format pages coexist in the same index, which
keeps pg_upgrade, physical replication, and standbys working with no
REINDEX: a replica that has not yet seen a page's conversion still reads
it correctly, and turning the parameter off (ALTER INDEX ... SET) stops
new conversions while leaving existing coded pages readable.
Prefix compression is a separate mechanism from deduplication, not an
extension of it: deduplication collapses tuples whose whole key is
equal, while prefix compression factors out a leading run shared by
distinct keys (a URL or jsonb-path column is the motivating case, where
deduplication does not help). The two compose on one page -- a build
deduplicates first and then codes the survivors, so a duplicate-heavy
prefix index is no larger than a plain one. Bottom-up index deletion
runs on coded pages unchanged, since it only removes items. A runtime
deduplication pass on a coded page decodes first, and because a coded
page can hold more logical data than fits plain, the pass aborts cleanly
and the page splits instead when the decoded set would overflow.
WAL replay reproduces the coded bytes without resorting to full-page
images in the common paths. A Relation-free layout routine,
_bt_split_leaf_layout(), together with a single item-collection routine,
_bt_split_leaf_collect(), are shared by the primary (_bt_split) and redo
(btree_xlog_split) so both derive the same plain item sequence and code
it against the same logged prefix, emitting identical bytes. An
in-place insert logs the coded tuple; a split logs each half's chosen
prefix plus the right half's plain items; a prefix deduplication logs
the chosen prefix plus the deduped plain items. A bounded fallback
carries the page as a forced full-page image only when a pathologically
compressible page's plain payload would exceed one block; realistic data
never reaches that threshold, so the nbtree-prefix-force-rfpi injection
point and a dedicated test in src/test/modules/nbtree exercise the
fallback deterministically.
amcheck decodes every leaf data item, including under heapallindexed,
and checks the BTP_PREFIX structural invariants (flag only on leaf
pages, a well-formed PREFIX item at the expected offset); its test suite
covers a prefix-compressed index. The create_index documentation and
the nbtree README describe the parameter, the on-disk format, and the
relationship to deduplication. Tested with wal_consistency_checking=all
on a primary and a streaming standby across insert, split, deduplication
(including the forced-image fallback), delete and vacuum workloads,
through crash recovery, and across a pg_upgrade that carries both page
formats.
---
contrib/amcheck/expected/check_btree.out | 27 +
contrib/amcheck/sql/check_btree.sql | 18 +
contrib/amcheck/verify_nbtree.c | 120 ++-
doc/src/sgml/ref/create_index.sgml | 35 +
src/backend/access/common/reloptions.c | 10 +
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 97 +++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 266 +++++-
src/backend/access/nbtree/nbtinsert.c | 375 ++++++--
src/backend/access/nbtree/nbtpage.c | 31 +-
src/backend/access/nbtree/nbtprefix.c | 814 ++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 23 +-
src/backend/access/nbtree/nbtree.c | 6 +-
src/backend/access/nbtree/nbtsearch.c | 3 +-
src/backend/access/nbtree/nbtsort.c | 54 +-
src/backend/access/nbtree/nbtsplitloc.c | 84 +-
src/backend/access/nbtree/nbtutils.c | 4 +-
src/backend/access/nbtree/nbtxlog.c | 313 ++++++-
src/include/access/nbtree.h | 147 +++-
src/include/access/nbtxlog.h | 44 +-
src/test/modules/nbtree/Makefile | 3 +-
.../nbtree/expected/nbtree_prefix_rfpi.out | 77 ++
src/test/modules/nbtree/meson.build | 1 +
.../modules/nbtree/sql/nbtree_prefix_rfpi.sql | 59 ++
src/test/regress/expected/btree_index.out | 256 ++++++
src/test/regress/sql/btree_index.sql | 167 ++++
src/tools/pgindent/typedefs.list | 2 +
28 files changed, 2923 insertions(+), 115 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
create mode 100644 src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
create mode 100644 src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
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..18551483e88 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -158,7 +158,12 @@ typedef struct BtreeLastVisibleEntry
OffsetNumber offset; /* Offset on index block */
int postingIndex; /* Number in the posting list (-1 for
* non-deduplicated tuples) */
- ItemPointer tid; /* Heap tid */
+ ItemPointer tid; /* Heap tid, or NULL; points at tiddata */
+ ItemPointerData tiddata; /* Own storage for *tid: a prefix-coded leaf
+ * tuple is decoded into a scratch buffer that
+ * is reused on the next loop iteration, so we
+ * must keep the heap TID by value, not point
+ * into the (transient) decoded tuple */
} BtreeLastVisibleEntry;
/*
@@ -935,7 +940,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
lVis->postingIndex = i;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
}
}
}
@@ -961,7 +967,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
lVis->postingIndex = -1;
}
}
@@ -1229,6 +1236,16 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber max;
BTPageOpaque topaque;
+ /*
+ * Scratch space for decoding prefix-coded leaf tuples (_bt_getitem).
+ * itupbuf backs the current loop item; crossbuf backs the right sibling's
+ * first item in the cross-page unique check, which must coexist with the
+ * current item's state. A pivot, high key or non-prefix leaf item is
+ * returned by _bt_getitem unchanged (buffer unused).
+ */
+ BTItemBuf itupbuf;
+ BTItemBuf crossbuf;
+
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1266,6 +1283,54 @@ bt_target_page_check(BtreeCheckState *state)
}
}
+ /*
+ * Structural checks for a prefix-compressed (BTP_PREFIX) leaf. A reader
+ * trusts this format implicitly, so validate the parts that an out-of-
+ * place decode would otherwise read as garbage: the flag appears only on
+ * leaf pages, and the PREFIX item sits at the expected offset with a
+ * length in the legal range. The per-item key/content checks below run
+ * on the decoded tuple from _bt_getitem, which fails on a malformed coded
+ * item, so item-level coding is covered there.
+ */
+ if (P_HASPREFIX(topaque))
+ {
+ ItemId pitemid;
+ IndexTuple pitup;
+ Size prefixlen;
+ OffsetNumber prefixoff = BTPageGetPrefixOff(topaque);
+
+ if (!P_ISLEAF(topaque))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix compression flag set on non-leaf page in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u page lsn=%X/%08X.",
+ state->targetblock,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ if (prefixoff > max)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix-compressed page has no PREFIX item in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix offset=%u max offset=%u page lsn=%X/%08X.",
+ state->targetblock, prefixoff, max,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ pitemid = PageGetItemIdCareful(state, state->targetblock,
+ state->target, prefixoff);
+ pitup = (IndexTuple) PageGetItem(state->target, pitemid);
+ prefixlen = IndexTupleSize(pitup) - sizeof(IndexTupleData);
+ if (prefixlen < 1 || prefixlen > BTREE_PREFIX_MAX)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("PREFIX item has invalid length in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix length=%zu page lsn=%X/%08X.",
+ state->targetblock, prefixlen,
+ 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"
@@ -1293,8 +1358,15 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, state->targetblock,
state->target, offset);
- itup = (IndexTuple) PageGetItem(state->target, itemid);
- tupsize = IndexTupleSize(itup);
+
+ /*
+ * lp_len and the tuple size limit are physical properties of the item
+ * as stored, so they are checked against the raw on-page tuple (coded,
+ * on a BTP_PREFIX leaf). Every key/content check below instead uses
+ * the decoded tuple returned by _bt_getitem.
+ */
+ tupsize = IndexTupleSize((IndexTuple) PageGetItem(state->target, itemid));
+ itup = _bt_getitem(state->target, offset, &itupbuf);
/*
* lp_len should match the IndexTuple reported length exactly, since
@@ -1606,7 +1678,7 @@ 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), &itupbuf);
tid = BTreeTupleGetPointsToTID(itup);
nhtid = psprintf("(%u,%u)",
ItemPointerGetBlockNumberNoCheck(tid),
@@ -1793,7 +1865,7 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, rightblock_number,
rightpage,
rightfirstoffset);
- itup = (IndexTuple) PageGetItem(rightpage, itemid);
+ itup = _bt_getitem(rightpage, rightfirstoffset, &crossbuf);
bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis);
@@ -1857,6 +1929,8 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
BlockNumber targetnext;
Page rightpage;
OffsetNumber nline;
+ OffsetNumber rightoffset;
+ BTItemBuf firstbuf;
/* Determine target's next block number */
opaque = BTPageGetOpaque(state->target);
@@ -2016,6 +2090,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/* Return first data item (if any) */
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
P_FIRSTDATAKEY(opaque));
+ rightoffset = P_FIRSTDATAKEY(opaque);
*rightfirstoffset = P_FIRSTDATAKEY(opaque);
}
else if (!P_ISLEAF(opaque) &&
@@ -2027,6 +2102,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
*/
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
OffsetNumberNext(P_FIRSTDATAKEY(opaque)));
+ rightoffset = OffsetNumberNext(P_FIRSTDATAKEY(opaque));
}
else
{
@@ -2046,9 +2122,19 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/*
* Return first real item scankey. Note that this relies on right page
* memory remaining allocated.
+ *
+ * On a BTP_PREFIX leaf the first data item is prefix-coded, so decode it;
+ * because the returned scankey (built by _bt_mkscankey) references the
+ * tuple's own storage, and _bt_getitem decodes into a buffer local to
+ * this function, copy the decoded tuple into the caller-reset memory
+ * context so it outlives the call. A pivot or non-prefix item is
+ * returned by _bt_getitem unchanged (it lives in the right page, which
+ * remains allocated), so no copy is needed then.
*/
- firstitup = (IndexTuple) PageGetItem(rightpage, rightitem);
- return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ firstitup = _bt_getitem(rightpage, rightoffset, &firstbuf);
+ if (firstitup == (IndexTuple) PageGetItem(rightpage, rightitem))
+ return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ return bt_mkscankey_pivotsearch(state->rel, CopyIndexTuple(firstitup));
}
/*
@@ -3095,14 +3181,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);
@@ -3122,11 +3207,12 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key,
{
BTPageOpaque topaque;
IndexTuple ritup;
+ BTItemBuf ritupbuf;
int uppnkeyatts;
ItemPointer rheaptid;
bool nonpivot;
- ritup = (IndexTuple) PageGetItem(state->target, itemid);
+ ritup = _bt_getitem(state->target, upperbound, &ritupbuf);
topaque = BTPageGetOpaque(state->target);
nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque);
@@ -3218,14 +3304,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 */
@@ -3236,12 +3321,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key,
if (cmp == 0)
{
IndexTuple child;
+ BTItemBuf childbuf;
int uppnkeyatts;
ItemPointer childheaptid;
BTPageOpaque copaque;
bool nonpivot;
- child = (IndexTuple) PageGetItem(nontarget, itemid);
+ child = _bt_getitem(nontarget, upperbound, &childbuf);
copaque = BTPageGetOpaque(nontarget);
nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque);
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..7e66765c3d3 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -477,6 +477,41 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
</note>
</listitem>
</varlistentry>
+
+ <varlistentry id="index-reloption-prefix-compression" xreflabel="prefix_compression">
+ <term><literal>prefix_compression</literal> (<type>boolean</type>)
+ <indexterm>
+ <primary><varname>prefix_compression</varname> storage parameter</primary>
+ </indexterm>
+ </term>
+ <listitem>
+ <para>
+ Controls leaf key prefix compression. When set to
+ <literal>ON</literal>, and the index's first key column is of a
+ variable-length type, a leaf page may store a shared prefix of that
+ column once and record only each tuple's unshared remainder, which can
+ substantially shrink indexes on keys that share long leading bytes
+ (for example URLs or <type>jsonb</type> keys with common paths). Set
+ to <literal>ON</literal> or <literal>OFF</literal> to enable or disable
+ the optimization. The default is <literal>OFF</literal>.
+ </para>
+
+ <note>
+ <para>
+ 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 adopts a prefix only when
+ it is written as a whole (during an index build, or either half of a
+ page split), so turning <literal>prefix_compression</literal> on or off
+ via <command>ALTER INDEX</command> does not rewrite the index: existing
+ pages are left as they are and are converted the next time they are
+ written whole. No <command>REINDEX</command> is required, and indexes
+ upgraded with <application>pg_upgrade</application> keep working
+ unchanged.
+ </para>
+ </note>
+ </listitem>
+ </varlistentry>
</variablelist>
<para>
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..596e595f20b 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1081,3 +1081,100 @@ 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
+front-code the first key column of each of its data tuples against a per-page
+prefix, which shrinks indexes on keys that share long leading bytes (URLs,
+jsonb keys with common paths, zero-padded codes). Like deduplication, this
+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.
+
+A leaf that uses the technique carries the BTP_PREFIX page flag and one extra
+item, the PREFIX item, 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. The PREFIX item is an
+IndexTupleData header followed by 1..BTREE_PREFIX_MAX prefix bytes, shaped so
+that code which walks a page's items by IndexTupleSize (such as
+_bt_restore_page) steps over it. On such a page every non-pivot tuple (plain
+or posting) whose first key column is not NULL stores that column as a varlena
+whose payload begins with a one-byte count of how many leading bytes it shares
+with the page prefix, followed by the unshared remainder (or, for a value whose
+header is not the form index_form_tuple would reproduce, the whole value after
+a sentinel count). Trailing key columns, INCLUDE columns, a posting list and
+alignment padding follow, moved to preserve alignment; padding that is only the
+key's closing zeros is dropped and restored on decode. Pivots, NULL first
+columns and the high key are always stored uncompressed. Coding and decoding
+depend only on the page prefix and the tuple, never the catalog, so WAL redo
+reproduces them and decoding yields the original tuple byte for byte. All of
+nbtprefix.c implements this; _bt_getitem() is the one accessor through which
+readers obtain a decoded leaf data tuple.
+
+A page adopts a prefix only when it is written as a whole: a finished leaf
+during an index build, or either half of a leaf page split (built coded from
+the start by _bt_split_leaf_half, since a compressed leaf can hold more logical
+data than fits on a plain page, choosing each half's own prefix and falling
+back to a plain layout or the original page's prefix if its own does not fit).
+Crucially, no insert, VACUUM, deduplication or change of a page's key range
+ever rewrites a page to keep its prefix valid: a tuple that happens to share
+fewer bytes simply records a smaller shared count. 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 physical/logical replication keep
+working and existing indexes continue to work with no REINDEX. Turning the
+parameter on with ALTER INDEX does not rewrite anything; 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 (the flag is cleared
+only when a page is split, emptied or dedup-rewritten) and are still read back
+correctly with the parameter off, since every reader decodes based on the page
+flag, not on the current reloption. Scan-time workspace that must hold decoded
+tuples (the index-only-scan currTuples/markTuples buffers, see
+BTTupleStorageSize) is therefore sized for a decoded page on every btree index,
+not just those whose parameter is currently on.
+
+Leaf changes to a prefix page are replayed without a full-page image in the
+common case. Redo cannot run the prefix-choosing heuristic (it has no
+relcache), so the primary logs the prefix it chose and both sides lay the
+page out through one Relation-free routine, _bt_split_leaf_layout(), fed by
+one item-collection routine, _bt_split_leaf_collect(), that the primary
+(_bt_split) and redo (btree_xlog_split) both call. Because the two derive
+the plain item sequence the same way and code it against the same logged
+prefix, they produce byte-identical pages. An in-place insert logs the coded
+tuple (standard insert redo adds it verbatim); a split logs each half's
+chosen prefix plus the right half's plain items; a deduplication logs the
+chosen prefix plus the deduped plain items. A bounded fallback forces a
+full-page image only when a pathologically compressible page's plain payload
+would exceed BLCKSZ (so the logged payload can never exceed one block); redo
+then restores the image via BLK_RESTORED. When VACUUM or page deletion
+empties a prefix leaf, the PREFIX item is removed and the flag cleared so that
+the invariant that an emptied leaf holds only its high key (relied on by page
+deletion) still holds.
+
+Deduplication and bottom-up index deletion both run on BTP_PREFIX pages.
+Bottom-up deletion only removes items and never rewrites survivors, so it is
+safe on coded pages unchanged. A deduplication pass decodes the page's items,
+merges duplicates into posting lists and re-compresses the result as a whole;
+because a coded page can hold more logical data than fits plain, if the
+decoded set would overflow a plain page the pass aborts cleanly and the page
+splits instead, so no in-place rewrite is ever forced to fail. An index build
+deduplicates before coding the finished page, so a duplicate-heavy prefix
+index is no larger than a plain one.
+
+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 byte 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 (deduplicate first, then code the
+survivors), which is why a build applies both. Reusing the posting-list
+format to carry a shared prefix was considered and rejected: a posting list
+stores one key plus many TIDs, whereas prefix coding stores many keys that
+share a prefix, so the two would need different per-item layouts anyway, and
+overloading posting lists would entangle the deduplication and compression
+paths that are kept separate here on purpose.
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..12aa252aa05 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -19,6 +19,7 @@
#include "access/tableam.h"
#include "access/xloginsert.h"
#include "miscadmin.h"
+#include "utils/injection_point.h"
#include "utils/rel.h"
static void _bt_bottomupdel_finish_pending(Page page, BTDedupState state,
@@ -69,7 +70,19 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Size pagesaving PG_USED_FOR_ASSERTS_ONLY = 0;
bool singlevalstrat = false;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
XLogRecPtr recptr;
+ IndexTuple *dedup_plain = NULL; /* deduped plain items, for prefix WAL */
+ int dedup_nplain = 0;
+ IndexTuple dedup_hikey = NULL;
+ char dedup_prefix[BTREE_PREFIX_MAX];
+ Size dedup_prefixlen = 0;
+ bool dedup_islogprefix = false; /* page ended up prefix-coded */
+ char *dedup_walbuf = NULL; /* packed prefix-dedup WAL payload */
+ Size dedup_walbuflen = 0;
+ bool dedup_rfpi = false; /* fall back to a forced full-page image */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -120,7 +133,16 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
newpage = PageGetTempPageCopySpecial(page);
PageSetLSN(newpage, PageGetLSN(page));
- /* Copy high key, if any */
+ /*
+ * The rebuilt page is laid out plain here (clear any inherited BTP_PREFIX;
+ * PageGetTempPageCopySpecial copies the special area but not the PREFIX
+ * item). If the page was prefix-compressed it is re-compressed as a whole
+ * below, after deduplication, honouring Strategy 1.
+ */
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
+ /* Copy high key, if any (a pivot, stored plain on any page) */
if (!P_RIGHTMOST(opaque))
{
ItemId hitemid = PageGetItemId(page, P_HIKEY);
@@ -140,12 +162,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Assert(!ItemIdIsDead(itemid));
+ /* Decode on a prefix page so keys/posting lists are the real ones. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/*
* No previous/base tuple for the data item -- use the data item
- * as base tuple of pending posting list
+ * as base tuple of pending posting list. On a prefix page the
+ * base must survive the whole merge run (it is kept as a pointer
+ * in state and read by _bt_dedup_finish_pending), so keep it in a
+ * dedicated buffer rather than the per-iteration curbuf.
*/
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (state->deduplicate &&
@@ -170,7 +202,20 @@ _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);
+ /*
+ * On a prefix page the deduped items are laid out plain here; the
+ * decoded set can exceed one page even when the coded form fit, so
+ * tolerate an overflow and abort the whole pass (the caller then
+ * splits the page instead of deduplicating it).
+ */
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state,
+ pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
if (singlevalstrat)
{
@@ -198,12 +243,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
}
/* itup starts new pending posting list */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page,
+ (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
/* Handle the last item */
- pagesaving += _bt_dedup_finish_pending(newpage, state);
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state, pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
/*
* If no items suitable for deduplication were found, newpage must be
@@ -238,6 +293,110 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
nopaque->btpo_flags &= ~BTP_HAS_GARBAGE;
}
+ /*
+ * Re-apply prefix compression to the rebuilt (plain) page as a whole
+ * (Strategy 1), before the critical section since it allocates a scratch
+ * page. First capture the deduped plain items and high key: if the page
+ * does become prefix-compressed, redo rebuilds its coded bytes from these
+ * plus the chosen prefix (via _bt_split_leaf_layout), the same way a prefix
+ * leaf split is replayed -- no full-page image needed.
+ */
+ if (pageprefix)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(newpage);
+ OffsetNumber nmin = P_FIRSTDATAKEY(nopq);
+ OffsetNumber nmax = PageGetMaxOffsetNumber(newpage);
+
+ dedup_nplain = (nmax >= nmin) ? (int) nmax - (int) nmin + 1 : 0;
+ if (dedup_nplain > 0)
+ {
+ dedup_plain = palloc(dedup_nplain * sizeof(IndexTuple));
+ for (int i = 0; i < dedup_nplain; i++)
+ dedup_plain[i] = CopyIndexTuple((IndexTuple)
+ PageGetItem(newpage,
+ PageGetItemId(newpage, nmin + i)));
+ }
+ if (!P_RIGHTMOST(nopq))
+ {
+ ItemId hii = PageGetItemId(newpage, P_HIKEY);
+
+ dedup_hikey = CopyIndexTuple((IndexTuple) PageGetItem(newpage, hii));
+ }
+
+ _bt_prefix_compress_page(rel, newpage);
+
+ /* Did it actually compress? (It gives up if a coded item won't fit.) */
+ if (P_HASPREFIX(BTPageGetOpaque(newpage)))
+ {
+ const char *pfx = _bt_page_get_prefix(newpage, &dedup_prefixlen);
+
+ if (dedup_prefixlen > 0)
+ memcpy(dedup_prefix, pfx, dedup_prefixlen);
+ dedup_islogprefix = true;
+ }
+ }
+
+ /*
+ * Pack the prefix-dedup WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the chosen prefix bytes, then the high
+ * key (unless rightmost), then the deduped plain items, each MAXALIGN'd,
+ * in one contiguous buffer logged with a single rdata slot.
+ */
+ if (dedup_islogprefix)
+ {
+ Size off = dedup_prefixlen;
+
+ if (dedup_hikey != NULL)
+ off += MAXALIGN(IndexTupleSize(dedup_hikey));
+ for (int i = 0; i < dedup_nplain; i++)
+ off += MAXALIGN(IndexTupleSize(dedup_plain[i]));
+
+ /*
+ * A pathologically compressible page can hold more plain data than
+ * fits in one page; if the payload would be that large, fall back to
+ * a forced full-page image of the coded page (bounded at BLCKSZ).
+ */
+ if (off > BLCKSZ)
+ dedup_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ /* Let a test force the otherwise-rare full-page-image fallback. */
+ if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ dedup_rfpi = true;
+#endif
+ if (dedup_rfpi)
+ ; /* image forced; no payload buffer built */
+ else
+ {
+ dedup_walbuflen = off;
+ dedup_walbuf = palloc(Max(off, 1));
+
+ off = 0;
+ if (dedup_prefixlen > 0)
+ {
+ memcpy(dedup_walbuf, dedup_prefix, dedup_prefixlen);
+ off += dedup_prefixlen;
+ }
+ if (dedup_hikey != NULL)
+ {
+ Size hsz = IndexTupleSize(dedup_hikey);
+
+ memcpy(dedup_walbuf + off, dedup_hikey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(dedup_walbuf + off + hsz, 0, MAXALIGN(hsz) - hsz);
+ off += MAXALIGN(hsz);
+ }
+ for (int i = 0; i < dedup_nplain; i++)
+ {
+ Size sz = IndexTupleSize(dedup_plain[i]);
+
+ memcpy(dedup_walbuf + off, dedup_plain[i], sz);
+ if (MAXALIGN(sz) != sz)
+ memset(dedup_walbuf + off + sz, 0, MAXALIGN(sz) - sz);
+ off += MAXALIGN(sz);
+ }
+ }
+ }
+
START_CRIT_SECTION();
PageRestoreTempPage(newpage, page);
@@ -248,19 +407,38 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
{
xl_btree_dedup xlrec_dedup;
- xlrec_dedup.nintervals = state->nintervals;
+ xlrec_dedup.nintervals = dedup_islogprefix ? 0 : state->nintervals;
+ xlrec_dedup.prefixlen = (uint8) dedup_prefixlen;
+ xlrec_dedup.flags = (dedup_hikey != NULL) ? XLH_DEDUP_NOT_RIGHTMOST : 0;
+ if (dedup_rfpi)
+ xlrec_dedup.flags |= XLH_DEDUP_RFPI;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ (dedup_rfpi ? REGBUF_FORCE_IMAGE : 0));
XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
- /*
- * The intervals array is not in the buffer, but pretend that it is.
- * When XLogInsert stores the whole buffer, the array need not be
- * stored too.
- */
- XLogRegisterBufData(0, state->intervals,
- state->nintervals * sizeof(BTDedupInterval));
+ if (dedup_islogprefix)
+ {
+ /*
+ * Prefix-dedup: carry the chosen prefix and the deduped plain
+ * items so redo can lay out the coded page; no full-page image
+ * (unless the payload was too large, handled by the forced image).
+ */
+ if (!dedup_rfpi && dedup_walbuflen > 0)
+ XLogRegisterBufData(0, dedup_walbuf, dedup_walbuflen);
+ }
+ else
+ {
+ /*
+ * The intervals array is not in the buffer, but pretend that it
+ * is. When XLogInsert stores the whole buffer, the array need not
+ * be stored too.
+ */
+ XLogRegisterBufData(0, state->intervals,
+ state->nintervals * sizeof(BTDedupInterval));
+ }
recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DEDUP);
}
@@ -277,6 +455,20 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
/* cannot leak memory here */
pfree(state->htids);
pfree(state);
+ return;
+
+dedup_overflow:
+
+ /*
+ * A prefix page's deduped items did not fit when laid out plain. Leave
+ * the original page untouched and return without writing anything; the
+ * caller falls through to split the page. No critical section was
+ * entered and the buffer was not marked dirty, so the page is unchanged.
+ */
+ Assert(pageprefix);
+ pfree(newpage);
+ pfree(state->htids);
+ pfree(state);
}
/*
@@ -318,6 +510,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
TM_IndexDeleteOp delstate;
bool neverdedup;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -371,9 +566,24 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
Assert(!ItemIdIsDead(itemid));
+ /*
+ * On a prefix-compressed page decode the item so _bt_keep_natts_fast
+ * sees the real key. The base of a pending interval must survive the
+ * whole run (it is compared against each following item), so keep it
+ * in its own buffer; the current item uses a reused buffer. Heap TIDs
+ * and posting lists are preserved by coding, so _bt_dedup_start_pending
+ * and _bt_bottomupdel_finish_pending (which read only TIDs/sizes, by
+ * page offset) need no decode.
+ */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/* itup starts first pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
@@ -387,6 +597,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
_bt_bottomupdel_finish_pending(page, state, &delstate);
/* itup starts new pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
@@ -554,7 +767,7 @@ _bt_dedup_save_htid(BTDedupState state, IndexTuple itup)
* where no deduplication was possible.
*/
Size
-_bt_dedup_finish_pending(Page newpage, BTDedupState state)
+_bt_dedup_finish_pending(Page newpage, BTDedupState state, bool overflowok)
{
OffsetNumber tupoff;
Size tuplesz;
@@ -572,7 +785,11 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
spacesaving = 0;
}
@@ -591,13 +808,26 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(final)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ pfree(final);
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
pfree(final);
spacesaving = state->phystupsize - (tuplesz + sizeof(ItemIdData));
/* Increment nintervals, since we wrote a new posting list tuple */
state->nintervals++;
- Assert(spacesaving > 0 && spacesaving < BLCKSZ);
+
+ /*
+ * On an ordinary page the merged interval's tuples were all physically
+ * present on one page, so the saving is bounded by BLCKSZ. On a prefix
+ * page (overflowok) phystupsize is the DECODED size of those tuples,
+ * which can exceed a page (a coded page holds more logical data than
+ * fits plain), so only require the saving to be positive there.
+ */
+ Assert(spacesaving > 0 && (overflowok || spacesaving < BLCKSZ));
}
/* Reset state for next pending posting list */
@@ -783,16 +1013,22 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state,
OffsetNumber minoff, IndexTuple newitem)
{
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(BTPageGetOpaque(page));
ItemId itemid;
IndexTuple itup;
+ BTItemBuf buf;
itemid = PageGetItemId(page, minoff);
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &buf);
if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts)
{
itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page));
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &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..dfd980a6a2a 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -348,8 +348,8 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate)
P_ISLEAF(opaque) &&
!P_IGNORE(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
@@ -1135,6 +1135,11 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple codeditup; /* itup coded for the page, or itup */
+ Size codeditemsz; /* its MAXALIGNed size */
+ IndexTuple codednposting = NULL; /* nposting coded for the page */
+ BTItemBuf opostbuf; /* decode workspace for an existing posting */
+ bool pageprefix; /* target page is BTP_PREFIX */
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1143,6 +1148,7 @@ _bt_insertonpg(Relation rel,
isroot = P_ISROOT(opaque);
isrightmost = P_RIGHTMOST(opaque);
isonly = P_LEFTMOST(opaque) && P_RIGHTMOST(opaque);
+ pageprefix = P_HASPREFIX(opaque);
/* child buffer must be given iff inserting on an internal page */
Assert(isleaf == !BufferIsValid(cbuf));
@@ -1182,6 +1188,9 @@ _bt_insertonpg(Relation rel,
*/
Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage);
oposting = (IndexTuple) PageGetItem(page, itemid);
+ /* On a prefix page the existing posting is coded; decode for the swap. */
+ if (pageprefix)
+ oposting = _bt_prefix_decode(page, oposting, &opostbuf);
/*
* postingoff value comes from earlier call to _bt_binsrch_posting().
@@ -1213,19 +1222,50 @@ _bt_insertonpg(Relation rel,
newitemoff = OffsetNumberNext(newitemoff);
}
+ /*
+ * On a prefix-compressed page the new item is stored coded, which is at
+ * most MAXALIGN larger than plain and usually much smaller. Size the fit
+ * test and the placement by the coded size. (Split is handled on plain
+ * tuples; _bt_split re-compresses each half itself.)
+ */
+ if (pageprefix)
+ {
+ codeditup = _bt_prefix_encode(page, itup);
+ codeditemsz = MAXALIGN(IndexTupleSize(codeditup));
+ /*
+ * A posting-list split replaces the existing coded posting with a
+ * re-coded one; encode it here, before the critical section, since
+ * _bt_prefix_encode() allocates.
+ */
+ if (nposting != NULL)
+ codednposting = _bt_prefix_encode(page, nposting);
+ }
+ else
+ {
+ codeditup = itup;
+ codeditemsz = itemsz;
+ }
+
/*
* Do we need to split the page to fit the item on it?
*
* 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.
+ * only for the item and not for its line pointer. The coded item size is
+ * used; a posting-list split additionally changes the posting tuple's
+ * size, but that can only shrink or keep free space (the new posting has
+ * one fewer TID's worth of room reused by newitem), so the item fit test
+ * remains a safe upper bound.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < codeditemsz)
{
Buffer rbuf;
Assert(!split_only_page);
+ if (codeditup != itup)
+ pfree(codeditup);
+
/* split the buffer into left and right halves */
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
@@ -1295,9 +1335,25 @@ _bt_insertonpg(Relation rel,
START_CRIT_SECTION();
if (postingoff != 0)
- memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ {
+ if (pageprefix)
+ {
+ /*
+ * The existing posting is stored coded; replace its slot (at
+ * the offset just before newitemoff, since newitemoff was
+ * advanced past it above) with the coded new posting (encoded
+ * before the critical section), whose size may differ.
+ */
+ if (!PageIndexTupleOverwrite(page, OffsetNumberPrev(newitemoff),
+ codednposting, IndexTupleSize(codednposting)))
+ elog(PANIC, "failed to overwrite posting list in block %u in index \"%s\"",
+ BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+ }
+ else
+ memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ }
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, codeditup, codeditemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1381,8 +1437,30 @@ _bt_insertonpg(Relation rel,
}
}
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- if (postingoff == 0)
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ ((pageprefix && postingoff != 0) ? REGBUF_FORCE_IMAGE : 0));
+ if (pageprefix && postingoff != 0)
+ {
+ /*
+ * A posting-list split on a prefix page rewrites an existing
+ * coded posting and adds a coded item; rather than teach redo to
+ * re-code both, force a full-page image (this is a rare case).
+ * postingoff is still recorded for the record format.
+ */
+ upostingoff = postingoff;
+ XLogRegisterBufData(0, &upostingoff, sizeof(uint16));
+ }
+ else if (pageprefix)
+ {
+ /*
+ * Simple insert on a prefix page: log the CODED item bytes as
+ * they were placed on the page, so redo adds them verbatim at
+ * newitemoff -- no full-page image needed, and byte-identical.
+ */
+ XLogRegisterBufData(0, (char *) codeditup, IndexTupleSize(codeditup));
+ }
+ else if (postingoff == 0)
{
/* Just log itup from caller */
XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -1418,6 +1496,11 @@ _bt_insertonpg(Relation rel,
END_CRIT_SECTION();
+ if (codeditup != itup)
+ pfree(codeditup);
+ if (codednposting != NULL && codednposting != nposting)
+ pfree(codednposting);
+
/* Release subsidiary buffers */
if (BufferIsValid(metabuf))
_bt_relbuf(rel, metabuf);
@@ -1506,6 +1589,22 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
ItemId itemid;
IndexTuple firstright,
lefthighkey;
+ BTItemBuf frbuf; /* decode scratch for firstright */
+ BTItemBuf llbuf; /* decode scratch for lastleft */
+ IndexTuple *leftitems = NULL; /* leaf: plain items for the left half */
+ IndexTuple *rightitems = NULL; /* leaf: plain items for the right half */
+ int nleft = 0;
+ int nright = 0;
+ IndexTuple righthighkey = NULL; /* leaf right half's high key (NULL=rightmost) */
+ const char *origprefix = NULL;
+ Size origprefixlen = 0;
+ char leftprefix[BTREE_PREFIX_MAX]; /* chosen prefix of each leaf half */
+ char rightprefix[BTREE_PREFIX_MAX];
+ Size leftprefixlen = 0;
+ Size rightprefixlen = 0;
+ char *rightwalbuf = NULL; /* packed right-page WAL payload */
+ Size rightwalbuflen = 0;
+ bool force_rfpi = false; /* carry the right page as a forced image */
OffsetNumber firstrightoff;
OffsetNumber afterleftoff,
afterrightoff,
@@ -1575,7 +1674,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
lopaque->btpo_flags = oopaque->btpo_flags;
- lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
/* set flag in leftpage indicating that rightpage has no downlink yet */
lopaque->btpo_flags |= BTP_INCOMPLETE_SPLIT;
lopaque->btpo_prev = oopaque->btpo_prev;
@@ -1650,6 +1749,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
itemid = PageGetItemId(origpage, firstrightoff);
itemsz = ItemIdGetLength(itemid);
firstright = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ firstright = _bt_prefix_decode(origpage, firstright, &frbuf);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
}
@@ -1673,6 +1774,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque));
itemid = PageGetItemId(origpage, lastleftoff);
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ lastleft = _bt_prefix_decode(origpage, lastleft, &llbuf);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
}
@@ -1712,7 +1815,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
/*
- * Add new high key to leftpage
+ * Add new high key to leftpage. For a leaf split the left half is built
+ * as a whole below (so it can be prefix-compressed from the start, which
+ * a plain incremental build cannot represent when the page holds more
+ * logical data than fits plain); the high key is passed to the builder
+ * then. Internal halves are built incrementally here as before.
*/
afterleftoff = P_HIKEY;
@@ -1720,11 +1827,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(BTreeTupleGetNAtts(lefthighkey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(itemsz == MAXALIGN(IndexTupleSize(lefthighkey)));
- if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
- elog(ERROR, "failed to add high key to the left sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
- afterleftoff = OffsetNumberNext(afterleftoff);
+ if (isleaf)
+ {
+ /* The builder needs a stable copy; lefthighkey points into workspace. */
+ lefthighkey = CopyIndexTuple(lefthighkey);
+ leftitems = palloc_array(IndexTuple, maxoff + 2);
+ rightitems = palloc_array(IndexTuple, maxoff + 2);
+ if (P_HASPREFIX(oopaque))
+ origprefix = _bt_page_get_prefix(origpage, &origprefixlen);
+ }
+ else
+ {
+ if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the left sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterleftoff = OffsetNumberNext(afterleftoff);
+ }
/*
* Acquire a new right page to split into, now that left page has a new
@@ -1765,7 +1884,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
ropaque->btpo_flags = oopaque->btpo_flags;
- ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = oopaque->btpo_next;
ropaque->btpo_level = oopaque->btpo_level;
@@ -1782,21 +1901,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (!isrightmost)
{
- IndexTuple righthighkey;
+ ItemId hiitemid = PageGetItemId(origpage, P_HIKEY);
+ Size hiitemsz = ItemIdGetLength(hiitemid);
+ IndexTuple hikey = (IndexTuple) PageGetItem(origpage, hiitemid);
- itemid = PageGetItemId(origpage, P_HIKEY);
- itemsz = ItemIdGetLength(itemid);
- righthighkey = (IndexTuple) PageGetItem(origpage, itemid);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) > 0);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) <=
+ Assert(BTreeTupleGetNAtts(hikey, rel) > 0);
+ Assert(BTreeTupleGetNAtts(hikey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
- if (PageAddItem(rightpage, righthighkey, itemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ if (isleaf)
+ righthighkey = CopyIndexTuple(hikey); /* stable: origpage is
+ * overwritten by the left
+ * half before WAL logging */
+ else
{
- elog(ERROR, "failed to add high key to the right sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
+ if (PageAddItem(rightpage, hikey, hiitemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the right sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterrightoff = OffsetNumberNext(afterrightoff);
}
- afterrightoff = OffsetNumberNext(afterrightoff);
}
/*
@@ -1813,7 +1936,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*
* Note: we *must* insert at least the right page's items in item-number
* order, for the benefit of _bt_restore_page().
+ *
+ * A leaf split builds each half as a whole page below (so it can be
+ * prefix-compressed from the start), which redo must reproduce byte for
+ * byte. To keep the primary and redo in lock step, both derive the
+ * split's plain item arrays from the original page through the single
+ * shared routine _bt_split_leaf_collect(); the incremental loop here
+ * handles only the internal (!isleaf) case.
*/
+ if (isleaf)
+ {
+ _bt_split_leaf_collect(origpage, firstrightoff, newitemoff, newitem,
+ newitemonleft, origpagepostingoff, nposting,
+ leftitems, &nleft, rightitems, &nright);
+ }
+ else
for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
{
IndexTuple dataitem;
@@ -1837,24 +1974,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
{
Assert(newitemoff <= firstrightoff);
if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
- false))
- {
+ false))
elog(ERROR, "failed to add new item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
Assert(newitemoff >= firstrightoff);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1863,28 +1996,24 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (i < firstrightoff)
{
if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
- {
elog(ERROR, "failed to add old item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add old item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
- /* Handle case where newitem goes at the end of rightpage */
- if (i <= newitemoff)
+ /* Handle case where newitem goes at the end of rightpage (internal only) */
+ if (!isleaf && i <= newitemoff)
{
/*
* Can't have newitemonleft here; that would imply we were told to put
@@ -1893,15 +2022,90 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*/
Assert(!newitemonleft && newitemoff == maxoff + 1);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
+ /*
+ * Leaf halves are built as whole pages now, so they can be prefix-
+ * compressed from the start (Strategy 1: a page is faulted into the
+ * BTP_PREFIX format only when it is written whole). The plain item arrays
+ * were collected above by _bt_split_leaf_collect(), the same routine redo
+ * uses, so the primary and redo lay out identical bytes.
+ */
+ if (isleaf)
+ {
+ _bt_split_leaf_half(rel, leftpage, lefthighkey, leftitems, nleft,
+ origprefix, origprefixlen,
+ leftprefix, &leftprefixlen);
+ _bt_split_leaf_half(rel, rightpage, righthighkey, rightitems, nright,
+ origprefix, origprefixlen,
+ rightprefix, &rightprefixlen);
+
+ /*
+ * Build the right page's WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the right prefix bytes, then the
+ * right high key (unless rightmost), then the right half's plain items,
+ * each MAXALIGN'd, in one contiguous buffer logged with a single rdata
+ * slot. A very compressible right half whose plain items would exceed
+ * a page is instead carried as a full-page image (set below), so this
+ * buffer is bounded.
+ */
+ if (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0)
+ {
+ Size roff = rightprefixlen;
+
+ if (righthighkey != NULL)
+ roff += MAXALIGN(IndexTupleSize(righthighkey));
+ for (int ri = 0; ri < nright; ri++)
+ roff += MAXALIGN(IndexTupleSize(rightitems[ri]));
+ rightwalbuf = palloc(Max(roff, 1));
+ rightwalbuflen = roff;
+
+ /*
+ * Decide (before the critical section, since the injection-point
+ * cache load can allocate) whether to carry the right page as a
+ * forced full-page image: normally only when its plain payload
+ * would exceed a block, but a test can force the otherwise-rare
+ * path with the nbtree-prefix-force-rfpi injection point.
+ */
+ if (rightwalbuflen > BLCKSZ)
+ force_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ else if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ force_rfpi = true;
+#endif
+
+ roff = 0;
+ if (rightprefixlen > 0)
+ {
+ memcpy(rightwalbuf, rightprefix, rightprefixlen);
+ roff += rightprefixlen;
+ }
+ if (righthighkey != NULL)
+ {
+ Size hsz = IndexTupleSize(righthighkey);
+
+ memcpy(rightwalbuf + roff, righthighkey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(rightwalbuf + roff + hsz, 0, MAXALIGN(hsz) - hsz);
+ roff += MAXALIGN(hsz);
+ }
+ for (int ri = 0; ri < nright; ri++)
+ {
+ Size rsz = IndexTupleSize(rightitems[ri]);
+
+ memcpy(rightwalbuf + roff, rightitems[ri], rsz);
+ if (MAXALIGN(rsz) != rsz)
+ memset(rightwalbuf + roff + rsz, 0, MAXALIGN(rsz) - rsz);
+ roff += MAXALIGN(rsz);
+ }
+ }
+ }
+
/*
* We have to grab the original right sibling (if any) and update its prev
* link. We are guaranteed that this is deadlock-free, since we couple
@@ -2005,17 +2209,71 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /*
+ * A leaf split that involves prefix compression (either half ends up
+ * compressed, or the pre-split page was compressed so its items are
+ * coded) is replayed by rebuilding both halves from the pre-split page,
+ * exactly as the primary did, rather than by replaying logged tuples --
+ * the coded physical layout would not match _bt_restore_page. No
+ * full-page image is needed; redo reconstructs the plain items from the
+ * original page (block 0) and lays each half out coded against the
+ * chosen prefix, whose length and bytes are carried in the record.
+ */
+ xlrec.flags = 0;
+ xlrec.leftprefixlen = 0;
+ xlrec.rightprefixlen = 0;
+ if (isleaf && (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0))
+ {
+ xlrec.flags |= XLH_SPLIT_LEAFPREFIX;
+ xlrec.leftprefixlen = (uint8) leftprefixlen;
+ xlrec.rightprefixlen = (uint8) rightprefixlen;
+
+ /*
+ * The right half is normally carried as its packed plain payload
+ * (redo lays it out coded), far cheaper than a full-page image.
+ * But a very compressible page can hold more plain data than fits
+ * in one page; force_rfpi (decided before the critical section)
+ * carries the coded right page as a bounded full-page image instead.
+ */
+ if (force_rfpi)
+ xlrec.flags |= XLH_SPLIT_RFPI;
+ }
+
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
+ /*
+ * The right page is rebuilt from scratch in redo. Ordinarily that is
+ * from the logged tuple region (REGBUF_WILL_INIT). A prefix-leaf split
+ * instead rebuilds it from its logged plain items, unless those would
+ * overflow the record (XLH_SPLIT_RFPI), in which case the coded page is
+ * carried as a forced full-page image.
+ */
+ XLogRegisterBuffer(1, rbuf,
+ (xlrec.flags & XLH_SPLIT_RFPI) ?
+ (REGBUF_STANDARD | REGBUF_FORCE_IMAGE) : REGBUF_WILL_INIT);
/* Log original right sibling, since we've changed its prev-pointer */
if (!isrightmost)
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
if (!isleaf)
XLogRegisterBuffer(3, cbuf, REGBUF_STANDARD);
+ /*
+ * For a prefix leaf split, carry each half's chosen prefix bytes, and
+ * (unless the right page is carried as a full-page image) the right
+ * high key and the right half's PLAIN items on block 1, so redo can lay
+ * the right page out coded without depending on block 0's state (block
+ * 0 may be restored from an automatic full-page image). The left half
+ * is rebuilt in redo from the original page (block 0) when it needs
+ * redo, or from its own FPI otherwise, so the left items are not logged.
+ */
+ if (xlrec.flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ if (!(xlrec.flags & XLH_SPLIT_RFPI) && rightwalbuflen > 0)
+ XLogRegisterBufData(1, rightwalbuf, rightwalbuflen);
+ }
+
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
@@ -2063,6 +2321,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
XLogRegisterBufData(0, lefthighkey,
MAXALIGN(IndexTupleSize(lefthighkey)));
+ /*
+ * For a prefix leaf split, the left half's chosen prefix bytes follow
+ * the high key on block 0 (redo reads newitem/orignewitem, then the
+ * high key, then this prefix).
+ */
+ if ((xlrec.flags & XLH_SPLIT_LEAFPREFIX) && leftprefixlen > 0)
+ XLogRegisterBufData(0, leftprefix, leftprefixlen);
+
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). The whole right page will be recreated.
@@ -2071,11 +2337,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the line pointers can be reconstructed. See comments for
- * _bt_restore_page().
+ * _bt_restore_page(). A prefix-compressed leaf split instead rebuilds
+ * the right page from the original page in redo (see XLH_SPLIT_LEAFPREFIX),
+ * so its tuple region is not logged.
*/
- XLogRegisterBufData(1,
- (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
- ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
+ if (!(xlrec.flags & XLH_SPLIT_LEAFPREFIX))
+ XLogRegisterBufData(1,
+ (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
+ ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
xlinfo = newitemonleft ? XLOG_BTREE_SPLIT_L : XLOG_BTREE_SPLIT_R;
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
@@ -2820,7 +3089,15 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
- /* Perform deduplication pass (when enabled and index-is-allequalimage) */
+ /*
+ * Perform deduplication pass (when enabled and index-is-allequalimage).
+ *
+ * On a prefix-compressed page the pass decodes items and lays the deduped
+ * result out plain before re-coding it; the decoded set can exceed one
+ * page even when the coded form fit, in which case the pass aborts without
+ * touching the page and we fall through to split it. When it does fit, the
+ * recompressed page's WAL replays without a full-page image.
+ */
if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
_bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz,
(indexUnchanged || uniquedup));
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f81..c755efc81b0 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1212,11 +1212,26 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * If VACUUM emptied a prefix-compressed leaf of all its data items, drop
+ * the PREFIX item too and clear BTP_PREFIX: an empty page carries no
+ * prefix, and page deletion relies on an emptied leaf holding only its
+ * high key (nbtpage.c's _bt_mark_page_halfdead). Recorded in the vacuum
+ * record so redo does the same.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
/*
@@ -2245,6 +2260,20 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
opaque = BTPageGetOpaque(page);
opaque->btpo_flags |= BTP_HALF_DEAD;
+ /*
+ * A prefix-compressed leaf being deleted may still carry its PREFIX item
+ * (e.g. emptied by simple deletion, which does not drop it). Remove it
+ * and clear BTP_PREFIX so the page holds only its high key, as the
+ * half-dead transition below requires. Redo (btree_xlog_mark_page_halfdead)
+ * reinitializes the page, so it needs no separate handling.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
MemSet(&trunctuple, 0, sizeof(IndexTupleData));
trunctuple.t_info = sizeof(IndexTupleData);
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 00000000000..c725f75cd92
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,814 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Leaf key prefix compression for Postgres btrees.
+ *
+ * A leaf page of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page front-codes the first key column of each non-pivot tuple against a
+ * per-page PREFIX item, so that a long key that repeats on a page costs
+ * little more than its unshared tail. The on-disk format is documented at
+ * BTP_PREFIX in nbtree.h; this file is the encoder, decoder, and the policy
+ * that chooses a page's prefix.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo repeats them, and decoding reproduces the plain
+ * tuple byte for byte. A tuple inserted later that shares less of the prefix
+ * just stores a smaller `shared' count, so no insert, VACUUM, deduplication
+ * or change of the page's key range ever has to rewrite the page to keep the
+ * prefix valid. The prefix is chosen only when a page is written as a whole
+ * (CREATE INDEX, or a half of a split).
+ *
+ * This is modeled on the leaf prefix compression of the BARK access method
+ * (src/backend/access/bark/barkutils.c).
+ *
+ *
+ * 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 "storage/bufpage.h"
+#include "utils/rel.h"
+
+/*
+ * Where each part of a tuple goes in its coded form. Coding and decoding
+ * both lay the tuple out from this, so the two cannot disagree.
+ */
+typedef struct BTPrefixPlan
+{
+ Size hoff; /* data offset: header and null bitmap */
+ Size c1; /* plain first column's size, with header */
+ Size keyend; /* end of the plain key (posting offset) */
+ uint8 code; /* first payload byte of the coded column */
+ const char *suffix; /* bytes stored after it */
+ Size suffixlen;
+ Size vsize; /* coded column's size, with header */
+ Size codedkeyend; /* end of the coded key */
+ Size total; /* size of the coded tuple */
+} BTPrefixPlan;
+
+/*
+ * Is `itup' stored coded on a BTP_PREFIX page? Only a non-pivot tuple (plain
+ * or posting) whose first column is not NULL; a pivot (high key, downlink)
+ * is stored as it is.
+ */
+static bool
+_bt_prefix_codable(IndexTuple itup)
+{
+ if (BTreeTupleIsPivot(itup))
+ return false;
+ return !IndexTupleHasNulls(itup) ||
+ !att_isnull(0, (uint8 *) itup + sizeof(IndexTupleData));
+}
+
+/*
+ * Does the varlena `val' have the header decoding would give it back: the
+ * 1-byte header if its data fit one, else an uncompressed 4-byte header?
+ */
+static bool
+_bt_prefix_canonical(const char *val)
+{
+ if (VARATT_IS_SHORT(val))
+ return !VARATT_IS_EXTERNAL(val);
+ return !VARATT_IS_EXTENDED(val) &&
+ VARSIZE(val) - VARHDRSZ + VARHDRSZ_SHORT > VARATT_SHORT_MAX;
+}
+
+/*
+ * End of a leaf tuple's key: the posting-list offset for a posting tuple, the
+ * whole tuple size otherwise. The bytes after the key (a posting list) are
+ * moved as they are.
+ */
+static Size
+_bt_prefix_keyend(IndexTuple itup)
+{
+ if (BTreeTupleIsPosting(itup))
+ return BTreeTupleGetPostingOffset(itup);
+ return IndexTupleSize(itup);
+}
+
+/*
+ * The size of a coded first column whose payload is `payload' bytes: a
+ * varlena with a 1-byte header when it fits one.
+ */
+static inline Size
+_bt_prefix_vsize(Size payload)
+{
+ if (payload + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ return payload + VARHDRSZ_SHORT;
+ return payload + VARHDRSZ;
+}
+
+/*
+ * Offset at which the bytes after the first column go in a coded tuple whose
+ * coded column ends at `vend', when in the plain tuple they start at
+ * `restorig': the first offset at or after `vend' that is the same distance
+ * past a MAXALIGN boundary.
+ */
+static inline Size
+_bt_prefix_restoff(Size vend, Size restorig)
+{
+ return vend + (MAXIMUM_ALIGNOF + restorig % MAXIMUM_ALIGNOF -
+ vend % MAXIMUM_ALIGNOF) % MAXIMUM_ALIGNOF;
+}
+
+/* Plan the coding of the codable tuple `itup' against `prefix'. */
+static void
+_bt_prefix_plan(const char *prefix, Size prefixlen, IndexTuple itup,
+ BTPrefixPlan *plan)
+{
+ const char *val;
+ Size restorig;
+ bool storerest;
+
+ plan->hoff = IndexInfoFindDataOffset(itup->t_info);
+ val = (const char *) itup + plan->hoff;
+ plan->c1 = VARSIZE_ANY(val);
+ plan->keyend = _bt_prefix_keyend(itup);
+
+ if (_bt_prefix_canonical(val))
+ {
+ const char *data = VARDATA_ANY(val);
+ Size datalen = VARSIZE_ANY_EXHDR(val);
+ Size limit = Min(prefixlen, datalen);
+ Size shared = 0;
+
+ while (shared < limit && prefix[shared] == data[shared])
+ shared++;
+ plan->code = (uint8) shared;
+ plan->suffix = data + shared;
+ plan->suffixlen = datalen - shared;
+ }
+ else
+ {
+ plan->code = BTREE_PREFIX_RAW;
+ plan->suffix = val;
+ plan->suffixlen = plan->c1;
+ }
+ plan->vsize = _bt_prefix_vsize(1 + plan->suffixlen);
+
+ /*
+ * The bytes after the first column can be left out when they are just the
+ * zero padding that ends the key.
+ */
+ restorig = plan->hoff + plan->c1;
+ storerest = plan->keyend != MAXALIGN(restorig);
+ for (Size i = restorig; !storerest && i < plan->keyend; i++)
+ storerest = ((const char *) itup)[i] != 0;
+
+ if (storerest)
+ {
+ plan->code |= BTREE_PREFIX_REST;
+ plan->codedkeyend = _bt_prefix_restoff(plan->hoff + plan->vsize,
+ restorig) +
+ (plan->keyend - restorig);
+ }
+ else
+ plan->codedkeyend = MAXALIGN(plan->hoff + plan->vsize);
+ plan->total = plan->codedkeyend + (IndexTupleSize(itup) - plan->keyend);
+}
+
+/*
+ * Copy n bytes, as memcpy. Decoding moves a few short runs of bytes per
+ * tuple; eight bytes at a time is several times faster than a libc memcpy
+ * for runs this short (the string-move startup cost dominates otherwise).
+ */
+static inline void
+_bt_prefix_copy(char *dst, const char *src, Size n)
+{
+ while (n >= sizeof(uint64))
+ {
+ memcpy(dst, src, sizeof(uint64));
+ dst += sizeof(uint64);
+ src += sizeof(uint64);
+ n -= sizeof(uint64);
+ }
+ while (n-- > 0)
+ *dst++ = *src++;
+}
+
+/* The prefix of a BTP_PREFIX page, and its length in *len. */
+const char *
+_bt_page_get_prefix(Page page, Size *len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ IndexTuple item;
+
+ Assert(P_HASPREFIX(opaque));
+ item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page,
+ BTPageGetPrefixOff(opaque)));
+ *len = IndexTupleSize(item) - sizeof(IndexTupleData);
+ return (const char *) item + sizeof(IndexTupleData);
+}
+
+/*
+ * Give `page', a leaf holding nothing yet but its high key (if it has one),
+ * the prefix `prefix' of `len' bytes: add the PREFIX item and set BTP_PREFIX.
+ * Its padding comes from the page's free space, which _bt_pageinit zeroed, so
+ * a page rebuilt in redo is the same.
+ */
+void
+_bt_page_set_prefix(Page page, const char *prefix, Size len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ union
+ {
+ IndexTupleData hdr;
+ char data[sizeof(IndexTupleData) + BTREE_PREFIX_MAX];
+ } item;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+ Assert(len >= 1 && len <= BTREE_PREFIX_MAX);
+ Assert(PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque) - 1);
+
+ memset(&item.hdr, 0, sizeof(IndexTupleData));
+ item.hdr.t_info = (unsigned short) (sizeof(IndexTupleData) + len);
+ memcpy(item.data + sizeof(IndexTupleData), prefix, len);
+ if (PageAddItem(page, item.data, sizeof(IndexTupleData) + len,
+ BTPageGetPrefixOff(opaque), false, false) ==
+ InvalidOffsetNumber)
+ elog(ERROR, "failed to add prefix item to btree page");
+ opaque->btpo_flags |= BTP_PREFIX;
+}
+
+/*
+ * The coded form of the leaf tuple `itup' for `page', palloc'd; `itup' itself
+ * when the page has no prefix or the tuple is stored plain.
+ */
+IndexTuple
+_bt_prefix_encode(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+ char *coded;
+ char *v;
+ char *payload;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ Assert(plan.total <= INDEX_SIZE_MASK);
+
+ coded = palloc0(plan.total);
+ memcpy(coded, itup, plan.hoff);
+ v = coded + plan.hoff;
+ if (plan.vsize <= VARATT_SHORT_MAX)
+ {
+ SET_VARSIZE_SHORT(v, plan.vsize);
+ payload = v + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ SET_VARSIZE(v, plan.vsize);
+ payload = v + VARHDRSZ;
+ }
+ payload[0] = (char) plan.code;
+ memcpy(payload + 1, plan.suffix, plan.suffixlen);
+ if (plan.code & BTREE_PREFIX_REST)
+ {
+ Size restorig = plan.hoff + plan.c1;
+
+ memcpy(coded + _bt_prefix_restoff(plan.hoff + plan.vsize, restorig),
+ (char *) itup + restorig, plan.keyend - restorig);
+ }
+ memcpy(coded + plan.codedkeyend, (char *) itup + plan.keyend,
+ IndexTupleSize(itup) - plan.keyend);
+
+ ((IndexTuple) coded)->t_info =
+ (itup->t_info & ~INDEX_SIZE_MASK) | (unsigned short) plan.total;
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) coded, BTreeTupleGetNPosting(itup),
+ plan.codedkeyend);
+
+#ifdef USE_ASSERT_CHECKING
+ {
+ BTItemBuf check;
+ IndexTuple decoded = _bt_prefix_decode(page, (IndexTuple) coded,
+ &check);
+
+ Assert(IndexTupleSize(decoded) == IndexTupleSize(itup) &&
+ memcmp(decoded, itup, IndexTupleSize(itup)) == 0);
+ }
+#endif
+
+ return (IndexTuple) coded;
+}
+
+/* The MAXALIGNed size `itup' takes on `page', coded if the page codes it. */
+Size
+_bt_prefix_coded_size(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return MAXALIGN(IndexTupleSize(itup));
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ return MAXALIGN(plan.total);
+}
+
+/*
+ * Decode the item `itup' of the BTP_PREFIX page `page' into `buf', and return
+ * it; an item stored plain is returned as it is. The inverse of
+ * _bt_prefix_encode, byte for byte. An item that does not decode within its
+ * own bounds, or claims more of the prefix than the page has, is reported as
+ * corruption.
+ */
+IndexTuple
+_bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf)
+{
+ const char *prefix;
+ Size prefixlen;
+ Size size = IndexTupleSize(itup);
+ Size hoff;
+ Size codedkeyend;
+ const char *v;
+ Size vsize = 0;
+ const char *payload;
+ Size paylen = 0;
+ uint8 code;
+ Size shared;
+ char *out = buf->data;
+ char *val = NULL;
+ Size c1;
+ Size restorig;
+ Size keyend;
+
+ if (!_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ hoff = IndexInfoFindDataOffset(itup->t_info);
+ codedkeyend = _bt_prefix_keyend(itup);
+ v = (const char *) itup + hoff;
+ if (hoff >= codedkeyend || codedkeyend > size ||
+ (vsize = VARSIZE_ANY(v)) > codedkeyend - hoff ||
+ (paylen = VARSIZE_ANY_EXHDR(v)) < 1)
+ goto corrupt;
+ payload = VARDATA_ANY(v);
+ code = (uint8) payload[0];
+ shared = code & BTREE_PREFIX_SHARED_MASK;
+
+ _bt_prefix_copy(out, (const char *) itup, hoff);
+ val = out + hoff;
+ if (shared == BTREE_PREFIX_RAW)
+ {
+ c1 = paylen - 1;
+ _bt_prefix_copy(val, payload + 1, c1);
+ if (c1 < VARHDRSZ_SHORT || VARSIZE_ANY(val) != c1)
+ goto corrupt;
+ }
+ else
+ {
+ Size datalen = shared + paylen - 1;
+ char *data;
+
+ if (hoff + VARHDRSZ + datalen > sizeof(BTItemBuf))
+ goto corrupt;
+ if (shared > prefixlen)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple shares %zu bytes of a %zu-byte page prefix",
+ shared, prefixlen)));
+ if (datalen + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ {
+ c1 = datalen + VARHDRSZ_SHORT;
+ SET_VARSIZE_SHORT(val, c1);
+ data = val + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ c1 = datalen + VARHDRSZ;
+ SET_VARSIZE(val, c1);
+ data = val + VARHDRSZ;
+ }
+ _bt_prefix_copy(data, prefix, shared);
+ _bt_prefix_copy(data + shared, payload + 1, paylen - 1);
+ }
+
+ restorig = hoff + c1;
+ if (code & BTREE_PREFIX_REST)
+ {
+ Size restoff = _bt_prefix_restoff(hoff + vsize, restorig);
+
+ if (restoff > codedkeyend)
+ goto corrupt;
+ keyend = restorig + (codedkeyend - restoff);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ _bt_prefix_copy(out + restorig, (const char *) itup + restoff,
+ codedkeyend - restoff);
+ }
+ else
+ {
+ keyend = MAXALIGN(restorig);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ memset(out + restorig, 0, keyend - restorig);
+ }
+ _bt_prefix_copy(out + keyend, (const char *) itup + codedkeyend,
+ size - codedkeyend);
+
+ ((IndexTuple) out)->t_info = (itup->t_info & ~INDEX_SIZE_MASK) |
+ (unsigned short) (keyend + (size - codedkeyend));
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) out, BTreeTupleGetNPosting(itup),
+ keyend);
+ return (IndexTuple) out;
+
+corrupt:
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple is not a valid prefix-coded tuple")));
+ return NULL; /* keep compiler quiet */
+}
+
+/*
+ * The bytes of `itup''s first column a page prefix may be taken from, and how
+ * many (at most BTREE_PREFIX_MAX) in *len; NULL when the tuple has none: it
+ * is not codable, its value is empty, or it would be stored raw.
+ */
+const char *
+_bt_prefix_candidate(IndexTuple itup, Size *len)
+{
+ const char *val;
+
+ if (!_bt_prefix_codable(itup))
+ return NULL;
+ val = (const char *) itup + IndexInfoFindDataOffset(itup->t_info);
+ if (!_bt_prefix_canonical(val) || VARSIZE_ANY_EXHDR(val) == 0)
+ return NULL;
+ *len = Min(VARSIZE_ANY_EXHDR(val), BTREE_PREFIX_MAX);
+ return VARDATA_ANY(val);
+}
+
+/*
+ * How many bytes of `prefix' the first column of `itup' would share on a page
+ * with that prefix: -1 when the tuple would not be coded, 0 when it would be
+ * stored raw.
+ */
+int
+_bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup)
+{
+ BTPrefixPlan plan;
+
+ if (!_bt_prefix_codable(itup))
+ return -1;
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ if ((plan.code & BTREE_PREFIX_SHARED_MASK) == BTREE_PREFIX_RAW)
+ return 0;
+ return plan.code & BTREE_PREFIX_SHARED_MASK;
+}
+
+/*
+ * May the leaves of `index' have a prefix? Only when the prefix_compression
+ * reloption is on and the first key column is of a varlena type.
+ */
+bool
+_bt_prefix_enabled(Relation index)
+{
+ return BTGetPrefixCompression(index) &&
+ TupleDescAttr(RelationGetDescr(index), 0)->attlen == -1;
+}
+
+/*
+ * Choose the prefix for a leaf about to be built from the `n' tuples `items',
+ * in key order: the first tuple's first-column bytes, cut to the longest run
+ * any later tuple shares, provided the tuples share at least
+ * BTREE_PREFIX_MIN_SHARED bytes of it on average. Returns the prefix's
+ * length, with *prefix pointing into items[0], or 0 for no prefix.
+ */
+Size
+_bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix)
+{
+ const char *cand;
+ Size candlen;
+ Size longest = 0;
+ Size total = 0;
+ int ncoded = 0;
+
+ if (!_bt_prefix_enabled(index) ||
+ (cand = _bt_prefix_candidate(items[0], &candlen)) == NULL)
+ return 0;
+ if (n == 1)
+ {
+ *prefix = cand;
+ return candlen;
+ }
+ for (int i = 1; i < n; i++)
+ {
+ int shared = _bt_prefix_shared(cand, candlen, items[i]);
+
+ if (shared < 0)
+ continue;
+ total += shared;
+ ncoded++;
+ longest = Max(longest, (Size) shared);
+ }
+ if (longest == 0 || total < (Size) BTREE_PREFIX_MIN_SHARED * ncoded)
+ return 0;
+ *prefix = cand;
+ return longest;
+}
+
+/*
+ * Fault a freshly finished leaf `page' forward into the BTP_PREFIX format if
+ * a prefix pays off (Strategy 1: compress a page only when it is written as a
+ * whole -- CREATE INDEX page finish, a split half, a dedup rewrite -- so that
+ * no insert, VACUUM or key-range change ever forces a page rewrite, and an
+ * old-format page is converted only when it is next written whole).
+ *
+ * On entry `page' is a complete leaf: its high key at P_HIKEY if it is not
+ * rightmost, then its data items, none of them coded (the page does not yet
+ * have BTP_PREFIX). If the data items share enough of a common first-column
+ * prefix, the page is rebuilt in place with the PREFIX item inserted after
+ * the high key and every data item coded against it; otherwise the page is
+ * left unchanged. Item order and the high key are preserved.
+ *
+ * Caller must hold the page's buffer exclusively (or own the page outright,
+ * as the build and split paths do with a temp page). No WAL is written here:
+ * callers log the resulting page (a build writes it whole; a split logs each
+ * half whole or by full-page image), and redo reaches the same bytes by
+ * running _bt_prefix_encode against the same prefix.
+ */
+void
+_bt_prefix_compress_page(Relation index, Page page)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber minoff;
+ OffsetNumber maxoff;
+ int n;
+ IndexTuple *items;
+ Size prefixlen;
+ const char *prefix;
+ Page tmp;
+ OffsetNumber o;
+ IndexTuple hikey = NULL;
+ Size hikeysz = 0;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+
+ if (!_bt_prefix_enabled(index))
+ return;
+
+ minoff = P_FIRSTDATAKEY(opaque);
+ maxoff = PageGetMaxOffsetNumber(page);
+ n = (int) maxoff - (int) minoff + 1;
+ if (n <= 0)
+ return;
+
+ items = (IndexTuple *) palloc(n * sizeof(IndexTuple));
+ for (int i = 0; i < n; i++)
+ items[i] = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, minoff + i));
+
+ prefixlen = _bt_prefix_choose(index, items, n, &prefix);
+ if (prefixlen == 0)
+ {
+ pfree(items);
+ return;
+ }
+
+ /* The high key (if any) stays at P_HIKEY, uncoded. */
+ if (!P_RIGHTMOST(opaque))
+ {
+ ItemId hii = PageGetItemId(page, P_HIKEY);
+
+ hikeysz = ItemIdGetLength(hii);
+ hikey = (IndexTuple) PageGetItem(page, hii);
+ }
+
+ /*
+ * Rebuild into a temp page: high key (if any), PREFIX item, then each
+ * data item coded. The coded form can be up to MAXALIGN larger than the
+ * plain one, but the PREFIX item replaces far more shared bytes than it
+ * adds, so a page that was not overfull stays within bounds; if a coded
+ * item somehow does not fit, leave the page uncompressed (always valid).
+ */
+ tmp = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(tmp)->btpo_flags &= ~BTP_PREFIX;
+ if (hikey != NULL &&
+ PageAddItem(tmp, (char *) hikey, hikeysz, P_HIKEY, false, false) ==
+ InvalidOffsetNumber)
+ goto giveup;
+ _bt_page_set_prefix(tmp, prefix, prefixlen);
+ o = OffsetNumberNext(PageGetMaxOffsetNumber(tmp));
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(tmp, items[i]);
+ bool ok;
+
+ ok = PageAddItem(tmp, (char *) coded, IndexTupleSize(coded), o++,
+ false, false) != InvalidOffsetNumber;
+ if (coded != items[i])
+ pfree(coded);
+ if (!ok)
+ goto giveup;
+ }
+
+ memcpy(page, tmp, BLCKSZ);
+ pfree(tmp);
+ pfree(items);
+ return;
+
+giveup:
+ pfree(tmp);
+ pfree(items);
+}
+
+/*
+ * Lay out one half of a leaf split on `page', which the caller has
+ * initialized (flags, links) and left empty: the high key `hikey' (NULL for
+ * a rightmost half), then the `n' plain tuples `items' in key order.
+ *
+ * The half is formed as a whole, so it may take a prefix of its own: its
+ * first item's leading bytes, if the items share enough (_bt_prefix_choose).
+ * Because a prefix-compressed leaf can hold more logical data than fits in a
+ * plain page, the half is laid out coded from the start rather than built
+ * plain and compressed afterwards. When the half does not fit with its own
+ * prefix (or shares too little to take one) it is laid out plain, and if that
+ * does not fit either, with `origprefix' (the splitting page's prefix, zero
+ * length when it had none), against which it fits by construction: the items
+ * keep the shares they had on the original page.
+ *
+ * Mirrors BARK's bark_split_leaf_half. Callers: _bt_split (both halves).
+ */
+void
+_bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen)
+{
+ BTPageOpaqueData saved = *BTPageGetOpaque(page);
+ const char *prefix[3];
+ Size prefixlen[3];
+ int ntries = 0;
+
+ Assert(P_ISLEAF(&saved) && !P_HASPREFIX(&saved));
+
+ /* Candidate prefixes to try, in order: own, none, the orig page's. */
+ if (_bt_prefix_enabled(index))
+ {
+ prefixlen[ntries] = _bt_prefix_choose(index, items, n, &prefix[ntries]);
+ if (prefixlen[ntries] > 0)
+ ntries++;
+ }
+ prefix[ntries] = NULL;
+ prefixlen[ntries++] = 0;
+ if (origprefixlen > 0)
+ {
+ prefix[ntries] = origprefix;
+ prefixlen[ntries++] = origprefixlen;
+ }
+
+ for (int t = 0; t < ntries; t++)
+ {
+ if (_bt_split_leaf_layout(page, hikey, items, n, prefix[t], prefixlen[t]))
+ {
+ /* Report the prefix that won, so the split can log it for redo. */
+ if (chosenprefixlen != NULL)
+ {
+ *chosenprefixlen = prefixlen[t];
+ if (prefixlen[t] > 0)
+ memcpy(chosenprefix, prefix[t], prefixlen[t]);
+ }
+ return;
+ }
+ /* restore the empty-page state for the next attempt */
+ _bt_pageinit(page, BLCKSZ);
+ *BTPageGetOpaque(page) = saved;
+ }
+ elog(ERROR, "failed to lay out a split half of index \"%s\"",
+ RelationGetRelationName(index));
+}
+
+/*
+ * Lay out one leaf split half on the caller-initialized empty `page' using a
+ * GIVEN prefix (`prefixlen' 0 for none): the high key `hikey' (NULL for a
+ * rightmost half), the PREFIX item, then the `n' plain tuples `items' coded
+ * against the prefix. Returns false if they do not fit (caller tries another
+ * prefix). This is Relation-free and deterministic in (hikey, items, prefix),
+ * so WAL redo calls it with the logged prefix to reproduce the primary's page
+ * byte for byte.
+ */
+bool
+_bt_split_leaf_layout(Page page, IndexTuple hikey, IndexTuple *items, int n,
+ const char *prefix, Size prefixlen)
+{
+ OffsetNumber o = P_HIKEY;
+ bool fit = true;
+
+ Assert(P_ISLEAF(BTPageGetOpaque(page)) && !P_HASPREFIX(BTPageGetOpaque(page)));
+ if (hikey != NULL &&
+ PageAddItem(page, (char *) hikey, MAXALIGN(IndexTupleSize(hikey)),
+ o++, false, false) == InvalidOffsetNumber)
+ return false;
+ if (prefixlen > 0)
+ {
+ _bt_page_set_prefix(page, prefix, prefixlen);
+ o++;
+ }
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(page, items[i]);
+
+ if (PageAddItem(page, (char *) coded, IndexTupleSize(coded),
+ o++, false, false) == InvalidOffsetNumber)
+ fit = false;
+ if (coded != items[i])
+ pfree(coded);
+ if (!fit)
+ return false;
+ }
+#ifdef USE_ASSERT_CHECKING
+ if (P_HASPREFIX(BTPageGetOpaque(page)))
+ {
+ OffsetNumber mx = PageGetMaxOffsetNumber(page);
+ OffsetNumber off;
+
+ for (off = P_FIRSTDATAKEY(BTPageGetOpaque(page)); off <= mx; off++)
+ {
+ BTItemBuf vb;
+
+ (void) _bt_getitem(page, off, &vb);
+ }
+ }
+#endif
+ return true;
+}
+
+/*
+ * Collect the plain (decoded) data items of a leaf split into the left and
+ * right arrays, in the exact order and with the exact substitutions that the
+ * split performs: the original page's data items split at `firstrightoff',
+ * with `newitem' inserted at `newitemoff' on the side given by `newitemonleft'
+ * and, for a posting-list split, the posting at `origpagepostingoff' replaced
+ * by `nposting'. `origpage' is the pre-split page (its items are decoded when
+ * it is a BTP_PREFIX page). The arrays receive pointers that are valid until
+ * the caller is done laying out the halves; items taken from the page are
+ * copied, so the caller owns and should reset the surrounding memory context.
+ *
+ * Both the primary (_bt_split) and WAL redo (btree_xlog_split) collect items
+ * this way and then lay out each half with _bt_split_leaf_half, so the two
+ * produce byte-identical coded pages without logging a full-page image.
+ */
+void
+_bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft, OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright)
+{
+ BTPageOpaque oopaque = BTPageGetOpaque(origpage);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(origpage);
+ bool hasprefix = P_HASPREFIX(oopaque);
+ OffsetNumber i;
+
+ *nleft = 0;
+ *nright = 0;
+ for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
+ {
+ ItemId itemid = PageGetItemId(origpage, i);
+ IndexTuple dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ BTItemBuf dibuf;
+
+ if (hasprefix)
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+
+ if (i == origpagepostingoff)
+ dataitem = nposting;
+ else if (i == newitemoff)
+ {
+ if (newitemonleft)
+ leftitems[(*nleft)++] = newitem;
+ else
+ rightitems[(*nright)++] = newitem;
+ }
+
+ if (i < firstrightoff)
+ leftitems[(*nleft)++] = CopyIndexTuple(dataitem);
+ else
+ rightitems[(*nright)++] = CopyIndexTuple(dataitem);
+ }
+ if (i <= newitemoff)
+ rightitems[(*nright)++] = newitem;
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..ffc5846e4c9 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -145,6 +145,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ BTItemBuf itupbuf; /* decode workspace for a loop's current item */
+ BTItemBuf finalbuf; /* decode workspace for a backward finaltup */
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -252,7 +254,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
continue;
}
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -353,9 +355,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,7 +415,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
else
tuple_alive = true;
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -596,9 +596,10 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
BTScanOpaque so = (BTScanOpaque) scan->opaque;
Relation rel = scan->indexRelation;
TupleDesc tupdesc = RelationGetDescr(rel);
- ItemId iid;
IndexTuple firsttup,
lasttup;
+ BTItemBuf firstbuf,
+ lastbuf;
int startikey = 0,
arrayidx = 0,
firstchangingattnum;
@@ -616,12 +617,10 @@ _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);
+ 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 +2052,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
ScanDirection dir = pstate->dir;
OffsetNumber aheadoffnum;
IndexTuple ahead;
+ BTItemBuf aheadbuf; /* decode workspace for the look-ahead tuple */
Assert(!pstate->forcenonrequired);
@@ -2088,8 +2088,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..b8b8ec018f3 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -435,8 +435,10 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ Size half = BTTupleStorageSize(scan->indexRelation);
+
+ so->currTuples = (char *) palloc(half * 2);
+ so->markTuples = so->currTuples + half;
}
/*
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..f932ca33c3a 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -696,6 +696,7 @@ _bt_compare(Relation rel,
TupleDesc itupdesc = RelationGetDescr(rel);
BTPageOpaque opaque = BTPageGetOpaque(page);
IndexTuple itup;
+ BTItemBuf itupbuf;
ItemPointer heapTid;
ScanKey scankey;
int ncmpkey;
@@ -713,7 +714,7 @@ _bt_compare(Relation rel,
if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque))
return 1;
- itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ itup = _bt_getitem(page, offnum, &itupbuf);
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dcf4..9a8b372033e 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -241,6 +241,10 @@ typedef struct BTPageState
Size btps_lastextra; /* last item's extra posting list space */
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
+ /* Leaf prefix compression: the candidate prefix of the page being filled */
+ const char *btps_prefix; /* into the page's first data item, or NULL */
+ Size btps_prefixlen; /* its length (0 = no candidate) */
+ Size btps_codedsaved; /* bytes the page's items would save coded */
struct BTPageState *btps_next; /* link to parent level, if any */
} BTPageState;
@@ -856,7 +860,8 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
*/
Assert(last_truncextra == 0 || isleaf);
if (pgspc < itupsz + (isleaf ? MAXALIGN(sizeof(ItemPointerData)) : 0) ||
- (pgspc + last_truncextra < state->btps_full && last_off > P_FIRSTKEY))
+ (pgspc + last_truncextra + state->btps_codedsaved < state->btps_full &&
+ last_off > P_FIRSTKEY))
{
/*
* Finish off the page and write it out.
@@ -980,6 +985,15 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
nopaque->btpo_next = P_NONE; /* redundant */
}
+ /*
+ * The old leaf page is complete: fault it forward into the
+ * BTP_PREFIX format if a prefix pays off. Done after btps_lowkey
+ * (the downlink, a copy of the uncoded high key) is saved, since
+ * compression rewrites opage in place.
+ */
+ if (isleaf)
+ _bt_prefix_compress_page(wstate->index, opage);
+
/*
* Write out the old page. _bt_blwritepage takes ownership of the
* 'opage' buffer.
@@ -990,6 +1004,11 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
* Reset last_off to point to new page
*/
last_off = P_FIRSTKEY;
+
+ /* The new page starts with no prefix candidate and no savings yet. */
+ state->btps_prefix = NULL;
+ state->btps_prefixlen = 0;
+ state->btps_codedsaved = 0;
}
/*
@@ -1017,6 +1036,34 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_sortaddtup(npage, itupsz, itup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Leaf prefix compression accounting (Strategy 1): the page's eventual
+ * prefix is its first data item's first-column bytes; estimate how much
+ * room coding this page's items would free, so the fillfactor test above
+ * can pack more plain items onto a page that will shrink when it is
+ * compressed at finish. Items are still placed plain here; the estimate
+ * only relaxes the soft limit, never the hard fit check.
+ */
+ if (isleaf && _bt_prefix_enabled(wstate->index))
+ {
+ if (state->btps_prefix == NULL)
+ state->btps_prefix = _bt_prefix_candidate(itup,
+ &state->btps_prefixlen);
+ else
+ {
+ int shared = _bt_prefix_shared(state->btps_prefix,
+ state->btps_prefixlen, itup);
+
+ /*
+ * A coded item drops `shared' value bytes but keeps a 1-byte
+ * share count; count the net MAXALIGN-rounded saving.
+ */
+ if (shared > 1)
+ state->btps_codedsaved +=
+ MAXALIGN(itupsz) - MAXALIGN(itupsz - (shared - 1));
+ }
+ }
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1115,6 +1162,11 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
* back one slot. Then we can dump out the page.
*/
_bt_slideleft((Page) s->btps_buf);
+
+ /* Fault the finished rightmost leaf forward into BTP_PREFIX if it pays. */
+ if (s->btps_level == 0)
+ _bt_prefix_compress_page(wstate->index, (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/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8b2..14bee568e45 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -52,6 +52,9 @@ typedef struct
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
+ bool hasprefix; /* origpage is prefix-compressed (BTP_PREFIX) */
+ BTItemBuf lbuf; /* decode scratch for _bt_split_lastleft */
+ BTItemBuf rbuf; /* decode scratch for _bt_split_firstright */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
@@ -172,6 +175,23 @@ _bt_findsplitloc(Relation rel,
olddataitemstotal = rightspace - (int) PageGetExactFreeSpace(origpage);
leaffillfactor = BTGetFillFactor(rel);
+ /*
+ * On a prefix-compressed leaf the page carries an extra PREFIX item that
+ * is not a data item. The split is reasoned about in CODED (on-page)
+ * bytes -- the physical reality of the page and of each recompressed half
+ * -- so subtract only the PREFIX item's space from olddataitemstotal and
+ * use the on-page (coded) item sizes in the scan below. (Plain-size
+ * accounting is not usable here: a compressed leaf can hold far more
+ * logical data than two plain pages, so a plain split can be infeasible.)
+ */
+ if (P_HASPREFIX(opaque))
+ {
+ ItemId pii = PageGetItemId(origpage, BTPageGetPrefixOff(opaque));
+
+ olddataitemstotal -= (int) (MAXALIGN(ItemIdGetLength(pii)) +
+ sizeof(ItemIdData));
+ }
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
state.rel = rel;
@@ -185,6 +205,7 @@ _bt_findsplitloc(Relation rel,
state.olddataitemstotal = olddataitemstotal;
state.minfirstrightsz = SIZE_MAX;
state.newitemoff = newitemoff;
+ state.hasprefix = P_HASPREFIX(opaque);
/* newitem cannot be a posting list item */
Assert(!BTreeTupleIsPosting(newitem));
@@ -463,6 +484,19 @@ _bt_recsplitloc(FindSplitData *state,
newitemisfirstright = (firstrightoff == state->newitemoff &&
!newitemonleft);
+ /*
+ * Reject a split that would leave the left page with no data items at all
+ * (firstrightoff at the first data key, with newitem not on the left).
+ * Upstream's split-point heuristics never choose such a point for a plain
+ * page; on a prefix-compressed page the coded-size accounting can make it
+ * look attractive, and _bt_split cannot form a left high key from a
+ * nonexistent lastleft.
+ */
+ if (state->hasprefix &&
+ firstrightoff == P_FIRSTDATAKEY(BTPageGetOpaque(state->origpage)) &&
+ !newitemonleft)
+ return;
+
if (newitemisfirstright)
firstrightsz = state->newitemsz;
else
@@ -686,8 +720,12 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
if (state->newitemoff > maxoff)
{
+ BTItemBuf tbuf;
+
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ if (state->hasprefix)
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
@@ -714,12 +752,20 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
+ /* Do cheaper test first (t_tid and the posting bit survive coding) */
if (BTreeTupleIsPosting(tup) ||
!_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
return false;
/* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ if (state->hasprefix)
+ {
+ BTItemBuf tbuf;
+
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ }
+ else
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -1160,12 +1206,38 @@ static inline IndexTuple
_bt_split_lastleft(FindSplitData *state, SplitPoint *split)
{
ItemId itemid;
+ OffsetNumber off;
if (split->newitemonleft && split->firstrightoff == state->newitemoff)
return state->newitem;
- itemid = PageGetItemId(state->origpage,
- OffsetNumberPrev(split->firstrightoff));
+ off = OffsetNumberPrev(split->firstrightoff);
+
+ /*
+ * On a prefix-compressed leaf the PREFIX item sits just before the first
+ * data item. When the split boundary is at the first data key, the data
+ * predecessor is the high key on a plain page (a pivot, read plain); a
+ * rightmost page has none, so fall back to the first data item (this only
+ * feeds penalty estimation, never placement). Never read the PREFIX item
+ * as a data tuple.
+ */
+ if (state->hasprefix)
+ {
+ BTPageOpaque op = BTPageGetOpaque(state->origpage);
+
+ if (off == BTPageGetPrefixOff(op))
+ {
+ if (!P_RIGHTMOST(op))
+ return (IndexTuple) PageGetItem(state->origpage,
+ PageGetItemId(state->origpage, P_HIKEY));
+ off = P_FIRSTDATAKEY(op);
+ }
+ itemid = PageGetItemId(state->origpage, off);
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->lbuf);
+ }
+ itemid = PageGetItemId(state->origpage, off);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
@@ -1181,5 +1253,9 @@ _bt_split_firstright(FindSplitData *state, SplitPoint *split)
return state->newitem;
itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ if (state->hasprefix)
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->rbuf);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
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..41bda6aba06 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -243,6 +243,211 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting,
_bt_restore_meta(record, 2);
}
+/*
+ * Replay a prefix-compressed leaf page split.
+ *
+ * Rather than replay logged tuples (whose coded physical layout
+ * _bt_restore_page could not reproduce), rebuild both halves the way the
+ * primary did: collect the split's plain items from the pre-split original
+ * page (block 0) with _bt_split_leaf_collect, then lay each half out coded
+ * against its chosen prefix (carried in the record) with _bt_split_leaf_layout.
+ * The result is byte-identical to the primary, so no full-page image is
+ * needed. The record's block-0 data carries, in order: newitem (when it went
+ * on the left, or for a posting split), the left high key, and the left half's
+ * prefix bytes; block-1 data carries the right half's prefix bytes.
+ */
+static void
+btree_xlog_split_prefix(XLogReaderState *record, BlockNumber origpagenumber,
+ BlockNumber rightpagenumber, BlockNumber spagenumber,
+ bool newitemonleft)
+{
+ XLogRecPtr lsn = record->EndRecPtr;
+ xl_btree_split *xlrec = (xl_btree_split *) XLogRecGetData(record);
+ Buffer rbuf;
+ Buffer origbuf;
+ Page origpage;
+ BTPageOpaque oopaque;
+ char *datapos;
+ Size datalen;
+ IndexTuple newitem = NULL;
+ IndexTuple left_hikey;
+ IndexTuple right_hikey = NULL;
+ IndexTuple nposting = NULL;
+ Size newitemsz = 0;
+ const char *leftprefix = NULL;
+ const char *rightprefix = NULL;
+ IndexTuple rightitems[MaxIndexTuplesPerPage];
+ int nright = 0;
+ Page rightpage;
+ BTPageOpaque ropaque;
+ XLogRedoAction origaction;
+
+ /*
+ * The right (new) page is rebuilt entirely from data logged with the
+ * record, independent of block 0, because block 0 may be restored from an
+ * automatic full-page image (so its live contents after redo are the left
+ * half, not the pre-split page). Block-1 data is, in order: the right
+ * half's chosen prefix bytes (rightprefixlen), the right high key when the
+ * split is not rightmost (spagenumber != P_NONE), then the right half's
+ * plain data items in offset order (self-describing by IndexTupleSize).
+ */
+ datapos = XLogRecGetBlockData(record, 1, &datalen);
+ if (xlrec->flags & XLH_SPLIT_RFPI)
+ {
+ /*
+ * The right half's plain items would have overflowed the WAL record,
+ * so the primary carried the coded right page as a forced full-page
+ * image. Restore it from that image.
+ */
+ if (XLogReadBufferForRedo(record, 1, &rbuf) != BLK_RESTORED)
+ elog(ERROR, "prefix leaf split right page lacks its full-page image");
+ }
+ else
+ {
+ if (xlrec->rightprefixlen > 0)
+ {
+ rightprefix = datapos;
+ datapos += xlrec->rightprefixlen;
+ datalen -= xlrec->rightprefixlen;
+ }
+ if (spagenumber != P_NONE)
+ {
+ right_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(right_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(right_hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nright < MaxIndexTuplesPerPage);
+ rightitems[nright++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ rbuf = XLogInitBufferForRedo(record, 1);
+ rightpage = BufferGetPage(rbuf);
+ _bt_pageinit(rightpage, BufferGetPageSize(rbuf));
+ ropaque = BTPageGetOpaque(rightpage);
+ ropaque->btpo_prev = origpagenumber;
+ ropaque->btpo_next = spagenumber;
+ ropaque->btpo_level = xlrec->level;
+ ropaque->btpo_flags = BTP_LEAF;
+ ropaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(rightpage, right_hikey, rightitems, nright,
+ rightprefix, xlrec->rightprefixlen))
+ elog(ERROR, "failed to replay right half of a prefix leaf split");
+ PageSetLSN(rightpage, lsn);
+ }
+ MarkBufferDirty(rbuf);
+
+ /*
+ * Rebuild the left (original) page from the pre-split page, unless it was
+ * restored from a full-page image (then it is already the final left
+ * half). Block-0 data is: [newitem (left/posting)] [left high key]
+ * [left prefix]. The left plain items come from the pre-split page.
+ */
+ origaction = XLogReadBufferForRedo(record, 0, &origbuf);
+ origpage = BufferGetPage(origbuf);
+
+ if (origaction == BLK_NEEDS_REDO)
+ {
+ IndexTuple *leftitems;
+ int nleft;
+ OffsetNumber origpagepostingoff = InvalidOffsetNumber;
+ Page leftpage;
+ BTPageOpaque lopaque;
+
+ oopaque = BTPageGetOpaque(origpage);
+
+ datapos = XLogRecGetBlockData(record, 0, &datalen);
+ if (newitemonleft || xlrec->postingoff != 0)
+ {
+ newitem = (IndexTuple) datapos;
+ newitemsz = MAXALIGN(IndexTupleSize(newitem));
+ datapos += newitemsz;
+ datalen -= newitemsz;
+ }
+ left_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(left_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(left_hikey));
+ if (xlrec->leftprefixlen > 0)
+ {
+ leftprefix = datapos;
+ datapos += xlrec->leftprefixlen;
+ datalen -= xlrec->leftprefixlen;
+ }
+ Assert(datalen == 0);
+
+ if (xlrec->postingoff != 0)
+ {
+ ItemId itemid;
+ IndexTuple oposting;
+ BTItemBuf obuf;
+
+ origpagepostingoff = OffsetNumberPrev(xlrec->newitemoff);
+ newitem = CopyIndexTuple(newitem);
+ itemid = PageGetItemId(origpage, origpagepostingoff);
+ oposting = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ oposting = _bt_prefix_decode(origpage, oposting, &obuf);
+ nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff);
+ }
+
+ leftitems = palloc_array(IndexTuple, PageGetMaxOffsetNumber(origpage) + 2);
+ rightitems[0] = NULL; /* not reused below */
+ {
+ IndexTuple dummyright[MaxIndexTuplesPerPage];
+ int dummynr;
+
+ _bt_split_leaf_collect(origpage, xlrec->firstrightoff,
+ xlrec->newitemoff, newitem, newitemonleft,
+ origpagepostingoff, nposting,
+ leftitems, &nleft, dummyright, &dummynr);
+ }
+
+ leftpage = PageGetTempPageCopySpecial(origpage);
+ lopaque = BTPageGetOpaque(leftpage);
+ lopaque->btpo_flags = BTP_LEAF | BTP_INCOMPLETE_SPLIT;
+ lopaque->btpo_prev = oopaque->btpo_prev;
+ lopaque->btpo_next = rightpagenumber;
+ lopaque->btpo_level = xlrec->level;
+ lopaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(leftpage, left_hikey, leftitems, nleft,
+ leftprefix, xlrec->leftprefixlen))
+ elog(ERROR, "failed to replay left half of a prefix leaf split");
+ PageRestoreTempPage(leftpage, origpage);
+ PageSetLSN(origpage, lsn);
+ MarkBufferDirty(origbuf);
+ pfree(leftitems);
+ }
+
+ /* Fix left-link of the page to the right of the new right sibling. */
+ if (spagenumber != P_NONE)
+ {
+ Buffer sbuf;
+
+ if (XLogReadBufferForRedo(record, 2, &sbuf) == BLK_NEEDS_REDO)
+ {
+ Page spage = BufferGetPage(sbuf);
+ BTPageOpaque spageop = BTPageGetOpaque(spage);
+
+ spageop->btpo_prev = rightpagenumber;
+ PageSetLSN(spage, lsn);
+ MarkBufferDirty(sbuf);
+ }
+ if (BufferIsValid(sbuf))
+ UnlockReleaseBuffer(sbuf);
+ }
+
+ UnlockReleaseBuffer(rbuf);
+ if (BufferIsValid(origbuf))
+ UnlockReleaseBuffer(origbuf);
+}
+
static void
btree_xlog_split(bool newitemonleft, XLogReaderState *record)
{
@@ -264,6 +469,18 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
if (!XLogRecGetBlockTagExtended(record, 2, NULL, NULL, &spagenumber, NULL))
spagenumber = P_NONE;
+ /*
+ * A prefix-compressed leaf split is replayed by rebuilding both halves
+ * from the pre-split original page, exactly as the primary did, so the
+ * coded pages come out byte-identical without a full-page image.
+ */
+ if (xlrec->flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ btree_xlog_split_prefix(record, origpagenumber, rightpagenumber,
+ spagenumber, newitemonleft);
+ return;
+ }
+
/*
* Clear the incomplete split flag on the appropriate child page one level
* down when origpage/buf is an internal page (there must have been
@@ -280,7 +497,7 @@ 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 the logged tuples. */
rbuf = XLogInitBufferForRedo(record, 1);
datapos = XLogRecGetBlockData(record, 1, &datalen);
rpage = BufferGetPage(rbuf);
@@ -468,6 +685,56 @@ btree_xlog_dedup(XLogReaderState *record)
BTDedupState state;
BTDedupInterval *intervals;
Page newpage;
+ bool pageprefix; /* source page is BTP_PREFIX */
+ BTItemBuf curbuf; /* decode scratch for the current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
+
+ if (xlrec->prefixlen > 0)
+ {
+ /*
+ * Prefix-dedup: the deduped result was carried as the chosen prefix
+ * bytes, then (unless rightmost) the high key, then the deduped
+ * plain items. Rebuild the coded page byte-for-byte the way the
+ * primary did, using the same layout routine as a prefix leaf split.
+ */
+ Size datalen;
+ char *datapos = XLogRecGetBlockData(record, 0, &datalen);
+ const char *prefix = datapos;
+ IndexTuple hikey = NULL;
+ IndexTuple items[MaxIndexTuplesPerPage];
+ int nitems = 0;
+
+ datapos += xlrec->prefixlen;
+ datalen -= xlrec->prefixlen;
+ if (xlrec->flags & XLH_DEDUP_NOT_RIGHTMOST)
+ {
+ hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(hikey));
+ datalen -= MAXALIGN(IndexTupleSize(hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nitems < MaxIndexTuplesPerPage);
+ items[nitems++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ newpage = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(newpage)->btpo_flags &= ~(BTP_PREFIX | BTP_HAS_GARBAGE);
+ if (!_bt_split_leaf_layout(newpage, hikey, items, nitems,
+ prefix, xlrec->prefixlen))
+ elog(ERROR, "failed to replay prefix deduplication");
+ PageRestoreTempPage(newpage, page);
+ PageSetLSN(page, lsn);
+ MarkBufferDirty(buf);
+ UnlockReleaseBuffer(buf);
+ return;
+ }
state = palloc_object(BTDedupStateData);
state->deduplicate = true; /* unused */
@@ -487,6 +754,19 @@ btree_xlog_dedup(XLogReaderState *record)
maxoff = PageGetMaxOffsetNumber(page);
newpage = PageGetTempPageCopySpecial(page);
+ /*
+ * The source page may be prefix-compressed even on the ordinary
+ * (non-prefix) dedup path: that happens when the primary deduplicated
+ * a prefix page but re-compression of the result declined, leaving a
+ * plain page (prefixlen == 0, no full-page image). Reproduce the
+ * primary: lay the result out plain and decode each coded data item
+ * before feeding it to the dedup state machine. The high key is a
+ * pivot and is never coded.
+ */
+ pageprefix = P_HASPREFIX(opaque);
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
if (!P_RIGHTMOST(opaque))
{
ItemId itemid = PageGetItemId(page, P_HIKEY);
@@ -505,8 +785,18 @@ btree_xlog_dedup(XLogReaderState *record)
ItemId itemid = PageGetItemId(page, offnum);
IndexTuple itup = (IndexTuple) PageGetItem(page, itemid);
+ /* Decode on a prefix source so keys/posting lists are real. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
+ {
+ /* base must survive the whole run; keep it in its own buffer */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
+ }
else if (state->nintervals < xlrec->nintervals &&
state->baseoff == intervals[state->nintervals].baseoff &&
state->nitems < intervals[state->nintervals].nitems)
@@ -516,12 +806,15 @@ btree_xlog_dedup(XLogReaderState *record)
}
else
{
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
Assert(state->nintervals == xlrec->nintervals);
Assert(memcmp(state->intervals, intervals,
state->nintervals * sizeof(BTDedupInterval)) == 0);
@@ -621,11 +914,23 @@ btree_xlog_vacuum(XLogReaderState *record)
if (xlrec->ndeleted > 0)
PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * Mirror the primary: an emptied prefix-compressed leaf drops its
+ * PREFIX item and the BTP_PREFIX flag (see _bt_delitems_vacuum).
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* Clear the vacuum cycle ID, and mark the page as not containing any
* LP_DEAD items
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..ca7e6852542 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,61 @@ 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 format and code).
+ *
+ * A leaf of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page holds one extra item, the PREFIX item, at BTPageGetPrefixOff (where
+ * its first data item would otherwise be); its data items start one offset
+ * later, so P_FIRSTDATAKEY -- which every loop over a page's data items
+ * starts from -- accounts for the flag. The PREFIX item is an
+ * IndexTupleData header (t_tid zero, t_info its size and nothing else, so
+ * that code walking a page's items by IndexTupleSize, such as split redo,
+ * steps over it) followed by 1..BTREE_PREFIX_MAX prefix bytes. The prefix is
+ * chosen only when a page is written whole (CREATE INDEX, or a half of a
+ * split) and never changes after that; a page's high key, which fixes the
+ * prefix item's offset, also changes only in a split.
+ *
+ * On a BTP_PREFIX page, every non-pivot tuple (plain or posting) whose first
+ * key column is not NULL stores that column coded, as a varlena (1-byte
+ * header when it fits one, else 4-byte) whose payload starts with a uint8
+ * `shared':
+ *
+ * shared <= BTREE_PREFIX_MAX: the value's data bytes are the page prefix's
+ * first `shared' bytes followed by the rest of the payload.
+ * The value's own varlena header is recomputed on decode (the
+ * form index_form_tuple gives it: 1-byte when it fits, else
+ * 4-byte uncompressed).
+ * shared == BTREE_PREFIX_RAW: the rest of the payload is the value exactly
+ * as stored, header included. Used for a value compressed
+ * inline, or whose header is not the canonical form above.
+ *
+ * The bytes after the first column (later key columns, INCLUDE columns, the
+ * posting list, and alignment padding) are kept as they are, moved to keep
+ * their alignment, or left out when they are only the key's closing zero
+ * padding (decode puts them back). A posting list follows the key at the new
+ * key end, and the posting offset in t_tid is changed to match. Pivots, NULL
+ * first columns, and the high key are stored as on any other page.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo can repeat them, and decoding gives back the plain
+ * tuple byte for byte.
+ */
+#define BTREE_PREFIX_MAX 126
+#define BTREE_PREFIX_RAW 127 /* shared count of a raw value */
+#define BTREE_PREFIX_SHARED_MASK 0x7F /* shared count bits */
+#define BTREE_PREFIX_REST 0x80 /* bytes after the column follow */
+
+/* A leaf whose tuples share less of its prefix than this, on average, has none. */
+#define BTREE_PREFIX_MIN_SHARED 4
+
+#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 +732,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.
@@ -1122,6 +1208,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 +1224,33 @@ 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))
+
+/*
+ * Size of one half (currTuples or markTuples) of an index-only scan's tuple
+ * storage. A plain leaf's live non-pivot tuples fit in one page, so BLCKSZ
+ * would suffice for them. A prefix-compressed (BTP_PREFIX) leaf can hold more
+ * logical data than fits plain: when its tuples are decoded for the scan they
+ * can exceed BLCKSZ. Because _bt_getitem() decodes based on the page flag,
+ * not on the current reloption, and a BTP_PREFIX page can persist after
+ * prefix_compression is turned off (the flag is cleared only when a page is
+ * split, emptied or dedup-rewritten -- never by ALTER INDEX), ANY btree index
+ * may contain such a page. The buffer must therefore always reserve the slack
+ * for a decoded page, independent of the live reloption; sizing it on the
+ * option would overflow currTuples/markTuples on a surviving prefix leaf.
+ *
+ * The bound is the most distinct index tuples a leaf can hold (each at least a
+ * line pointer plus a minimally coded tuple) times the most a single first
+ * column can regain on decode (the page prefix, at most BTREE_PREFIX_MAX
+ * bytes, MAXALIGNed).
+ */
+#define BTTupleStorageSize(relation) \
+ (BLCKSZ + (BLCKSZ / (int) MAXALIGN(sizeof(IndexTupleData) + 2)) * \
+ (int) MAXALIGN(BTREE_PREFIX_MAX))
/*
* Constant definition for progress reporting. Phase numbers must match
@@ -1202,7 +1316,8 @@ extern bool _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
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 Size _bt_dedup_finish_pending(Page newpage, BTDedupState state,
+ bool overflowok);
extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids,
int nhtids);
extern void _bt_update_posting(BTVacuumPosting vacposting);
@@ -1315,6 +1430,34 @@ 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 void _bt_page_set_prefix(Page page, const char *prefix, Size len);
+extern IndexTuple _bt_prefix_encode(Page page, IndexTuple itup);
+extern Size _bt_prefix_coded_size(Page page, IndexTuple itup);
+extern bool _bt_prefix_enabled(Relation index);
+extern const char *_bt_prefix_candidate(IndexTuple itup, Size *len);
+extern int _bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup);
+extern Size _bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix);
+extern void _bt_prefix_compress_page(Relation index, Page page);
+extern void _bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen);
+extern bool _bt_split_leaf_layout(Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *prefix, Size prefixlen);
+extern void _bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft,
+ OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright);
+
/*
* prototypes for functions in nbtvalidate.c
*/
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..285eac6f1aa 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -156,25 +156,59 @@ 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 */
+ uint16 flags; /* XLH_SPLIT_* below */
+ uint8 leftprefixlen; /* leaf: left half's prefix length (0 none) */
+ uint8 rightprefixlen; /* leaf: right half's prefix length (0 none) */
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, rightprefixlen) + sizeof(uint8))
+
+/*
+ * xl_btree_split flag bits.
+ *
+ * A prefix-compressed leaf split logs no full-page image in the common case:
+ * redo rebuilds each half the way the primary did. The left half is
+ * reconstructed from the pre-split original page (block 0) together with the
+ * logged new item and high key; the right half is reconstructed from its plain
+ * data items, which are logged as block-1 data. Each half's chosen prefix
+ * bytes are logged too (block 0 for the left half, block 1 for the right), and
+ * applied with _bt_split_leaf_layout(); redo cannot run the prefix-choosing
+ * heuristic itself, so the primary logs the prefix it chose.
+ * leftprefixlen/rightprefixlen give the lengths (0 means the half is not
+ * prefix-compressed). XLH_SPLIT_LEAFPREFIX marks a leaf split that uses this
+ * path at all (so redo rebuilds coded rather than replaying items).
+ */
+#define XLH_SPLIT_LEAFPREFIX 0x01 /* leaf split, rebuild halves coded */
+#define XLH_SPLIT_RFPI 0x08 /* right half carried by a full-page image
+ * (its plain items would overflow the WAL
+ * record); redo restores it from the image */
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
* merged together into posting list tuples.
*
- * The WAL record represents a deduplication pass for a leaf page. An array
- * of BTDedupInterval structs follows.
+ * The WAL record represents a deduplication pass for a leaf page. Normally
+ * (prefixlen == 0) an array of BTDedupInterval structs follows and redo
+ * replays them against the old page. When the page is prefix-compressed
+ * (prefixlen > 0) the deduped result is instead carried as the chosen prefix
+ * bytes (prefixlen) followed by, when not rightmost, the high key, then the
+ * deduped plain data items -- each MAXALIGN'd -- and redo lays them out coded
+ * with _bt_split_leaf_layout(), avoiding a full-page image.
*/
typedef struct xl_btree_dedup
{
uint16 nintervals;
+ uint8 prefixlen; /* >0: prefix-dedup payload, see above */
+ uint8 flags; /* XLH_DEDUP_* */
- /* DEDUPLICATION INTERVALS FOLLOW */
+ /* DEDUPLICATION INTERVALS or PREFIX PAYLOAD FOLLOW */
} xl_btree_dedup;
-#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, flags) + sizeof(uint8))
+
+/* xl_btree_dedup flag bits */
+#define XLH_DEDUP_NOT_RIGHTMOST 0x01 /* prefix payload includes a high key */
+#define XLH_DEDUP_RFPI 0x02 /* prefix page carried as a forced image */
/*
* This is what we need to know about page reuse within btree. This record
diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile
index 20a1ca6a92b..ca0779e9b57 100644
--- a/src/test/modules/nbtree/Makefile
+++ b/src/test/modules/nbtree/Makefile
@@ -3,7 +3,8 @@
EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck
REGRESS = nbtree_half_dead_pages \
- nbtree_incomplete_splits
+ nbtree_incomplete_splits \
+ nbtree_prefix_rfpi
ISOLATION = backwards-scan-concurrent-splits \
predicate-empty-index
diff --git a/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
new file mode 100644
index 00000000000..1614ad0186b
--- /dev/null
+++ b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
@@ -0,0 +1,77 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+ bt_index_parent_check
+-----------------------
+
+(1 row)
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+ forced_rfpi_ok
+----------------
+ t
+(1 row)
+
+reset enable_seqscan;
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+ injection_points_detach
+-------------------------
+
+(1 row)
+
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build
index b5dc026392e..d6a34e265be 100644
--- a/src/test/modules/nbtree/meson.build
+++ b/src/test/modules/nbtree/meson.build
@@ -12,6 +12,7 @@ tests += {
'sql': [
'nbtree_half_dead_pages',
'nbtree_incomplete_splits',
+ 'nbtree_prefix_rfpi',
],
},
'isolation': {
diff --git a/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
new file mode 100644
index 00000000000..45899e5796f
--- /dev/null
+++ b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
@@ -0,0 +1,59 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+reset enable_seqscan;
+
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out
index 7c53d598649..c1dae4b55ad 100644
--- a/src/test/regress/expected/btree_index.out
+++ b/src/test/regress/expected/btree_index.out
@@ -891,3 +891,259 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql
index 6990d7818da..6cd053a1596 100644
--- a/src/test/regress/sql/btree_index.sql
+++ b/src/test/regress/sql/btree_index.sql
@@ -520,3 +520,170 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b964e5ee907..c3fb56802f6 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,7 @@ BTPageStat
BTPageState
BTParallelScanDesc
BTPendingFSM
+BTPrefixPlan
BTReadPageState
BTScanInsert
BTScanInsertData
--
2.50.1
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-29 16:42 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-10-30 11:52 ` Yao Feng <fengyao0087@gmail.com>
2026-10-30 15:28 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-11-04 18:08 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
0 siblings, 2 replies; 12+ messages in thread
From: Yao Feng @ 2026-10-30 11:52 UTC (permalink / raw)
To: Greg Burd <greg@burd.me>; +Cc: pgsql-hackers@lists.postgresql.org; pg@bowt.ie
Hi Greg,
Thanks for working on this, took a look at the v4 patch.
I think this is very worthwhile work. FWIW, a recent SIGMOD paper on
B-tree compression (https://dl.acm.org/doi/10.1145/3654972) noted that
while front-coding saves a lot of space for strings, decompression CPU cost
tends to be the main bottleneck for queries on modern hardware. Looking at
the v4 benchmarks, they mostly cover index size and write throughput -- did
you have a chance to measure read-side performance (e.g. index scans, point
lookups)?
A few quick questions from having played with something similar a while
back:
1. Skipping decompression when index tuples aren't returned:
Right now _bt_readpage() eagerly calls _bt_getitem() to decode every leaf
tuple. That's necessary when the scan actually needs to return the index
tuple (like IndexOnlyScan), but for operations that don't return tuples and
only care about the heap TID -- such as a plain IndexScan, BitmapIndexScan,
or COUNT(*) -- could we skip decompression?
2. Multi-column indexes:
The patch requires column 0 to be a varlena. I'm curious if you considered
supporting subsequent columns (e.g. for something like `(tenant_id int, url
text)`), or if keeping it strictly to the first column was just a natural
place to draw the line on complexity for now?
3. Prefix boundaries within a page:
With one prefix per page, if a page spans two different domains (say, a
bunch of `http://example.com/1` <http://example.com/1;, `.../2` followed by
`http://sample.org/1` <http://sample.org/1;, `.../2`), the second group
only shares "http://";. Just curious if you've considered this kind of case?
Curious to hear your thoughts.
--
Yao Feng
Greg Burd <greg@burd.me> 于2026年10月30日周五 00:43写道:
> Hello,
>
> v4 attached, rebased on current master (cfbot had gone stale on a
> not-applying from the v3 window; the patch applies clean on HEAD, so
> this mostly just gives the bot a fresh version to chew on). No code
> changes from v3.
>
> I did say I'd run more benchmarks for fun, so here they are. Release
> builds (cassert=off), master vs the patch, c7i.4xlarge, fsync and
> synchronous_commit off so the WAL numbers aren't buried under flush
> latency. "off" is the patch with prefix_compression left off, to show
> the default path is free.
>
> The headline is that the size win shows up where the keys are actually
> distinct-but-prefixed, which is the case the feature is for. One row at
> a time into an existing URL index (37-byte shared prefix, distinct
> suffixes):
>
> workload index pages (master -> on) WAL/txn (master -> on)
> ---------------- ------------------------- ----------------------
> insert_random 22177 -> 7506 (-66%) 292 -> 226 bytes
> insert_ascending 19036 -> 5429 (-71%) 277 -> 227 bytes
>
> TPS is within noise either way (random 138k -> 136k, ascending 134k ->
> 131k). WAL per insert drops because a coded tuple is smaller than the
> plain one it replaces. The split/dedup redo rework from the earlier
> versions is what keeps that from turning into a full-page-image tax.
>
> CREATE INDEX on 2M rows is a more mixed bag, because how much a prefix
> buys depends entirely on the key shape:
>
> shape index pages (master -> on) build WAL (master ->
> on)
> ------------------ -------------------------
> ------------------------
> url (g % 1e6) 10531 -> 9538 (-9%) 80 -> 48 MB
> code (ACCT-...) 7140 -> 6495 (-9%) 48 -> 32 MB
> md5 (random hex) 14420 -> 13026 (-10%) 99 -> 109 MB
> jsonb (tenant+path) 11636 -> 10657 (-8%) 71 -> 28 MB
> int (control) 5487 -> 5487 (0%) 32 -> 32 MB
>
> Two things worth calling out there. The build-time size wins look
> smaller than the insert ones only because these build shapes use lots of
> near-duplicate keys (g % 1e6) with a short shared run; deduplication
> already collapses most of that, so prefix coding is adding a few percent
> on top rather than doing the heavy lifting.
>
> The insert workload above, with genuinely distinct keys, is the fairer
> picture of what the feature does. And the int control is exactly
> unchanged. A page whose keys share nothing is left alone, so there's no
> cost to carrying the option on a table where it doesn't pay.
>
> md5 is the one that surprised me a little: even random hex strings share
> enough of a leading nibble run across a page to shave ~10% off, though
> the build WAL ticked up slightly there (109 vs 99 MB), which I want to
> look at. Probably the recompression churn on a shape that barely
> benefits. Not a blocker, but I'd rather understand it than wave it off.
>
> Update churn (indexUnchanged UPDATEs, 8 clients) is within noise on TPS
> and WAL, and the index stays flat at 186 pages before and after on all
> three variants, which is bottom-up deletion doing its job on coded pages.
>
> Everything else stands from v3.
>
> best.
>
> -greg
>
>
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-29 16:42 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-30 11:52 ` Re: nbtree: opt-in leaf key prefix compression Yao Feng <fengyao0087@gmail.com>
@ 2026-10-30 15:28 ` Greg Burd <greg@burd.me>
2026-11-03 17:48 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
1 sibling, 1 reply; 12+ messages in thread
From: Greg Burd @ 2026-10-30 15:28 UTC (permalink / raw)
To: fengyao0087@gmail.com; +Cc: pgsql-hackers@lists.postgresql.org; pg@bowt.ie
On Fri, 30 Oct 2026 19:52:00 +0800, Yao Feng <fengyao0087@gmail.com> wrote:
> Hi Greg,
Hey Yao,
Quick list-etiquette note, don't top post. Add your reply within the
content of the message or after it. :)
> Thanks for working on this, took a look at the v4 patch.
Thanks for taking the time to read and review it! :)
> I think this is very worthwhile work.
Thanks, so do I.
> FWIW, a recent SIGMOD paper on
> B-tree compression (https://dl.acm.org/doi/10.1145/3654972) noted that
> while front-coding saves a lot of space for strings, decompression CPU cost
> tends to be the main bottleneck for queries on modern hardware. Looking at
> the v4 benchmarks, they mostly cover index size and write throughput -- did
> you have a chance to measure read-side performance (e.g. index scans, point
> lookups)?
Thanks for the careful read, and for the SIGMOD pointer. That
decompression-CPU-dominates finding matches what I see below, and it's
exactly the right thing to point out, these kinds of changes are
trade-offs. Rarely is there a free win. Yep, you're right that v4 only
reported size and write throughput, I should have considered CPU
overhead as an important metric earlier in my process. So, I went and
measured the read side to see where it bites.
Read-side numbers:
Release build, c7i.4xlarge, shared_buffers=4GB, everything warm, 3M
distinct URL keys (37-byte shared prefix, 10-digit distinct suffix),
prefix_compression off vs on in the same binary so this isolates the
decode cost, nothing else:
index size off 24855 pages on 22630 pages (-9%)
point lookup (IOS) off 180.3k tps on 178.3k tps (~1%, noise)
COUNT(*) index-only off 341 ms on 360 ms (+6%)
range scan, 100k keys off 10.9 ms on 11.7 ms (+7%)
So: a point lookup decodes one tuple and the cost vanishes into the
noise, but a scan that walks many tuples pays a real ~6-7%. That lines
up with the paper and with your instinct (that, the cost is proportional
to how many tuples you decode) so it shows up on traversal, not on
seeks. This is also with a fairly short 37-byte prefix; a longer shared
prefix decodes more bytes per tuple and would cost a bit more, which I
I'll guess grows with the size of the prefix, but I should measure and not guess.
Which leads straight into your question 1.
1. Skipping decode when the tuple isn't returned
This is the right instinct, and the optimization makes sense. The heap
TID lives in t_tid, which is never coded, so a scan that only needs the
TID (e.g. a plain IndexScan, BitmapIndexScan, COUNT(*)) genuinely does
not need the decoded key to *return* anything.
Two honest caveats on how far it reaches, though. The tuple copy is
already skipped: _bt_saveitem() only materializes into so->currTuples
when the scan asked for the index tuple (IndexOnlyScan), so a plain
IndexScan already doesn't pay the copy. What my eager _bt_getitem() in
_bt_readpage() still pays for is the *key check*, _bt_checkkeys() needs
the real key to decide whether a tuple satisfies the scan keys, and
that's true coded or not.
So the win is specifically only when a tuple is in the interior of a
matching range (the scan keys are already known-satisfied and we're not
returning the tuple), the decode feeds nothing.
That's exactly the COUNT(*) / full-range case above, which is where the
6% sits, so it's worth doing. I just don't want to claim it makes all
scans free, only the "walking a known-matching run without returning it"
ones.
I'll prototype and measure it as a follow-up in this thread. The "do I
need the key here?" signal (roughly, so->currTuples != NULL, or
key-check still required) into _bt_readpage() and keep the item coded
when neither holds.
2. Multi-column indexes
First-column-only was a deliberate line, not a fundamental limit. The
coding is defined on the leading key column's bytes; extending it to a
shared prefix that spans into later columns is possible but means the
per-page prefix has to describe a column boundary and the decode has to
reassemble a multi-attribute tuple, which is more format and more decode
CPU for (I suspect) a smaller marginal win than the first column already
captures. For (tenant_id int, url text) specifically the leading int is
fixed-width and usually low-cardinality, so you'd want to prefix-code
the *second* column within each leading-value run, which is really a
different feature (per-run prefixes, see 3). I'd rather land the
first-column case, which is the common shape (a single text/jsonb key,
or a leading key that's the long one), and treat multi-column as a
follow-on proposal when/if this lands. But I'm adding it to my list
because I'm optimistic on this patch.
3. Prefix boundaries within a page
Yes, this is the real limitation of one-prefix-per-page, and you have
hit on the right worst case: a page spanning example.com and sample.org
only gets to factor out "http://";. _bt_prefix_choose() picks the single
longest prefix common to all data items on the page, so a page
straddling two groups keeps only what both share.
Two things soften it in practice but don't remove it: the keys are
sorted, so a straddling page is the boundary page between two runs. Most
pages sit entirely inside one run and get the full prefix. Also a page
that compresses poorly is simply left uncompressed (the int control in
the v4 numbers), so a bad straddle costs nothing rather than bloating.
The actual fix is multiple prefixes per page (a small prefix directory,
each data item pointing at its group's prefix), which is a natural v2 of
the on-disk format and also what would make the multi-column case in (2)
fall out.
I deliberately kept v4 to one prefix to get the format and the
WAL/replay story reviewed before adding that complexity. I think it's
the right thing to consider building next if the basic version is judged
commit-worthy, at minimum to benchmark it and see if it makes a
difference or not.
Here's a wild idea for another day: if the zstd-dict thread [1] lands or
the work from the thread started by Hannu [2] I might give adding
dictionary-based compression to the prefix compression algorithm and
measure that. Who knows...
I'll fold the read-side numbers into the next revision and prototype the
skip-decode path.
Thanks again, these were the right questions.
best.
-greg
[1] https://postgr.es/m/CAMT0RQQ+7bhiM9FtrZ7cdWxxFR=jtQCq6poDPs75oJuGMDdPKg@mail.gmail.com
[2] https://postgr.es/m/CAMT0RQQtJprWKdK4L_wsD++aQaQ972kRdGfGA+su3JfFdYuEmA@mail.gmail.com
Attachments:
v5-0001-nbtree-opt-in-leaf-key-prefix-compression.patch (text/plain, 170.0K) download
From b4750942e5ecdd8f7c625b0ab16706e322d7cea0 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 15:25:58 -0400
Subject: [PATCH v5] nbtree: opt-in leaf key prefix compression
Add leaf key prefix compression to nbtree, controlled by a new
per-index storage parameter, prefix_compression, that is off by default.
When a leaf page is written as a whole -- during an index build, as a
split half, or as the result of a deduplication pass -- and its data
items share a leading byte-prefix of their first key column, the page is
rebuilt in the BTP_PREFIX format: a single PREFIX item after the high
key holds the shared bytes, and each data item is stored "coded" (its
copy of the shared prefix replaced by a one-byte length). Reads of a
leaf data item funnel through _bt_getitem(), which decodes on demand;
high keys and internal-page downlinks are never coded and are read with
the existing PageGetItem().
The feature is deliberately a per-page opt-in with no on-disk version
change: BTREE_VERSION and the catalog version are untouched. A page is
converted only when it is next written whole, never by an insert, VACUUM
or key-range change, so compression "faults forward" on an existing
index. Old-format and new-format pages coexist in the same index, which
keeps pg_upgrade, physical replication, and standbys working with no
REINDEX: a replica that has not yet seen a page's conversion still reads
it correctly, and turning the parameter off (ALTER INDEX ... SET) stops
new conversions while leaving existing coded pages readable.
Prefix compression is a separate mechanism from deduplication, not an
extension of it: deduplication collapses tuples whose whole key is
equal, while prefix compression factors out a leading run shared by
distinct keys (a URL or jsonb-path column is the motivating case, where
deduplication does not help). The two compose on one page -- a build
deduplicates first and then codes the survivors, so a duplicate-heavy
prefix index is no larger than a plain one. Bottom-up index deletion
runs on coded pages unchanged, since it only removes items. A runtime
deduplication pass on a coded page decodes first, and because a coded
page can hold more logical data than fits plain, the pass aborts cleanly
and the page splits instead when the decoded set would overflow.
WAL replay reproduces the coded bytes without resorting to full-page
images in the common paths. A Relation-free layout routine,
_bt_split_leaf_layout(), together with a single item-collection routine,
_bt_split_leaf_collect(), are shared by the primary (_bt_split) and redo
(btree_xlog_split) so both derive the same plain item sequence and code
it against the same logged prefix, emitting identical bytes. An
in-place insert logs the coded tuple; a split logs each half's chosen
prefix plus the right half's plain items; a prefix deduplication logs
the chosen prefix plus the deduped plain items. A bounded fallback
carries the page as a forced full-page image only when a pathologically
compressible page's plain payload would exceed one block; realistic data
never reaches that threshold, so the nbtree-prefix-force-rfpi injection
point and a dedicated test in src/test/modules/nbtree exercise the
fallback deterministically.
amcheck decodes every leaf data item, including under heapallindexed,
and checks the BTP_PREFIX structural invariants (flag only on leaf
pages, a well-formed PREFIX item at the expected offset); its test suite
covers a prefix-compressed index. The create_index documentation and
the nbtree README describe the parameter, the on-disk format, and the
relationship to deduplication. Tested with wal_consistency_checking=all
on a primary and a streaming standby across insert, split, deduplication
(including the forced-image fallback), delete and vacuum workloads,
through crash recovery, and across a pg_upgrade that carries both page
formats.
---
contrib/amcheck/expected/check_btree.out | 27 +
contrib/amcheck/sql/check_btree.sql | 18 +
contrib/amcheck/verify_nbtree.c | 120 ++-
doc/src/sgml/ref/create_index.sgml | 35 +
src/backend/access/common/reloptions.c | 10 +
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 97 +++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 266 +++++-
src/backend/access/nbtree/nbtinsert.c | 375 ++++++--
src/backend/access/nbtree/nbtpage.c | 31 +-
src/backend/access/nbtree/nbtprefix.c | 814 ++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 23 +-
src/backend/access/nbtree/nbtree.c | 6 +-
src/backend/access/nbtree/nbtsearch.c | 3 +-
src/backend/access/nbtree/nbtsort.c | 54 +-
src/backend/access/nbtree/nbtsplitloc.c | 84 +-
src/backend/access/nbtree/nbtutils.c | 4 +-
src/backend/access/nbtree/nbtxlog.c | 313 ++++++-
src/include/access/nbtree.h | 147 +++-
src/include/access/nbtxlog.h | 44 +-
src/test/modules/nbtree/Makefile | 3 +-
.../nbtree/expected/nbtree_prefix_rfpi.out | 77 ++
src/test/modules/nbtree/meson.build | 1 +
.../modules/nbtree/sql/nbtree_prefix_rfpi.sql | 59 ++
src/test/regress/expected/btree_index.out | 256 ++++++
src/test/regress/sql/btree_index.sql | 167 ++++
src/tools/pgindent/typedefs.list | 2 +
28 files changed, 2923 insertions(+), 115 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
create mode 100644 src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
create mode 100644 src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
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..18551483e88 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -158,7 +158,12 @@ typedef struct BtreeLastVisibleEntry
OffsetNumber offset; /* Offset on index block */
int postingIndex; /* Number in the posting list (-1 for
* non-deduplicated tuples) */
- ItemPointer tid; /* Heap tid */
+ ItemPointer tid; /* Heap tid, or NULL; points at tiddata */
+ ItemPointerData tiddata; /* Own storage for *tid: a prefix-coded leaf
+ * tuple is decoded into a scratch buffer that
+ * is reused on the next loop iteration, so we
+ * must keep the heap TID by value, not point
+ * into the (transient) decoded tuple */
} BtreeLastVisibleEntry;
/*
@@ -935,7 +940,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
lVis->postingIndex = i;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
}
}
}
@@ -961,7 +967,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
lVis->postingIndex = -1;
}
}
@@ -1229,6 +1236,16 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber max;
BTPageOpaque topaque;
+ /*
+ * Scratch space for decoding prefix-coded leaf tuples (_bt_getitem).
+ * itupbuf backs the current loop item; crossbuf backs the right sibling's
+ * first item in the cross-page unique check, which must coexist with the
+ * current item's state. A pivot, high key or non-prefix leaf item is
+ * returned by _bt_getitem unchanged (buffer unused).
+ */
+ BTItemBuf itupbuf;
+ BTItemBuf crossbuf;
+
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1266,6 +1283,54 @@ bt_target_page_check(BtreeCheckState *state)
}
}
+ /*
+ * Structural checks for a prefix-compressed (BTP_PREFIX) leaf. A reader
+ * trusts this format implicitly, so validate the parts that an out-of-
+ * place decode would otherwise read as garbage: the flag appears only on
+ * leaf pages, and the PREFIX item sits at the expected offset with a
+ * length in the legal range. The per-item key/content checks below run
+ * on the decoded tuple from _bt_getitem, which fails on a malformed coded
+ * item, so item-level coding is covered there.
+ */
+ if (P_HASPREFIX(topaque))
+ {
+ ItemId pitemid;
+ IndexTuple pitup;
+ Size prefixlen;
+ OffsetNumber prefixoff = BTPageGetPrefixOff(topaque);
+
+ if (!P_ISLEAF(topaque))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix compression flag set on non-leaf page in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u page lsn=%X/%08X.",
+ state->targetblock,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ if (prefixoff > max)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix-compressed page has no PREFIX item in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix offset=%u max offset=%u page lsn=%X/%08X.",
+ state->targetblock, prefixoff, max,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ pitemid = PageGetItemIdCareful(state, state->targetblock,
+ state->target, prefixoff);
+ pitup = (IndexTuple) PageGetItem(state->target, pitemid);
+ prefixlen = IndexTupleSize(pitup) - sizeof(IndexTupleData);
+ if (prefixlen < 1 || prefixlen > BTREE_PREFIX_MAX)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("PREFIX item has invalid length in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix length=%zu page lsn=%X/%08X.",
+ state->targetblock, prefixlen,
+ 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"
@@ -1293,8 +1358,15 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, state->targetblock,
state->target, offset);
- itup = (IndexTuple) PageGetItem(state->target, itemid);
- tupsize = IndexTupleSize(itup);
+
+ /*
+ * lp_len and the tuple size limit are physical properties of the item
+ * as stored, so they are checked against the raw on-page tuple (coded,
+ * on a BTP_PREFIX leaf). Every key/content check below instead uses
+ * the decoded tuple returned by _bt_getitem.
+ */
+ tupsize = IndexTupleSize((IndexTuple) PageGetItem(state->target, itemid));
+ itup = _bt_getitem(state->target, offset, &itupbuf);
/*
* lp_len should match the IndexTuple reported length exactly, since
@@ -1606,7 +1678,7 @@ 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), &itupbuf);
tid = BTreeTupleGetPointsToTID(itup);
nhtid = psprintf("(%u,%u)",
ItemPointerGetBlockNumberNoCheck(tid),
@@ -1793,7 +1865,7 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, rightblock_number,
rightpage,
rightfirstoffset);
- itup = (IndexTuple) PageGetItem(rightpage, itemid);
+ itup = _bt_getitem(rightpage, rightfirstoffset, &crossbuf);
bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis);
@@ -1857,6 +1929,8 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
BlockNumber targetnext;
Page rightpage;
OffsetNumber nline;
+ OffsetNumber rightoffset;
+ BTItemBuf firstbuf;
/* Determine target's next block number */
opaque = BTPageGetOpaque(state->target);
@@ -2016,6 +2090,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/* Return first data item (if any) */
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
P_FIRSTDATAKEY(opaque));
+ rightoffset = P_FIRSTDATAKEY(opaque);
*rightfirstoffset = P_FIRSTDATAKEY(opaque);
}
else if (!P_ISLEAF(opaque) &&
@@ -2027,6 +2102,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
*/
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
OffsetNumberNext(P_FIRSTDATAKEY(opaque)));
+ rightoffset = OffsetNumberNext(P_FIRSTDATAKEY(opaque));
}
else
{
@@ -2046,9 +2122,19 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/*
* Return first real item scankey. Note that this relies on right page
* memory remaining allocated.
+ *
+ * On a BTP_PREFIX leaf the first data item is prefix-coded, so decode it;
+ * because the returned scankey (built by _bt_mkscankey) references the
+ * tuple's own storage, and _bt_getitem decodes into a buffer local to
+ * this function, copy the decoded tuple into the caller-reset memory
+ * context so it outlives the call. A pivot or non-prefix item is
+ * returned by _bt_getitem unchanged (it lives in the right page, which
+ * remains allocated), so no copy is needed then.
*/
- firstitup = (IndexTuple) PageGetItem(rightpage, rightitem);
- return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ firstitup = _bt_getitem(rightpage, rightoffset, &firstbuf);
+ if (firstitup == (IndexTuple) PageGetItem(rightpage, rightitem))
+ return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ return bt_mkscankey_pivotsearch(state->rel, CopyIndexTuple(firstitup));
}
/*
@@ -3095,14 +3181,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);
@@ -3122,11 +3207,12 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key,
{
BTPageOpaque topaque;
IndexTuple ritup;
+ BTItemBuf ritupbuf;
int uppnkeyatts;
ItemPointer rheaptid;
bool nonpivot;
- ritup = (IndexTuple) PageGetItem(state->target, itemid);
+ ritup = _bt_getitem(state->target, upperbound, &ritupbuf);
topaque = BTPageGetOpaque(state->target);
nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque);
@@ -3218,14 +3304,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 */
@@ -3236,12 +3321,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key,
if (cmp == 0)
{
IndexTuple child;
+ BTItemBuf childbuf;
int uppnkeyatts;
ItemPointer childheaptid;
BTPageOpaque copaque;
bool nonpivot;
- child = (IndexTuple) PageGetItem(nontarget, itemid);
+ child = _bt_getitem(nontarget, upperbound, &childbuf);
copaque = BTPageGetOpaque(nontarget);
nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque);
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..7e66765c3d3 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -477,6 +477,41 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
</note>
</listitem>
</varlistentry>
+
+ <varlistentry id="index-reloption-prefix-compression" xreflabel="prefix_compression">
+ <term><literal>prefix_compression</literal> (<type>boolean</type>)
+ <indexterm>
+ <primary><varname>prefix_compression</varname> storage parameter</primary>
+ </indexterm>
+ </term>
+ <listitem>
+ <para>
+ Controls leaf key prefix compression. When set to
+ <literal>ON</literal>, and the index's first key column is of a
+ variable-length type, a leaf page may store a shared prefix of that
+ column once and record only each tuple's unshared remainder, which can
+ substantially shrink indexes on keys that share long leading bytes
+ (for example URLs or <type>jsonb</type> keys with common paths). Set
+ to <literal>ON</literal> or <literal>OFF</literal> to enable or disable
+ the optimization. The default is <literal>OFF</literal>.
+ </para>
+
+ <note>
+ <para>
+ 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 adopts a prefix only when
+ it is written as a whole (during an index build, or either half of a
+ page split), so turning <literal>prefix_compression</literal> on or off
+ via <command>ALTER INDEX</command> does not rewrite the index: existing
+ pages are left as they are and are converted the next time they are
+ written whole. No <command>REINDEX</command> is required, and indexes
+ upgraded with <application>pg_upgrade</application> keep working
+ unchanged.
+ </para>
+ </note>
+ </listitem>
+ </varlistentry>
</variablelist>
<para>
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..596e595f20b 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1081,3 +1081,100 @@ 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
+front-code the first key column of each of its data tuples against a per-page
+prefix, which shrinks indexes on keys that share long leading bytes (URLs,
+jsonb keys with common paths, zero-padded codes). Like deduplication, this
+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.
+
+A leaf that uses the technique carries the BTP_PREFIX page flag and one extra
+item, the PREFIX item, 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. The PREFIX item is an
+IndexTupleData header followed by 1..BTREE_PREFIX_MAX prefix bytes, shaped so
+that code which walks a page's items by IndexTupleSize (such as
+_bt_restore_page) steps over it. On such a page every non-pivot tuple (plain
+or posting) whose first key column is not NULL stores that column as a varlena
+whose payload begins with a one-byte count of how many leading bytes it shares
+with the page prefix, followed by the unshared remainder (or, for a value whose
+header is not the form index_form_tuple would reproduce, the whole value after
+a sentinel count). Trailing key columns, INCLUDE columns, a posting list and
+alignment padding follow, moved to preserve alignment; padding that is only the
+key's closing zeros is dropped and restored on decode. Pivots, NULL first
+columns and the high key are always stored uncompressed. Coding and decoding
+depend only on the page prefix and the tuple, never the catalog, so WAL redo
+reproduces them and decoding yields the original tuple byte for byte. All of
+nbtprefix.c implements this; _bt_getitem() is the one accessor through which
+readers obtain a decoded leaf data tuple.
+
+A page adopts a prefix only when it is written as a whole: a finished leaf
+during an index build, or either half of a leaf page split (built coded from
+the start by _bt_split_leaf_half, since a compressed leaf can hold more logical
+data than fits on a plain page, choosing each half's own prefix and falling
+back to a plain layout or the original page's prefix if its own does not fit).
+Crucially, no insert, VACUUM, deduplication or change of a page's key range
+ever rewrites a page to keep its prefix valid: a tuple that happens to share
+fewer bytes simply records a smaller shared count. 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 physical/logical replication keep
+working and existing indexes continue to work with no REINDEX. Turning the
+parameter on with ALTER INDEX does not rewrite anything; 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 (the flag is cleared
+only when a page is split, emptied or dedup-rewritten) and are still read back
+correctly with the parameter off, since every reader decodes based on the page
+flag, not on the current reloption. Scan-time workspace that must hold decoded
+tuples (the index-only-scan currTuples/markTuples buffers, see
+BTTupleStorageSize) is therefore sized for a decoded page on every btree index,
+not just those whose parameter is currently on.
+
+Leaf changes to a prefix page are replayed without a full-page image in the
+common case. Redo cannot run the prefix-choosing heuristic (it has no
+relcache), so the primary logs the prefix it chose and both sides lay the
+page out through one Relation-free routine, _bt_split_leaf_layout(), fed by
+one item-collection routine, _bt_split_leaf_collect(), that the primary
+(_bt_split) and redo (btree_xlog_split) both call. Because the two derive
+the plain item sequence the same way and code it against the same logged
+prefix, they produce byte-identical pages. An in-place insert logs the coded
+tuple (standard insert redo adds it verbatim); a split logs each half's
+chosen prefix plus the right half's plain items; a deduplication logs the
+chosen prefix plus the deduped plain items. A bounded fallback forces a
+full-page image only when a pathologically compressible page's plain payload
+would exceed BLCKSZ (so the logged payload can never exceed one block); redo
+then restores the image via BLK_RESTORED. When VACUUM or page deletion
+empties a prefix leaf, the PREFIX item is removed and the flag cleared so that
+the invariant that an emptied leaf holds only its high key (relied on by page
+deletion) still holds.
+
+Deduplication and bottom-up index deletion both run on BTP_PREFIX pages.
+Bottom-up deletion only removes items and never rewrites survivors, so it is
+safe on coded pages unchanged. A deduplication pass decodes the page's items,
+merges duplicates into posting lists and re-compresses the result as a whole;
+because a coded page can hold more logical data than fits plain, if the
+decoded set would overflow a plain page the pass aborts cleanly and the page
+splits instead, so no in-place rewrite is ever forced to fail. An index build
+deduplicates before coding the finished page, so a duplicate-heavy prefix
+index is no larger than a plain one.
+
+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 byte 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 (deduplicate first, then code the
+survivors), which is why a build applies both. Reusing the posting-list
+format to carry a shared prefix was considered and rejected: a posting list
+stores one key plus many TIDs, whereas prefix coding stores many keys that
+share a prefix, so the two would need different per-item layouts anyway, and
+overloading posting lists would entangle the deduplication and compression
+paths that are kept separate here on purpose.
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..12aa252aa05 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -19,6 +19,7 @@
#include "access/tableam.h"
#include "access/xloginsert.h"
#include "miscadmin.h"
+#include "utils/injection_point.h"
#include "utils/rel.h"
static void _bt_bottomupdel_finish_pending(Page page, BTDedupState state,
@@ -69,7 +70,19 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Size pagesaving PG_USED_FOR_ASSERTS_ONLY = 0;
bool singlevalstrat = false;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
XLogRecPtr recptr;
+ IndexTuple *dedup_plain = NULL; /* deduped plain items, for prefix WAL */
+ int dedup_nplain = 0;
+ IndexTuple dedup_hikey = NULL;
+ char dedup_prefix[BTREE_PREFIX_MAX];
+ Size dedup_prefixlen = 0;
+ bool dedup_islogprefix = false; /* page ended up prefix-coded */
+ char *dedup_walbuf = NULL; /* packed prefix-dedup WAL payload */
+ Size dedup_walbuflen = 0;
+ bool dedup_rfpi = false; /* fall back to a forced full-page image */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -120,7 +133,16 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
newpage = PageGetTempPageCopySpecial(page);
PageSetLSN(newpage, PageGetLSN(page));
- /* Copy high key, if any */
+ /*
+ * The rebuilt page is laid out plain here (clear any inherited BTP_PREFIX;
+ * PageGetTempPageCopySpecial copies the special area but not the PREFIX
+ * item). If the page was prefix-compressed it is re-compressed as a whole
+ * below, after deduplication, honouring Strategy 1.
+ */
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
+ /* Copy high key, if any (a pivot, stored plain on any page) */
if (!P_RIGHTMOST(opaque))
{
ItemId hitemid = PageGetItemId(page, P_HIKEY);
@@ -140,12 +162,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Assert(!ItemIdIsDead(itemid));
+ /* Decode on a prefix page so keys/posting lists are the real ones. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/*
* No previous/base tuple for the data item -- use the data item
- * as base tuple of pending posting list
+ * as base tuple of pending posting list. On a prefix page the
+ * base must survive the whole merge run (it is kept as a pointer
+ * in state and read by _bt_dedup_finish_pending), so keep it in a
+ * dedicated buffer rather than the per-iteration curbuf.
*/
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (state->deduplicate &&
@@ -170,7 +202,20 @@ _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);
+ /*
+ * On a prefix page the deduped items are laid out plain here; the
+ * decoded set can exceed one page even when the coded form fit, so
+ * tolerate an overflow and abort the whole pass (the caller then
+ * splits the page instead of deduplicating it).
+ */
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state,
+ pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
if (singlevalstrat)
{
@@ -198,12 +243,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
}
/* itup starts new pending posting list */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page,
+ (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
/* Handle the last item */
- pagesaving += _bt_dedup_finish_pending(newpage, state);
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state, pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
/*
* If no items suitable for deduplication were found, newpage must be
@@ -238,6 +293,110 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
nopaque->btpo_flags &= ~BTP_HAS_GARBAGE;
}
+ /*
+ * Re-apply prefix compression to the rebuilt (plain) page as a whole
+ * (Strategy 1), before the critical section since it allocates a scratch
+ * page. First capture the deduped plain items and high key: if the page
+ * does become prefix-compressed, redo rebuilds its coded bytes from these
+ * plus the chosen prefix (via _bt_split_leaf_layout), the same way a prefix
+ * leaf split is replayed -- no full-page image needed.
+ */
+ if (pageprefix)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(newpage);
+ OffsetNumber nmin = P_FIRSTDATAKEY(nopq);
+ OffsetNumber nmax = PageGetMaxOffsetNumber(newpage);
+
+ dedup_nplain = (nmax >= nmin) ? (int) nmax - (int) nmin + 1 : 0;
+ if (dedup_nplain > 0)
+ {
+ dedup_plain = palloc(dedup_nplain * sizeof(IndexTuple));
+ for (int i = 0; i < dedup_nplain; i++)
+ dedup_plain[i] = CopyIndexTuple((IndexTuple)
+ PageGetItem(newpage,
+ PageGetItemId(newpage, nmin + i)));
+ }
+ if (!P_RIGHTMOST(nopq))
+ {
+ ItemId hii = PageGetItemId(newpage, P_HIKEY);
+
+ dedup_hikey = CopyIndexTuple((IndexTuple) PageGetItem(newpage, hii));
+ }
+
+ _bt_prefix_compress_page(rel, newpage);
+
+ /* Did it actually compress? (It gives up if a coded item won't fit.) */
+ if (P_HASPREFIX(BTPageGetOpaque(newpage)))
+ {
+ const char *pfx = _bt_page_get_prefix(newpage, &dedup_prefixlen);
+
+ if (dedup_prefixlen > 0)
+ memcpy(dedup_prefix, pfx, dedup_prefixlen);
+ dedup_islogprefix = true;
+ }
+ }
+
+ /*
+ * Pack the prefix-dedup WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the chosen prefix bytes, then the high
+ * key (unless rightmost), then the deduped plain items, each MAXALIGN'd,
+ * in one contiguous buffer logged with a single rdata slot.
+ */
+ if (dedup_islogprefix)
+ {
+ Size off = dedup_prefixlen;
+
+ if (dedup_hikey != NULL)
+ off += MAXALIGN(IndexTupleSize(dedup_hikey));
+ for (int i = 0; i < dedup_nplain; i++)
+ off += MAXALIGN(IndexTupleSize(dedup_plain[i]));
+
+ /*
+ * A pathologically compressible page can hold more plain data than
+ * fits in one page; if the payload would be that large, fall back to
+ * a forced full-page image of the coded page (bounded at BLCKSZ).
+ */
+ if (off > BLCKSZ)
+ dedup_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ /* Let a test force the otherwise-rare full-page-image fallback. */
+ if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ dedup_rfpi = true;
+#endif
+ if (dedup_rfpi)
+ ; /* image forced; no payload buffer built */
+ else
+ {
+ dedup_walbuflen = off;
+ dedup_walbuf = palloc(Max(off, 1));
+
+ off = 0;
+ if (dedup_prefixlen > 0)
+ {
+ memcpy(dedup_walbuf, dedup_prefix, dedup_prefixlen);
+ off += dedup_prefixlen;
+ }
+ if (dedup_hikey != NULL)
+ {
+ Size hsz = IndexTupleSize(dedup_hikey);
+
+ memcpy(dedup_walbuf + off, dedup_hikey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(dedup_walbuf + off + hsz, 0, MAXALIGN(hsz) - hsz);
+ off += MAXALIGN(hsz);
+ }
+ for (int i = 0; i < dedup_nplain; i++)
+ {
+ Size sz = IndexTupleSize(dedup_plain[i]);
+
+ memcpy(dedup_walbuf + off, dedup_plain[i], sz);
+ if (MAXALIGN(sz) != sz)
+ memset(dedup_walbuf + off + sz, 0, MAXALIGN(sz) - sz);
+ off += MAXALIGN(sz);
+ }
+ }
+ }
+
START_CRIT_SECTION();
PageRestoreTempPage(newpage, page);
@@ -248,19 +407,38 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
{
xl_btree_dedup xlrec_dedup;
- xlrec_dedup.nintervals = state->nintervals;
+ xlrec_dedup.nintervals = dedup_islogprefix ? 0 : state->nintervals;
+ xlrec_dedup.prefixlen = (uint8) dedup_prefixlen;
+ xlrec_dedup.flags = (dedup_hikey != NULL) ? XLH_DEDUP_NOT_RIGHTMOST : 0;
+ if (dedup_rfpi)
+ xlrec_dedup.flags |= XLH_DEDUP_RFPI;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ (dedup_rfpi ? REGBUF_FORCE_IMAGE : 0));
XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
- /*
- * The intervals array is not in the buffer, but pretend that it is.
- * When XLogInsert stores the whole buffer, the array need not be
- * stored too.
- */
- XLogRegisterBufData(0, state->intervals,
- state->nintervals * sizeof(BTDedupInterval));
+ if (dedup_islogprefix)
+ {
+ /*
+ * Prefix-dedup: carry the chosen prefix and the deduped plain
+ * items so redo can lay out the coded page; no full-page image
+ * (unless the payload was too large, handled by the forced image).
+ */
+ if (!dedup_rfpi && dedup_walbuflen > 0)
+ XLogRegisterBufData(0, dedup_walbuf, dedup_walbuflen);
+ }
+ else
+ {
+ /*
+ * The intervals array is not in the buffer, but pretend that it
+ * is. When XLogInsert stores the whole buffer, the array need not
+ * be stored too.
+ */
+ XLogRegisterBufData(0, state->intervals,
+ state->nintervals * sizeof(BTDedupInterval));
+ }
recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DEDUP);
}
@@ -277,6 +455,20 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
/* cannot leak memory here */
pfree(state->htids);
pfree(state);
+ return;
+
+dedup_overflow:
+
+ /*
+ * A prefix page's deduped items did not fit when laid out plain. Leave
+ * the original page untouched and return without writing anything; the
+ * caller falls through to split the page. No critical section was
+ * entered and the buffer was not marked dirty, so the page is unchanged.
+ */
+ Assert(pageprefix);
+ pfree(newpage);
+ pfree(state->htids);
+ pfree(state);
}
/*
@@ -318,6 +510,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
TM_IndexDeleteOp delstate;
bool neverdedup;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -371,9 +566,24 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
Assert(!ItemIdIsDead(itemid));
+ /*
+ * On a prefix-compressed page decode the item so _bt_keep_natts_fast
+ * sees the real key. The base of a pending interval must survive the
+ * whole run (it is compared against each following item), so keep it
+ * in its own buffer; the current item uses a reused buffer. Heap TIDs
+ * and posting lists are preserved by coding, so _bt_dedup_start_pending
+ * and _bt_bottomupdel_finish_pending (which read only TIDs/sizes, by
+ * page offset) need no decode.
+ */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/* itup starts first pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
@@ -387,6 +597,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
_bt_bottomupdel_finish_pending(page, state, &delstate);
/* itup starts new pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
@@ -554,7 +767,7 @@ _bt_dedup_save_htid(BTDedupState state, IndexTuple itup)
* where no deduplication was possible.
*/
Size
-_bt_dedup_finish_pending(Page newpage, BTDedupState state)
+_bt_dedup_finish_pending(Page newpage, BTDedupState state, bool overflowok)
{
OffsetNumber tupoff;
Size tuplesz;
@@ -572,7 +785,11 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
spacesaving = 0;
}
@@ -591,13 +808,26 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(final)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ pfree(final);
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
pfree(final);
spacesaving = state->phystupsize - (tuplesz + sizeof(ItemIdData));
/* Increment nintervals, since we wrote a new posting list tuple */
state->nintervals++;
- Assert(spacesaving > 0 && spacesaving < BLCKSZ);
+
+ /*
+ * On an ordinary page the merged interval's tuples were all physically
+ * present on one page, so the saving is bounded by BLCKSZ. On a prefix
+ * page (overflowok) phystupsize is the DECODED size of those tuples,
+ * which can exceed a page (a coded page holds more logical data than
+ * fits plain), so only require the saving to be positive there.
+ */
+ Assert(spacesaving > 0 && (overflowok || spacesaving < BLCKSZ));
}
/* Reset state for next pending posting list */
@@ -783,16 +1013,22 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state,
OffsetNumber minoff, IndexTuple newitem)
{
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(BTPageGetOpaque(page));
ItemId itemid;
IndexTuple itup;
+ BTItemBuf buf;
itemid = PageGetItemId(page, minoff);
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &buf);
if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts)
{
itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page));
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &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..dfd980a6a2a 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -348,8 +348,8 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate)
P_ISLEAF(opaque) &&
!P_IGNORE(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
@@ -1135,6 +1135,11 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple codeditup; /* itup coded for the page, or itup */
+ Size codeditemsz; /* its MAXALIGNed size */
+ IndexTuple codednposting = NULL; /* nposting coded for the page */
+ BTItemBuf opostbuf; /* decode workspace for an existing posting */
+ bool pageprefix; /* target page is BTP_PREFIX */
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1143,6 +1148,7 @@ _bt_insertonpg(Relation rel,
isroot = P_ISROOT(opaque);
isrightmost = P_RIGHTMOST(opaque);
isonly = P_LEFTMOST(opaque) && P_RIGHTMOST(opaque);
+ pageprefix = P_HASPREFIX(opaque);
/* child buffer must be given iff inserting on an internal page */
Assert(isleaf == !BufferIsValid(cbuf));
@@ -1182,6 +1188,9 @@ _bt_insertonpg(Relation rel,
*/
Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage);
oposting = (IndexTuple) PageGetItem(page, itemid);
+ /* On a prefix page the existing posting is coded; decode for the swap. */
+ if (pageprefix)
+ oposting = _bt_prefix_decode(page, oposting, &opostbuf);
/*
* postingoff value comes from earlier call to _bt_binsrch_posting().
@@ -1213,19 +1222,50 @@ _bt_insertonpg(Relation rel,
newitemoff = OffsetNumberNext(newitemoff);
}
+ /*
+ * On a prefix-compressed page the new item is stored coded, which is at
+ * most MAXALIGN larger than plain and usually much smaller. Size the fit
+ * test and the placement by the coded size. (Split is handled on plain
+ * tuples; _bt_split re-compresses each half itself.)
+ */
+ if (pageprefix)
+ {
+ codeditup = _bt_prefix_encode(page, itup);
+ codeditemsz = MAXALIGN(IndexTupleSize(codeditup));
+ /*
+ * A posting-list split replaces the existing coded posting with a
+ * re-coded one; encode it here, before the critical section, since
+ * _bt_prefix_encode() allocates.
+ */
+ if (nposting != NULL)
+ codednposting = _bt_prefix_encode(page, nposting);
+ }
+ else
+ {
+ codeditup = itup;
+ codeditemsz = itemsz;
+ }
+
/*
* Do we need to split the page to fit the item on it?
*
* 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.
+ * only for the item and not for its line pointer. The coded item size is
+ * used; a posting-list split additionally changes the posting tuple's
+ * size, but that can only shrink or keep free space (the new posting has
+ * one fewer TID's worth of room reused by newitem), so the item fit test
+ * remains a safe upper bound.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < codeditemsz)
{
Buffer rbuf;
Assert(!split_only_page);
+ if (codeditup != itup)
+ pfree(codeditup);
+
/* split the buffer into left and right halves */
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
@@ -1295,9 +1335,25 @@ _bt_insertonpg(Relation rel,
START_CRIT_SECTION();
if (postingoff != 0)
- memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ {
+ if (pageprefix)
+ {
+ /*
+ * The existing posting is stored coded; replace its slot (at
+ * the offset just before newitemoff, since newitemoff was
+ * advanced past it above) with the coded new posting (encoded
+ * before the critical section), whose size may differ.
+ */
+ if (!PageIndexTupleOverwrite(page, OffsetNumberPrev(newitemoff),
+ codednposting, IndexTupleSize(codednposting)))
+ elog(PANIC, "failed to overwrite posting list in block %u in index \"%s\"",
+ BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+ }
+ else
+ memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ }
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, codeditup, codeditemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1381,8 +1437,30 @@ _bt_insertonpg(Relation rel,
}
}
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- if (postingoff == 0)
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ ((pageprefix && postingoff != 0) ? REGBUF_FORCE_IMAGE : 0));
+ if (pageprefix && postingoff != 0)
+ {
+ /*
+ * A posting-list split on a prefix page rewrites an existing
+ * coded posting and adds a coded item; rather than teach redo to
+ * re-code both, force a full-page image (this is a rare case).
+ * postingoff is still recorded for the record format.
+ */
+ upostingoff = postingoff;
+ XLogRegisterBufData(0, &upostingoff, sizeof(uint16));
+ }
+ else if (pageprefix)
+ {
+ /*
+ * Simple insert on a prefix page: log the CODED item bytes as
+ * they were placed on the page, so redo adds them verbatim at
+ * newitemoff -- no full-page image needed, and byte-identical.
+ */
+ XLogRegisterBufData(0, (char *) codeditup, IndexTupleSize(codeditup));
+ }
+ else if (postingoff == 0)
{
/* Just log itup from caller */
XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -1418,6 +1496,11 @@ _bt_insertonpg(Relation rel,
END_CRIT_SECTION();
+ if (codeditup != itup)
+ pfree(codeditup);
+ if (codednposting != NULL && codednposting != nposting)
+ pfree(codednposting);
+
/* Release subsidiary buffers */
if (BufferIsValid(metabuf))
_bt_relbuf(rel, metabuf);
@@ -1506,6 +1589,22 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
ItemId itemid;
IndexTuple firstright,
lefthighkey;
+ BTItemBuf frbuf; /* decode scratch for firstright */
+ BTItemBuf llbuf; /* decode scratch for lastleft */
+ IndexTuple *leftitems = NULL; /* leaf: plain items for the left half */
+ IndexTuple *rightitems = NULL; /* leaf: plain items for the right half */
+ int nleft = 0;
+ int nright = 0;
+ IndexTuple righthighkey = NULL; /* leaf right half's high key (NULL=rightmost) */
+ const char *origprefix = NULL;
+ Size origprefixlen = 0;
+ char leftprefix[BTREE_PREFIX_MAX]; /* chosen prefix of each leaf half */
+ char rightprefix[BTREE_PREFIX_MAX];
+ Size leftprefixlen = 0;
+ Size rightprefixlen = 0;
+ char *rightwalbuf = NULL; /* packed right-page WAL payload */
+ Size rightwalbuflen = 0;
+ bool force_rfpi = false; /* carry the right page as a forced image */
OffsetNumber firstrightoff;
OffsetNumber afterleftoff,
afterrightoff,
@@ -1575,7 +1674,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
lopaque->btpo_flags = oopaque->btpo_flags;
- lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
/* set flag in leftpage indicating that rightpage has no downlink yet */
lopaque->btpo_flags |= BTP_INCOMPLETE_SPLIT;
lopaque->btpo_prev = oopaque->btpo_prev;
@@ -1650,6 +1749,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
itemid = PageGetItemId(origpage, firstrightoff);
itemsz = ItemIdGetLength(itemid);
firstright = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ firstright = _bt_prefix_decode(origpage, firstright, &frbuf);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
}
@@ -1673,6 +1774,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque));
itemid = PageGetItemId(origpage, lastleftoff);
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ lastleft = _bt_prefix_decode(origpage, lastleft, &llbuf);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
}
@@ -1712,7 +1815,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
/*
- * Add new high key to leftpage
+ * Add new high key to leftpage. For a leaf split the left half is built
+ * as a whole below (so it can be prefix-compressed from the start, which
+ * a plain incremental build cannot represent when the page holds more
+ * logical data than fits plain); the high key is passed to the builder
+ * then. Internal halves are built incrementally here as before.
*/
afterleftoff = P_HIKEY;
@@ -1720,11 +1827,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(BTreeTupleGetNAtts(lefthighkey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(itemsz == MAXALIGN(IndexTupleSize(lefthighkey)));
- if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
- elog(ERROR, "failed to add high key to the left sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
- afterleftoff = OffsetNumberNext(afterleftoff);
+ if (isleaf)
+ {
+ /* The builder needs a stable copy; lefthighkey points into workspace. */
+ lefthighkey = CopyIndexTuple(lefthighkey);
+ leftitems = palloc_array(IndexTuple, maxoff + 2);
+ rightitems = palloc_array(IndexTuple, maxoff + 2);
+ if (P_HASPREFIX(oopaque))
+ origprefix = _bt_page_get_prefix(origpage, &origprefixlen);
+ }
+ else
+ {
+ if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the left sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterleftoff = OffsetNumberNext(afterleftoff);
+ }
/*
* Acquire a new right page to split into, now that left page has a new
@@ -1765,7 +1884,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
ropaque->btpo_flags = oopaque->btpo_flags;
- ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = oopaque->btpo_next;
ropaque->btpo_level = oopaque->btpo_level;
@@ -1782,21 +1901,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (!isrightmost)
{
- IndexTuple righthighkey;
+ ItemId hiitemid = PageGetItemId(origpage, P_HIKEY);
+ Size hiitemsz = ItemIdGetLength(hiitemid);
+ IndexTuple hikey = (IndexTuple) PageGetItem(origpage, hiitemid);
- itemid = PageGetItemId(origpage, P_HIKEY);
- itemsz = ItemIdGetLength(itemid);
- righthighkey = (IndexTuple) PageGetItem(origpage, itemid);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) > 0);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) <=
+ Assert(BTreeTupleGetNAtts(hikey, rel) > 0);
+ Assert(BTreeTupleGetNAtts(hikey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
- if (PageAddItem(rightpage, righthighkey, itemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ if (isleaf)
+ righthighkey = CopyIndexTuple(hikey); /* stable: origpage is
+ * overwritten by the left
+ * half before WAL logging */
+ else
{
- elog(ERROR, "failed to add high key to the right sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
+ if (PageAddItem(rightpage, hikey, hiitemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the right sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterrightoff = OffsetNumberNext(afterrightoff);
}
- afterrightoff = OffsetNumberNext(afterrightoff);
}
/*
@@ -1813,7 +1936,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*
* Note: we *must* insert at least the right page's items in item-number
* order, for the benefit of _bt_restore_page().
+ *
+ * A leaf split builds each half as a whole page below (so it can be
+ * prefix-compressed from the start), which redo must reproduce byte for
+ * byte. To keep the primary and redo in lock step, both derive the
+ * split's plain item arrays from the original page through the single
+ * shared routine _bt_split_leaf_collect(); the incremental loop here
+ * handles only the internal (!isleaf) case.
*/
+ if (isleaf)
+ {
+ _bt_split_leaf_collect(origpage, firstrightoff, newitemoff, newitem,
+ newitemonleft, origpagepostingoff, nposting,
+ leftitems, &nleft, rightitems, &nright);
+ }
+ else
for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
{
IndexTuple dataitem;
@@ -1837,24 +1974,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
{
Assert(newitemoff <= firstrightoff);
if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
- false))
- {
+ false))
elog(ERROR, "failed to add new item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
Assert(newitemoff >= firstrightoff);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1863,28 +1996,24 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (i < firstrightoff)
{
if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
- {
elog(ERROR, "failed to add old item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add old item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
- /* Handle case where newitem goes at the end of rightpage */
- if (i <= newitemoff)
+ /* Handle case where newitem goes at the end of rightpage (internal only) */
+ if (!isleaf && i <= newitemoff)
{
/*
* Can't have newitemonleft here; that would imply we were told to put
@@ -1893,15 +2022,90 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*/
Assert(!newitemonleft && newitemoff == maxoff + 1);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
+ /*
+ * Leaf halves are built as whole pages now, so they can be prefix-
+ * compressed from the start (Strategy 1: a page is faulted into the
+ * BTP_PREFIX format only when it is written whole). The plain item arrays
+ * were collected above by _bt_split_leaf_collect(), the same routine redo
+ * uses, so the primary and redo lay out identical bytes.
+ */
+ if (isleaf)
+ {
+ _bt_split_leaf_half(rel, leftpage, lefthighkey, leftitems, nleft,
+ origprefix, origprefixlen,
+ leftprefix, &leftprefixlen);
+ _bt_split_leaf_half(rel, rightpage, righthighkey, rightitems, nright,
+ origprefix, origprefixlen,
+ rightprefix, &rightprefixlen);
+
+ /*
+ * Build the right page's WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the right prefix bytes, then the
+ * right high key (unless rightmost), then the right half's plain items,
+ * each MAXALIGN'd, in one contiguous buffer logged with a single rdata
+ * slot. A very compressible right half whose plain items would exceed
+ * a page is instead carried as a full-page image (set below), so this
+ * buffer is bounded.
+ */
+ if (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0)
+ {
+ Size roff = rightprefixlen;
+
+ if (righthighkey != NULL)
+ roff += MAXALIGN(IndexTupleSize(righthighkey));
+ for (int ri = 0; ri < nright; ri++)
+ roff += MAXALIGN(IndexTupleSize(rightitems[ri]));
+ rightwalbuf = palloc(Max(roff, 1));
+ rightwalbuflen = roff;
+
+ /*
+ * Decide (before the critical section, since the injection-point
+ * cache load can allocate) whether to carry the right page as a
+ * forced full-page image: normally only when its plain payload
+ * would exceed a block, but a test can force the otherwise-rare
+ * path with the nbtree-prefix-force-rfpi injection point.
+ */
+ if (rightwalbuflen > BLCKSZ)
+ force_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ else if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ force_rfpi = true;
+#endif
+
+ roff = 0;
+ if (rightprefixlen > 0)
+ {
+ memcpy(rightwalbuf, rightprefix, rightprefixlen);
+ roff += rightprefixlen;
+ }
+ if (righthighkey != NULL)
+ {
+ Size hsz = IndexTupleSize(righthighkey);
+
+ memcpy(rightwalbuf + roff, righthighkey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(rightwalbuf + roff + hsz, 0, MAXALIGN(hsz) - hsz);
+ roff += MAXALIGN(hsz);
+ }
+ for (int ri = 0; ri < nright; ri++)
+ {
+ Size rsz = IndexTupleSize(rightitems[ri]);
+
+ memcpy(rightwalbuf + roff, rightitems[ri], rsz);
+ if (MAXALIGN(rsz) != rsz)
+ memset(rightwalbuf + roff + rsz, 0, MAXALIGN(rsz) - rsz);
+ roff += MAXALIGN(rsz);
+ }
+ }
+ }
+
/*
* We have to grab the original right sibling (if any) and update its prev
* link. We are guaranteed that this is deadlock-free, since we couple
@@ -2005,17 +2209,71 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /*
+ * A leaf split that involves prefix compression (either half ends up
+ * compressed, or the pre-split page was compressed so its items are
+ * coded) is replayed by rebuilding both halves from the pre-split page,
+ * exactly as the primary did, rather than by replaying logged tuples --
+ * the coded physical layout would not match _bt_restore_page. No
+ * full-page image is needed; redo reconstructs the plain items from the
+ * original page (block 0) and lays each half out coded against the
+ * chosen prefix, whose length and bytes are carried in the record.
+ */
+ xlrec.flags = 0;
+ xlrec.leftprefixlen = 0;
+ xlrec.rightprefixlen = 0;
+ if (isleaf && (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0))
+ {
+ xlrec.flags |= XLH_SPLIT_LEAFPREFIX;
+ xlrec.leftprefixlen = (uint8) leftprefixlen;
+ xlrec.rightprefixlen = (uint8) rightprefixlen;
+
+ /*
+ * The right half is normally carried as its packed plain payload
+ * (redo lays it out coded), far cheaper than a full-page image.
+ * But a very compressible page can hold more plain data than fits
+ * in one page; force_rfpi (decided before the critical section)
+ * carries the coded right page as a bounded full-page image instead.
+ */
+ if (force_rfpi)
+ xlrec.flags |= XLH_SPLIT_RFPI;
+ }
+
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
+ /*
+ * The right page is rebuilt from scratch in redo. Ordinarily that is
+ * from the logged tuple region (REGBUF_WILL_INIT). A prefix-leaf split
+ * instead rebuilds it from its logged plain items, unless those would
+ * overflow the record (XLH_SPLIT_RFPI), in which case the coded page is
+ * carried as a forced full-page image.
+ */
+ XLogRegisterBuffer(1, rbuf,
+ (xlrec.flags & XLH_SPLIT_RFPI) ?
+ (REGBUF_STANDARD | REGBUF_FORCE_IMAGE) : REGBUF_WILL_INIT);
/* Log original right sibling, since we've changed its prev-pointer */
if (!isrightmost)
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
if (!isleaf)
XLogRegisterBuffer(3, cbuf, REGBUF_STANDARD);
+ /*
+ * For a prefix leaf split, carry each half's chosen prefix bytes, and
+ * (unless the right page is carried as a full-page image) the right
+ * high key and the right half's PLAIN items on block 1, so redo can lay
+ * the right page out coded without depending on block 0's state (block
+ * 0 may be restored from an automatic full-page image). The left half
+ * is rebuilt in redo from the original page (block 0) when it needs
+ * redo, or from its own FPI otherwise, so the left items are not logged.
+ */
+ if (xlrec.flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ if (!(xlrec.flags & XLH_SPLIT_RFPI) && rightwalbuflen > 0)
+ XLogRegisterBufData(1, rightwalbuf, rightwalbuflen);
+ }
+
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
@@ -2063,6 +2321,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
XLogRegisterBufData(0, lefthighkey,
MAXALIGN(IndexTupleSize(lefthighkey)));
+ /*
+ * For a prefix leaf split, the left half's chosen prefix bytes follow
+ * the high key on block 0 (redo reads newitem/orignewitem, then the
+ * high key, then this prefix).
+ */
+ if ((xlrec.flags & XLH_SPLIT_LEAFPREFIX) && leftprefixlen > 0)
+ XLogRegisterBufData(0, leftprefix, leftprefixlen);
+
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). The whole right page will be recreated.
@@ -2071,11 +2337,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the line pointers can be reconstructed. See comments for
- * _bt_restore_page().
+ * _bt_restore_page(). A prefix-compressed leaf split instead rebuilds
+ * the right page from the original page in redo (see XLH_SPLIT_LEAFPREFIX),
+ * so its tuple region is not logged.
*/
- XLogRegisterBufData(1,
- (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
- ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
+ if (!(xlrec.flags & XLH_SPLIT_LEAFPREFIX))
+ XLogRegisterBufData(1,
+ (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
+ ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
xlinfo = newitemonleft ? XLOG_BTREE_SPLIT_L : XLOG_BTREE_SPLIT_R;
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
@@ -2820,7 +3089,15 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
- /* Perform deduplication pass (when enabled and index-is-allequalimage) */
+ /*
+ * Perform deduplication pass (when enabled and index-is-allequalimage).
+ *
+ * On a prefix-compressed page the pass decodes items and lays the deduped
+ * result out plain before re-coding it; the decoded set can exceed one
+ * page even when the coded form fit, in which case the pass aborts without
+ * touching the page and we fall through to split it. When it does fit, the
+ * recompressed page's WAL replays without a full-page image.
+ */
if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
_bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz,
(indexUnchanged || uniquedup));
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f81..c755efc81b0 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1212,11 +1212,26 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * If VACUUM emptied a prefix-compressed leaf of all its data items, drop
+ * the PREFIX item too and clear BTP_PREFIX: an empty page carries no
+ * prefix, and page deletion relies on an emptied leaf holding only its
+ * high key (nbtpage.c's _bt_mark_page_halfdead). Recorded in the vacuum
+ * record so redo does the same.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
/*
@@ -2245,6 +2260,20 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
opaque = BTPageGetOpaque(page);
opaque->btpo_flags |= BTP_HALF_DEAD;
+ /*
+ * A prefix-compressed leaf being deleted may still carry its PREFIX item
+ * (e.g. emptied by simple deletion, which does not drop it). Remove it
+ * and clear BTP_PREFIX so the page holds only its high key, as the
+ * half-dead transition below requires. Redo (btree_xlog_mark_page_halfdead)
+ * reinitializes the page, so it needs no separate handling.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
MemSet(&trunctuple, 0, sizeof(IndexTupleData));
trunctuple.t_info = sizeof(IndexTupleData);
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 00000000000..c725f75cd92
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,814 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Leaf key prefix compression for Postgres btrees.
+ *
+ * A leaf page of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page front-codes the first key column of each non-pivot tuple against a
+ * per-page PREFIX item, so that a long key that repeats on a page costs
+ * little more than its unshared tail. The on-disk format is documented at
+ * BTP_PREFIX in nbtree.h; this file is the encoder, decoder, and the policy
+ * that chooses a page's prefix.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo repeats them, and decoding reproduces the plain
+ * tuple byte for byte. A tuple inserted later that shares less of the prefix
+ * just stores a smaller `shared' count, so no insert, VACUUM, deduplication
+ * or change of the page's key range ever has to rewrite the page to keep the
+ * prefix valid. The prefix is chosen only when a page is written as a whole
+ * (CREATE INDEX, or a half of a split).
+ *
+ * This is modeled on the leaf prefix compression of the BARK access method
+ * (src/backend/access/bark/barkutils.c).
+ *
+ *
+ * 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 "storage/bufpage.h"
+#include "utils/rel.h"
+
+/*
+ * Where each part of a tuple goes in its coded form. Coding and decoding
+ * both lay the tuple out from this, so the two cannot disagree.
+ */
+typedef struct BTPrefixPlan
+{
+ Size hoff; /* data offset: header and null bitmap */
+ Size c1; /* plain first column's size, with header */
+ Size keyend; /* end of the plain key (posting offset) */
+ uint8 code; /* first payload byte of the coded column */
+ const char *suffix; /* bytes stored after it */
+ Size suffixlen;
+ Size vsize; /* coded column's size, with header */
+ Size codedkeyend; /* end of the coded key */
+ Size total; /* size of the coded tuple */
+} BTPrefixPlan;
+
+/*
+ * Is `itup' stored coded on a BTP_PREFIX page? Only a non-pivot tuple (plain
+ * or posting) whose first column is not NULL; a pivot (high key, downlink)
+ * is stored as it is.
+ */
+static bool
+_bt_prefix_codable(IndexTuple itup)
+{
+ if (BTreeTupleIsPivot(itup))
+ return false;
+ return !IndexTupleHasNulls(itup) ||
+ !att_isnull(0, (uint8 *) itup + sizeof(IndexTupleData));
+}
+
+/*
+ * Does the varlena `val' have the header decoding would give it back: the
+ * 1-byte header if its data fit one, else an uncompressed 4-byte header?
+ */
+static bool
+_bt_prefix_canonical(const char *val)
+{
+ if (VARATT_IS_SHORT(val))
+ return !VARATT_IS_EXTERNAL(val);
+ return !VARATT_IS_EXTENDED(val) &&
+ VARSIZE(val) - VARHDRSZ + VARHDRSZ_SHORT > VARATT_SHORT_MAX;
+}
+
+/*
+ * End of a leaf tuple's key: the posting-list offset for a posting tuple, the
+ * whole tuple size otherwise. The bytes after the key (a posting list) are
+ * moved as they are.
+ */
+static Size
+_bt_prefix_keyend(IndexTuple itup)
+{
+ if (BTreeTupleIsPosting(itup))
+ return BTreeTupleGetPostingOffset(itup);
+ return IndexTupleSize(itup);
+}
+
+/*
+ * The size of a coded first column whose payload is `payload' bytes: a
+ * varlena with a 1-byte header when it fits one.
+ */
+static inline Size
+_bt_prefix_vsize(Size payload)
+{
+ if (payload + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ return payload + VARHDRSZ_SHORT;
+ return payload + VARHDRSZ;
+}
+
+/*
+ * Offset at which the bytes after the first column go in a coded tuple whose
+ * coded column ends at `vend', when in the plain tuple they start at
+ * `restorig': the first offset at or after `vend' that is the same distance
+ * past a MAXALIGN boundary.
+ */
+static inline Size
+_bt_prefix_restoff(Size vend, Size restorig)
+{
+ return vend + (MAXIMUM_ALIGNOF + restorig % MAXIMUM_ALIGNOF -
+ vend % MAXIMUM_ALIGNOF) % MAXIMUM_ALIGNOF;
+}
+
+/* Plan the coding of the codable tuple `itup' against `prefix'. */
+static void
+_bt_prefix_plan(const char *prefix, Size prefixlen, IndexTuple itup,
+ BTPrefixPlan *plan)
+{
+ const char *val;
+ Size restorig;
+ bool storerest;
+
+ plan->hoff = IndexInfoFindDataOffset(itup->t_info);
+ val = (const char *) itup + plan->hoff;
+ plan->c1 = VARSIZE_ANY(val);
+ plan->keyend = _bt_prefix_keyend(itup);
+
+ if (_bt_prefix_canonical(val))
+ {
+ const char *data = VARDATA_ANY(val);
+ Size datalen = VARSIZE_ANY_EXHDR(val);
+ Size limit = Min(prefixlen, datalen);
+ Size shared = 0;
+
+ while (shared < limit && prefix[shared] == data[shared])
+ shared++;
+ plan->code = (uint8) shared;
+ plan->suffix = data + shared;
+ plan->suffixlen = datalen - shared;
+ }
+ else
+ {
+ plan->code = BTREE_PREFIX_RAW;
+ plan->suffix = val;
+ plan->suffixlen = plan->c1;
+ }
+ plan->vsize = _bt_prefix_vsize(1 + plan->suffixlen);
+
+ /*
+ * The bytes after the first column can be left out when they are just the
+ * zero padding that ends the key.
+ */
+ restorig = plan->hoff + plan->c1;
+ storerest = plan->keyend != MAXALIGN(restorig);
+ for (Size i = restorig; !storerest && i < plan->keyend; i++)
+ storerest = ((const char *) itup)[i] != 0;
+
+ if (storerest)
+ {
+ plan->code |= BTREE_PREFIX_REST;
+ plan->codedkeyend = _bt_prefix_restoff(plan->hoff + plan->vsize,
+ restorig) +
+ (plan->keyend - restorig);
+ }
+ else
+ plan->codedkeyend = MAXALIGN(plan->hoff + plan->vsize);
+ plan->total = plan->codedkeyend + (IndexTupleSize(itup) - plan->keyend);
+}
+
+/*
+ * Copy n bytes, as memcpy. Decoding moves a few short runs of bytes per
+ * tuple; eight bytes at a time is several times faster than a libc memcpy
+ * for runs this short (the string-move startup cost dominates otherwise).
+ */
+static inline void
+_bt_prefix_copy(char *dst, const char *src, Size n)
+{
+ while (n >= sizeof(uint64))
+ {
+ memcpy(dst, src, sizeof(uint64));
+ dst += sizeof(uint64);
+ src += sizeof(uint64);
+ n -= sizeof(uint64);
+ }
+ while (n-- > 0)
+ *dst++ = *src++;
+}
+
+/* The prefix of a BTP_PREFIX page, and its length in *len. */
+const char *
+_bt_page_get_prefix(Page page, Size *len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ IndexTuple item;
+
+ Assert(P_HASPREFIX(opaque));
+ item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page,
+ BTPageGetPrefixOff(opaque)));
+ *len = IndexTupleSize(item) - sizeof(IndexTupleData);
+ return (const char *) item + sizeof(IndexTupleData);
+}
+
+/*
+ * Give `page', a leaf holding nothing yet but its high key (if it has one),
+ * the prefix `prefix' of `len' bytes: add the PREFIX item and set BTP_PREFIX.
+ * Its padding comes from the page's free space, which _bt_pageinit zeroed, so
+ * a page rebuilt in redo is the same.
+ */
+void
+_bt_page_set_prefix(Page page, const char *prefix, Size len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ union
+ {
+ IndexTupleData hdr;
+ char data[sizeof(IndexTupleData) + BTREE_PREFIX_MAX];
+ } item;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+ Assert(len >= 1 && len <= BTREE_PREFIX_MAX);
+ Assert(PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque) - 1);
+
+ memset(&item.hdr, 0, sizeof(IndexTupleData));
+ item.hdr.t_info = (unsigned short) (sizeof(IndexTupleData) + len);
+ memcpy(item.data + sizeof(IndexTupleData), prefix, len);
+ if (PageAddItem(page, item.data, sizeof(IndexTupleData) + len,
+ BTPageGetPrefixOff(opaque), false, false) ==
+ InvalidOffsetNumber)
+ elog(ERROR, "failed to add prefix item to btree page");
+ opaque->btpo_flags |= BTP_PREFIX;
+}
+
+/*
+ * The coded form of the leaf tuple `itup' for `page', palloc'd; `itup' itself
+ * when the page has no prefix or the tuple is stored plain.
+ */
+IndexTuple
+_bt_prefix_encode(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+ char *coded;
+ char *v;
+ char *payload;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ Assert(plan.total <= INDEX_SIZE_MASK);
+
+ coded = palloc0(plan.total);
+ memcpy(coded, itup, plan.hoff);
+ v = coded + plan.hoff;
+ if (plan.vsize <= VARATT_SHORT_MAX)
+ {
+ SET_VARSIZE_SHORT(v, plan.vsize);
+ payload = v + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ SET_VARSIZE(v, plan.vsize);
+ payload = v + VARHDRSZ;
+ }
+ payload[0] = (char) plan.code;
+ memcpy(payload + 1, plan.suffix, plan.suffixlen);
+ if (plan.code & BTREE_PREFIX_REST)
+ {
+ Size restorig = plan.hoff + plan.c1;
+
+ memcpy(coded + _bt_prefix_restoff(plan.hoff + plan.vsize, restorig),
+ (char *) itup + restorig, plan.keyend - restorig);
+ }
+ memcpy(coded + plan.codedkeyend, (char *) itup + plan.keyend,
+ IndexTupleSize(itup) - plan.keyend);
+
+ ((IndexTuple) coded)->t_info =
+ (itup->t_info & ~INDEX_SIZE_MASK) | (unsigned short) plan.total;
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) coded, BTreeTupleGetNPosting(itup),
+ plan.codedkeyend);
+
+#ifdef USE_ASSERT_CHECKING
+ {
+ BTItemBuf check;
+ IndexTuple decoded = _bt_prefix_decode(page, (IndexTuple) coded,
+ &check);
+
+ Assert(IndexTupleSize(decoded) == IndexTupleSize(itup) &&
+ memcmp(decoded, itup, IndexTupleSize(itup)) == 0);
+ }
+#endif
+
+ return (IndexTuple) coded;
+}
+
+/* The MAXALIGNed size `itup' takes on `page', coded if the page codes it. */
+Size
+_bt_prefix_coded_size(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return MAXALIGN(IndexTupleSize(itup));
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ return MAXALIGN(plan.total);
+}
+
+/*
+ * Decode the item `itup' of the BTP_PREFIX page `page' into `buf', and return
+ * it; an item stored plain is returned as it is. The inverse of
+ * _bt_prefix_encode, byte for byte. An item that does not decode within its
+ * own bounds, or claims more of the prefix than the page has, is reported as
+ * corruption.
+ */
+IndexTuple
+_bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf)
+{
+ const char *prefix;
+ Size prefixlen;
+ Size size = IndexTupleSize(itup);
+ Size hoff;
+ Size codedkeyend;
+ const char *v;
+ Size vsize = 0;
+ const char *payload;
+ Size paylen = 0;
+ uint8 code;
+ Size shared;
+ char *out = buf->data;
+ char *val = NULL;
+ Size c1;
+ Size restorig;
+ Size keyend;
+
+ if (!_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ hoff = IndexInfoFindDataOffset(itup->t_info);
+ codedkeyend = _bt_prefix_keyend(itup);
+ v = (const char *) itup + hoff;
+ if (hoff >= codedkeyend || codedkeyend > size ||
+ (vsize = VARSIZE_ANY(v)) > codedkeyend - hoff ||
+ (paylen = VARSIZE_ANY_EXHDR(v)) < 1)
+ goto corrupt;
+ payload = VARDATA_ANY(v);
+ code = (uint8) payload[0];
+ shared = code & BTREE_PREFIX_SHARED_MASK;
+
+ _bt_prefix_copy(out, (const char *) itup, hoff);
+ val = out + hoff;
+ if (shared == BTREE_PREFIX_RAW)
+ {
+ c1 = paylen - 1;
+ _bt_prefix_copy(val, payload + 1, c1);
+ if (c1 < VARHDRSZ_SHORT || VARSIZE_ANY(val) != c1)
+ goto corrupt;
+ }
+ else
+ {
+ Size datalen = shared + paylen - 1;
+ char *data;
+
+ if (hoff + VARHDRSZ + datalen > sizeof(BTItemBuf))
+ goto corrupt;
+ if (shared > prefixlen)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple shares %zu bytes of a %zu-byte page prefix",
+ shared, prefixlen)));
+ if (datalen + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ {
+ c1 = datalen + VARHDRSZ_SHORT;
+ SET_VARSIZE_SHORT(val, c1);
+ data = val + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ c1 = datalen + VARHDRSZ;
+ SET_VARSIZE(val, c1);
+ data = val + VARHDRSZ;
+ }
+ _bt_prefix_copy(data, prefix, shared);
+ _bt_prefix_copy(data + shared, payload + 1, paylen - 1);
+ }
+
+ restorig = hoff + c1;
+ if (code & BTREE_PREFIX_REST)
+ {
+ Size restoff = _bt_prefix_restoff(hoff + vsize, restorig);
+
+ if (restoff > codedkeyend)
+ goto corrupt;
+ keyend = restorig + (codedkeyend - restoff);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ _bt_prefix_copy(out + restorig, (const char *) itup + restoff,
+ codedkeyend - restoff);
+ }
+ else
+ {
+ keyend = MAXALIGN(restorig);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ memset(out + restorig, 0, keyend - restorig);
+ }
+ _bt_prefix_copy(out + keyend, (const char *) itup + codedkeyend,
+ size - codedkeyend);
+
+ ((IndexTuple) out)->t_info = (itup->t_info & ~INDEX_SIZE_MASK) |
+ (unsigned short) (keyend + (size - codedkeyend));
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) out, BTreeTupleGetNPosting(itup),
+ keyend);
+ return (IndexTuple) out;
+
+corrupt:
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple is not a valid prefix-coded tuple")));
+ return NULL; /* keep compiler quiet */
+}
+
+/*
+ * The bytes of `itup''s first column a page prefix may be taken from, and how
+ * many (at most BTREE_PREFIX_MAX) in *len; NULL when the tuple has none: it
+ * is not codable, its value is empty, or it would be stored raw.
+ */
+const char *
+_bt_prefix_candidate(IndexTuple itup, Size *len)
+{
+ const char *val;
+
+ if (!_bt_prefix_codable(itup))
+ return NULL;
+ val = (const char *) itup + IndexInfoFindDataOffset(itup->t_info);
+ if (!_bt_prefix_canonical(val) || VARSIZE_ANY_EXHDR(val) == 0)
+ return NULL;
+ *len = Min(VARSIZE_ANY_EXHDR(val), BTREE_PREFIX_MAX);
+ return VARDATA_ANY(val);
+}
+
+/*
+ * How many bytes of `prefix' the first column of `itup' would share on a page
+ * with that prefix: -1 when the tuple would not be coded, 0 when it would be
+ * stored raw.
+ */
+int
+_bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup)
+{
+ BTPrefixPlan plan;
+
+ if (!_bt_prefix_codable(itup))
+ return -1;
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ if ((plan.code & BTREE_PREFIX_SHARED_MASK) == BTREE_PREFIX_RAW)
+ return 0;
+ return plan.code & BTREE_PREFIX_SHARED_MASK;
+}
+
+/*
+ * May the leaves of `index' have a prefix? Only when the prefix_compression
+ * reloption is on and the first key column is of a varlena type.
+ */
+bool
+_bt_prefix_enabled(Relation index)
+{
+ return BTGetPrefixCompression(index) &&
+ TupleDescAttr(RelationGetDescr(index), 0)->attlen == -1;
+}
+
+/*
+ * Choose the prefix for a leaf about to be built from the `n' tuples `items',
+ * in key order: the first tuple's first-column bytes, cut to the longest run
+ * any later tuple shares, provided the tuples share at least
+ * BTREE_PREFIX_MIN_SHARED bytes of it on average. Returns the prefix's
+ * length, with *prefix pointing into items[0], or 0 for no prefix.
+ */
+Size
+_bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix)
+{
+ const char *cand;
+ Size candlen;
+ Size longest = 0;
+ Size total = 0;
+ int ncoded = 0;
+
+ if (!_bt_prefix_enabled(index) ||
+ (cand = _bt_prefix_candidate(items[0], &candlen)) == NULL)
+ return 0;
+ if (n == 1)
+ {
+ *prefix = cand;
+ return candlen;
+ }
+ for (int i = 1; i < n; i++)
+ {
+ int shared = _bt_prefix_shared(cand, candlen, items[i]);
+
+ if (shared < 0)
+ continue;
+ total += shared;
+ ncoded++;
+ longest = Max(longest, (Size) shared);
+ }
+ if (longest == 0 || total < (Size) BTREE_PREFIX_MIN_SHARED * ncoded)
+ return 0;
+ *prefix = cand;
+ return longest;
+}
+
+/*
+ * Fault a freshly finished leaf `page' forward into the BTP_PREFIX format if
+ * a prefix pays off (Strategy 1: compress a page only when it is written as a
+ * whole -- CREATE INDEX page finish, a split half, a dedup rewrite -- so that
+ * no insert, VACUUM or key-range change ever forces a page rewrite, and an
+ * old-format page is converted only when it is next written whole).
+ *
+ * On entry `page' is a complete leaf: its high key at P_HIKEY if it is not
+ * rightmost, then its data items, none of them coded (the page does not yet
+ * have BTP_PREFIX). If the data items share enough of a common first-column
+ * prefix, the page is rebuilt in place with the PREFIX item inserted after
+ * the high key and every data item coded against it; otherwise the page is
+ * left unchanged. Item order and the high key are preserved.
+ *
+ * Caller must hold the page's buffer exclusively (or own the page outright,
+ * as the build and split paths do with a temp page). No WAL is written here:
+ * callers log the resulting page (a build writes it whole; a split logs each
+ * half whole or by full-page image), and redo reaches the same bytes by
+ * running _bt_prefix_encode against the same prefix.
+ */
+void
+_bt_prefix_compress_page(Relation index, Page page)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber minoff;
+ OffsetNumber maxoff;
+ int n;
+ IndexTuple *items;
+ Size prefixlen;
+ const char *prefix;
+ Page tmp;
+ OffsetNumber o;
+ IndexTuple hikey = NULL;
+ Size hikeysz = 0;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+
+ if (!_bt_prefix_enabled(index))
+ return;
+
+ minoff = P_FIRSTDATAKEY(opaque);
+ maxoff = PageGetMaxOffsetNumber(page);
+ n = (int) maxoff - (int) minoff + 1;
+ if (n <= 0)
+ return;
+
+ items = (IndexTuple *) palloc(n * sizeof(IndexTuple));
+ for (int i = 0; i < n; i++)
+ items[i] = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, minoff + i));
+
+ prefixlen = _bt_prefix_choose(index, items, n, &prefix);
+ if (prefixlen == 0)
+ {
+ pfree(items);
+ return;
+ }
+
+ /* The high key (if any) stays at P_HIKEY, uncoded. */
+ if (!P_RIGHTMOST(opaque))
+ {
+ ItemId hii = PageGetItemId(page, P_HIKEY);
+
+ hikeysz = ItemIdGetLength(hii);
+ hikey = (IndexTuple) PageGetItem(page, hii);
+ }
+
+ /*
+ * Rebuild into a temp page: high key (if any), PREFIX item, then each
+ * data item coded. The coded form can be up to MAXALIGN larger than the
+ * plain one, but the PREFIX item replaces far more shared bytes than it
+ * adds, so a page that was not overfull stays within bounds; if a coded
+ * item somehow does not fit, leave the page uncompressed (always valid).
+ */
+ tmp = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(tmp)->btpo_flags &= ~BTP_PREFIX;
+ if (hikey != NULL &&
+ PageAddItem(tmp, (char *) hikey, hikeysz, P_HIKEY, false, false) ==
+ InvalidOffsetNumber)
+ goto giveup;
+ _bt_page_set_prefix(tmp, prefix, prefixlen);
+ o = OffsetNumberNext(PageGetMaxOffsetNumber(tmp));
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(tmp, items[i]);
+ bool ok;
+
+ ok = PageAddItem(tmp, (char *) coded, IndexTupleSize(coded), o++,
+ false, false) != InvalidOffsetNumber;
+ if (coded != items[i])
+ pfree(coded);
+ if (!ok)
+ goto giveup;
+ }
+
+ memcpy(page, tmp, BLCKSZ);
+ pfree(tmp);
+ pfree(items);
+ return;
+
+giveup:
+ pfree(tmp);
+ pfree(items);
+}
+
+/*
+ * Lay out one half of a leaf split on `page', which the caller has
+ * initialized (flags, links) and left empty: the high key `hikey' (NULL for
+ * a rightmost half), then the `n' plain tuples `items' in key order.
+ *
+ * The half is formed as a whole, so it may take a prefix of its own: its
+ * first item's leading bytes, if the items share enough (_bt_prefix_choose).
+ * Because a prefix-compressed leaf can hold more logical data than fits in a
+ * plain page, the half is laid out coded from the start rather than built
+ * plain and compressed afterwards. When the half does not fit with its own
+ * prefix (or shares too little to take one) it is laid out plain, and if that
+ * does not fit either, with `origprefix' (the splitting page's prefix, zero
+ * length when it had none), against which it fits by construction: the items
+ * keep the shares they had on the original page.
+ *
+ * Mirrors BARK's bark_split_leaf_half. Callers: _bt_split (both halves).
+ */
+void
+_bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen)
+{
+ BTPageOpaqueData saved = *BTPageGetOpaque(page);
+ const char *prefix[3];
+ Size prefixlen[3];
+ int ntries = 0;
+
+ Assert(P_ISLEAF(&saved) && !P_HASPREFIX(&saved));
+
+ /* Candidate prefixes to try, in order: own, none, the orig page's. */
+ if (_bt_prefix_enabled(index))
+ {
+ prefixlen[ntries] = _bt_prefix_choose(index, items, n, &prefix[ntries]);
+ if (prefixlen[ntries] > 0)
+ ntries++;
+ }
+ prefix[ntries] = NULL;
+ prefixlen[ntries++] = 0;
+ if (origprefixlen > 0)
+ {
+ prefix[ntries] = origprefix;
+ prefixlen[ntries++] = origprefixlen;
+ }
+
+ for (int t = 0; t < ntries; t++)
+ {
+ if (_bt_split_leaf_layout(page, hikey, items, n, prefix[t], prefixlen[t]))
+ {
+ /* Report the prefix that won, so the split can log it for redo. */
+ if (chosenprefixlen != NULL)
+ {
+ *chosenprefixlen = prefixlen[t];
+ if (prefixlen[t] > 0)
+ memcpy(chosenprefix, prefix[t], prefixlen[t]);
+ }
+ return;
+ }
+ /* restore the empty-page state for the next attempt */
+ _bt_pageinit(page, BLCKSZ);
+ *BTPageGetOpaque(page) = saved;
+ }
+ elog(ERROR, "failed to lay out a split half of index \"%s\"",
+ RelationGetRelationName(index));
+}
+
+/*
+ * Lay out one leaf split half on the caller-initialized empty `page' using a
+ * GIVEN prefix (`prefixlen' 0 for none): the high key `hikey' (NULL for a
+ * rightmost half), the PREFIX item, then the `n' plain tuples `items' coded
+ * against the prefix. Returns false if they do not fit (caller tries another
+ * prefix). This is Relation-free and deterministic in (hikey, items, prefix),
+ * so WAL redo calls it with the logged prefix to reproduce the primary's page
+ * byte for byte.
+ */
+bool
+_bt_split_leaf_layout(Page page, IndexTuple hikey, IndexTuple *items, int n,
+ const char *prefix, Size prefixlen)
+{
+ OffsetNumber o = P_HIKEY;
+ bool fit = true;
+
+ Assert(P_ISLEAF(BTPageGetOpaque(page)) && !P_HASPREFIX(BTPageGetOpaque(page)));
+ if (hikey != NULL &&
+ PageAddItem(page, (char *) hikey, MAXALIGN(IndexTupleSize(hikey)),
+ o++, false, false) == InvalidOffsetNumber)
+ return false;
+ if (prefixlen > 0)
+ {
+ _bt_page_set_prefix(page, prefix, prefixlen);
+ o++;
+ }
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(page, items[i]);
+
+ if (PageAddItem(page, (char *) coded, IndexTupleSize(coded),
+ o++, false, false) == InvalidOffsetNumber)
+ fit = false;
+ if (coded != items[i])
+ pfree(coded);
+ if (!fit)
+ return false;
+ }
+#ifdef USE_ASSERT_CHECKING
+ if (P_HASPREFIX(BTPageGetOpaque(page)))
+ {
+ OffsetNumber mx = PageGetMaxOffsetNumber(page);
+ OffsetNumber off;
+
+ for (off = P_FIRSTDATAKEY(BTPageGetOpaque(page)); off <= mx; off++)
+ {
+ BTItemBuf vb;
+
+ (void) _bt_getitem(page, off, &vb);
+ }
+ }
+#endif
+ return true;
+}
+
+/*
+ * Collect the plain (decoded) data items of a leaf split into the left and
+ * right arrays, in the exact order and with the exact substitutions that the
+ * split performs: the original page's data items split at `firstrightoff',
+ * with `newitem' inserted at `newitemoff' on the side given by `newitemonleft'
+ * and, for a posting-list split, the posting at `origpagepostingoff' replaced
+ * by `nposting'. `origpage' is the pre-split page (its items are decoded when
+ * it is a BTP_PREFIX page). The arrays receive pointers that are valid until
+ * the caller is done laying out the halves; items taken from the page are
+ * copied, so the caller owns and should reset the surrounding memory context.
+ *
+ * Both the primary (_bt_split) and WAL redo (btree_xlog_split) collect items
+ * this way and then lay out each half with _bt_split_leaf_half, so the two
+ * produce byte-identical coded pages without logging a full-page image.
+ */
+void
+_bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft, OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright)
+{
+ BTPageOpaque oopaque = BTPageGetOpaque(origpage);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(origpage);
+ bool hasprefix = P_HASPREFIX(oopaque);
+ OffsetNumber i;
+
+ *nleft = 0;
+ *nright = 0;
+ for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
+ {
+ ItemId itemid = PageGetItemId(origpage, i);
+ IndexTuple dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ BTItemBuf dibuf;
+
+ if (hasprefix)
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+
+ if (i == origpagepostingoff)
+ dataitem = nposting;
+ else if (i == newitemoff)
+ {
+ if (newitemonleft)
+ leftitems[(*nleft)++] = newitem;
+ else
+ rightitems[(*nright)++] = newitem;
+ }
+
+ if (i < firstrightoff)
+ leftitems[(*nleft)++] = CopyIndexTuple(dataitem);
+ else
+ rightitems[(*nright)++] = CopyIndexTuple(dataitem);
+ }
+ if (i <= newitemoff)
+ rightitems[(*nright)++] = newitem;
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..ffc5846e4c9 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -145,6 +145,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ BTItemBuf itupbuf; /* decode workspace for a loop's current item */
+ BTItemBuf finalbuf; /* decode workspace for a backward finaltup */
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -252,7 +254,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
continue;
}
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -353,9 +355,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,7 +415,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
else
tuple_alive = true;
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -596,9 +596,10 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
BTScanOpaque so = (BTScanOpaque) scan->opaque;
Relation rel = scan->indexRelation;
TupleDesc tupdesc = RelationGetDescr(rel);
- ItemId iid;
IndexTuple firsttup,
lasttup;
+ BTItemBuf firstbuf,
+ lastbuf;
int startikey = 0,
arrayidx = 0,
firstchangingattnum;
@@ -616,12 +617,10 @@ _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);
+ 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 +2052,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
ScanDirection dir = pstate->dir;
OffsetNumber aheadoffnum;
IndexTuple ahead;
+ BTItemBuf aheadbuf; /* decode workspace for the look-ahead tuple */
Assert(!pstate->forcenonrequired);
@@ -2088,8 +2088,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..b8b8ec018f3 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -435,8 +435,10 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ Size half = BTTupleStorageSize(scan->indexRelation);
+
+ so->currTuples = (char *) palloc(half * 2);
+ so->markTuples = so->currTuples + half;
}
/*
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..f932ca33c3a 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -696,6 +696,7 @@ _bt_compare(Relation rel,
TupleDesc itupdesc = RelationGetDescr(rel);
BTPageOpaque opaque = BTPageGetOpaque(page);
IndexTuple itup;
+ BTItemBuf itupbuf;
ItemPointer heapTid;
ScanKey scankey;
int ncmpkey;
@@ -713,7 +714,7 @@ _bt_compare(Relation rel,
if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque))
return 1;
- itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ itup = _bt_getitem(page, offnum, &itupbuf);
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dcf4..9a8b372033e 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -241,6 +241,10 @@ typedef struct BTPageState
Size btps_lastextra; /* last item's extra posting list space */
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
+ /* Leaf prefix compression: the candidate prefix of the page being filled */
+ const char *btps_prefix; /* into the page's first data item, or NULL */
+ Size btps_prefixlen; /* its length (0 = no candidate) */
+ Size btps_codedsaved; /* bytes the page's items would save coded */
struct BTPageState *btps_next; /* link to parent level, if any */
} BTPageState;
@@ -856,7 +860,8 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
*/
Assert(last_truncextra == 0 || isleaf);
if (pgspc < itupsz + (isleaf ? MAXALIGN(sizeof(ItemPointerData)) : 0) ||
- (pgspc + last_truncextra < state->btps_full && last_off > P_FIRSTKEY))
+ (pgspc + last_truncextra + state->btps_codedsaved < state->btps_full &&
+ last_off > P_FIRSTKEY))
{
/*
* Finish off the page and write it out.
@@ -980,6 +985,15 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
nopaque->btpo_next = P_NONE; /* redundant */
}
+ /*
+ * The old leaf page is complete: fault it forward into the
+ * BTP_PREFIX format if a prefix pays off. Done after btps_lowkey
+ * (the downlink, a copy of the uncoded high key) is saved, since
+ * compression rewrites opage in place.
+ */
+ if (isleaf)
+ _bt_prefix_compress_page(wstate->index, opage);
+
/*
* Write out the old page. _bt_blwritepage takes ownership of the
* 'opage' buffer.
@@ -990,6 +1004,11 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
* Reset last_off to point to new page
*/
last_off = P_FIRSTKEY;
+
+ /* The new page starts with no prefix candidate and no savings yet. */
+ state->btps_prefix = NULL;
+ state->btps_prefixlen = 0;
+ state->btps_codedsaved = 0;
}
/*
@@ -1017,6 +1036,34 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_sortaddtup(npage, itupsz, itup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Leaf prefix compression accounting (Strategy 1): the page's eventual
+ * prefix is its first data item's first-column bytes; estimate how much
+ * room coding this page's items would free, so the fillfactor test above
+ * can pack more plain items onto a page that will shrink when it is
+ * compressed at finish. Items are still placed plain here; the estimate
+ * only relaxes the soft limit, never the hard fit check.
+ */
+ if (isleaf && _bt_prefix_enabled(wstate->index))
+ {
+ if (state->btps_prefix == NULL)
+ state->btps_prefix = _bt_prefix_candidate(itup,
+ &state->btps_prefixlen);
+ else
+ {
+ int shared = _bt_prefix_shared(state->btps_prefix,
+ state->btps_prefixlen, itup);
+
+ /*
+ * A coded item drops `shared' value bytes but keeps a 1-byte
+ * share count; count the net MAXALIGN-rounded saving.
+ */
+ if (shared > 1)
+ state->btps_codedsaved +=
+ MAXALIGN(itupsz) - MAXALIGN(itupsz - (shared - 1));
+ }
+ }
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1115,6 +1162,11 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
* back one slot. Then we can dump out the page.
*/
_bt_slideleft((Page) s->btps_buf);
+
+ /* Fault the finished rightmost leaf forward into BTP_PREFIX if it pays. */
+ if (s->btps_level == 0)
+ _bt_prefix_compress_page(wstate->index, (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/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8b2..14bee568e45 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -52,6 +52,9 @@ typedef struct
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
+ bool hasprefix; /* origpage is prefix-compressed (BTP_PREFIX) */
+ BTItemBuf lbuf; /* decode scratch for _bt_split_lastleft */
+ BTItemBuf rbuf; /* decode scratch for _bt_split_firstright */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
@@ -172,6 +175,23 @@ _bt_findsplitloc(Relation rel,
olddataitemstotal = rightspace - (int) PageGetExactFreeSpace(origpage);
leaffillfactor = BTGetFillFactor(rel);
+ /*
+ * On a prefix-compressed leaf the page carries an extra PREFIX item that
+ * is not a data item. The split is reasoned about in CODED (on-page)
+ * bytes -- the physical reality of the page and of each recompressed half
+ * -- so subtract only the PREFIX item's space from olddataitemstotal and
+ * use the on-page (coded) item sizes in the scan below. (Plain-size
+ * accounting is not usable here: a compressed leaf can hold far more
+ * logical data than two plain pages, so a plain split can be infeasible.)
+ */
+ if (P_HASPREFIX(opaque))
+ {
+ ItemId pii = PageGetItemId(origpage, BTPageGetPrefixOff(opaque));
+
+ olddataitemstotal -= (int) (MAXALIGN(ItemIdGetLength(pii)) +
+ sizeof(ItemIdData));
+ }
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
state.rel = rel;
@@ -185,6 +205,7 @@ _bt_findsplitloc(Relation rel,
state.olddataitemstotal = olddataitemstotal;
state.minfirstrightsz = SIZE_MAX;
state.newitemoff = newitemoff;
+ state.hasprefix = P_HASPREFIX(opaque);
/* newitem cannot be a posting list item */
Assert(!BTreeTupleIsPosting(newitem));
@@ -463,6 +484,19 @@ _bt_recsplitloc(FindSplitData *state,
newitemisfirstright = (firstrightoff == state->newitemoff &&
!newitemonleft);
+ /*
+ * Reject a split that would leave the left page with no data items at all
+ * (firstrightoff at the first data key, with newitem not on the left).
+ * Upstream's split-point heuristics never choose such a point for a plain
+ * page; on a prefix-compressed page the coded-size accounting can make it
+ * look attractive, and _bt_split cannot form a left high key from a
+ * nonexistent lastleft.
+ */
+ if (state->hasprefix &&
+ firstrightoff == P_FIRSTDATAKEY(BTPageGetOpaque(state->origpage)) &&
+ !newitemonleft)
+ return;
+
if (newitemisfirstright)
firstrightsz = state->newitemsz;
else
@@ -686,8 +720,12 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
if (state->newitemoff > maxoff)
{
+ BTItemBuf tbuf;
+
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ if (state->hasprefix)
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
@@ -714,12 +752,20 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
+ /* Do cheaper test first (t_tid and the posting bit survive coding) */
if (BTreeTupleIsPosting(tup) ||
!_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
return false;
/* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ if (state->hasprefix)
+ {
+ BTItemBuf tbuf;
+
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ }
+ else
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -1160,12 +1206,38 @@ static inline IndexTuple
_bt_split_lastleft(FindSplitData *state, SplitPoint *split)
{
ItemId itemid;
+ OffsetNumber off;
if (split->newitemonleft && split->firstrightoff == state->newitemoff)
return state->newitem;
- itemid = PageGetItemId(state->origpage,
- OffsetNumberPrev(split->firstrightoff));
+ off = OffsetNumberPrev(split->firstrightoff);
+
+ /*
+ * On a prefix-compressed leaf the PREFIX item sits just before the first
+ * data item. When the split boundary is at the first data key, the data
+ * predecessor is the high key on a plain page (a pivot, read plain); a
+ * rightmost page has none, so fall back to the first data item (this only
+ * feeds penalty estimation, never placement). Never read the PREFIX item
+ * as a data tuple.
+ */
+ if (state->hasprefix)
+ {
+ BTPageOpaque op = BTPageGetOpaque(state->origpage);
+
+ if (off == BTPageGetPrefixOff(op))
+ {
+ if (!P_RIGHTMOST(op))
+ return (IndexTuple) PageGetItem(state->origpage,
+ PageGetItemId(state->origpage, P_HIKEY));
+ off = P_FIRSTDATAKEY(op);
+ }
+ itemid = PageGetItemId(state->origpage, off);
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->lbuf);
+ }
+ itemid = PageGetItemId(state->origpage, off);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
@@ -1181,5 +1253,9 @@ _bt_split_firstright(FindSplitData *state, SplitPoint *split)
return state->newitem;
itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ if (state->hasprefix)
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->rbuf);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
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..41bda6aba06 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -243,6 +243,211 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting,
_bt_restore_meta(record, 2);
}
+/*
+ * Replay a prefix-compressed leaf page split.
+ *
+ * Rather than replay logged tuples (whose coded physical layout
+ * _bt_restore_page could not reproduce), rebuild both halves the way the
+ * primary did: collect the split's plain items from the pre-split original
+ * page (block 0) with _bt_split_leaf_collect, then lay each half out coded
+ * against its chosen prefix (carried in the record) with _bt_split_leaf_layout.
+ * The result is byte-identical to the primary, so no full-page image is
+ * needed. The record's block-0 data carries, in order: newitem (when it went
+ * on the left, or for a posting split), the left high key, and the left half's
+ * prefix bytes; block-1 data carries the right half's prefix bytes.
+ */
+static void
+btree_xlog_split_prefix(XLogReaderState *record, BlockNumber origpagenumber,
+ BlockNumber rightpagenumber, BlockNumber spagenumber,
+ bool newitemonleft)
+{
+ XLogRecPtr lsn = record->EndRecPtr;
+ xl_btree_split *xlrec = (xl_btree_split *) XLogRecGetData(record);
+ Buffer rbuf;
+ Buffer origbuf;
+ Page origpage;
+ BTPageOpaque oopaque;
+ char *datapos;
+ Size datalen;
+ IndexTuple newitem = NULL;
+ IndexTuple left_hikey;
+ IndexTuple right_hikey = NULL;
+ IndexTuple nposting = NULL;
+ Size newitemsz = 0;
+ const char *leftprefix = NULL;
+ const char *rightprefix = NULL;
+ IndexTuple rightitems[MaxIndexTuplesPerPage];
+ int nright = 0;
+ Page rightpage;
+ BTPageOpaque ropaque;
+ XLogRedoAction origaction;
+
+ /*
+ * The right (new) page is rebuilt entirely from data logged with the
+ * record, independent of block 0, because block 0 may be restored from an
+ * automatic full-page image (so its live contents after redo are the left
+ * half, not the pre-split page). Block-1 data is, in order: the right
+ * half's chosen prefix bytes (rightprefixlen), the right high key when the
+ * split is not rightmost (spagenumber != P_NONE), then the right half's
+ * plain data items in offset order (self-describing by IndexTupleSize).
+ */
+ datapos = XLogRecGetBlockData(record, 1, &datalen);
+ if (xlrec->flags & XLH_SPLIT_RFPI)
+ {
+ /*
+ * The right half's plain items would have overflowed the WAL record,
+ * so the primary carried the coded right page as a forced full-page
+ * image. Restore it from that image.
+ */
+ if (XLogReadBufferForRedo(record, 1, &rbuf) != BLK_RESTORED)
+ elog(ERROR, "prefix leaf split right page lacks its full-page image");
+ }
+ else
+ {
+ if (xlrec->rightprefixlen > 0)
+ {
+ rightprefix = datapos;
+ datapos += xlrec->rightprefixlen;
+ datalen -= xlrec->rightprefixlen;
+ }
+ if (spagenumber != P_NONE)
+ {
+ right_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(right_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(right_hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nright < MaxIndexTuplesPerPage);
+ rightitems[nright++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ rbuf = XLogInitBufferForRedo(record, 1);
+ rightpage = BufferGetPage(rbuf);
+ _bt_pageinit(rightpage, BufferGetPageSize(rbuf));
+ ropaque = BTPageGetOpaque(rightpage);
+ ropaque->btpo_prev = origpagenumber;
+ ropaque->btpo_next = spagenumber;
+ ropaque->btpo_level = xlrec->level;
+ ropaque->btpo_flags = BTP_LEAF;
+ ropaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(rightpage, right_hikey, rightitems, nright,
+ rightprefix, xlrec->rightprefixlen))
+ elog(ERROR, "failed to replay right half of a prefix leaf split");
+ PageSetLSN(rightpage, lsn);
+ }
+ MarkBufferDirty(rbuf);
+
+ /*
+ * Rebuild the left (original) page from the pre-split page, unless it was
+ * restored from a full-page image (then it is already the final left
+ * half). Block-0 data is: [newitem (left/posting)] [left high key]
+ * [left prefix]. The left plain items come from the pre-split page.
+ */
+ origaction = XLogReadBufferForRedo(record, 0, &origbuf);
+ origpage = BufferGetPage(origbuf);
+
+ if (origaction == BLK_NEEDS_REDO)
+ {
+ IndexTuple *leftitems;
+ int nleft;
+ OffsetNumber origpagepostingoff = InvalidOffsetNumber;
+ Page leftpage;
+ BTPageOpaque lopaque;
+
+ oopaque = BTPageGetOpaque(origpage);
+
+ datapos = XLogRecGetBlockData(record, 0, &datalen);
+ if (newitemonleft || xlrec->postingoff != 0)
+ {
+ newitem = (IndexTuple) datapos;
+ newitemsz = MAXALIGN(IndexTupleSize(newitem));
+ datapos += newitemsz;
+ datalen -= newitemsz;
+ }
+ left_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(left_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(left_hikey));
+ if (xlrec->leftprefixlen > 0)
+ {
+ leftprefix = datapos;
+ datapos += xlrec->leftprefixlen;
+ datalen -= xlrec->leftprefixlen;
+ }
+ Assert(datalen == 0);
+
+ if (xlrec->postingoff != 0)
+ {
+ ItemId itemid;
+ IndexTuple oposting;
+ BTItemBuf obuf;
+
+ origpagepostingoff = OffsetNumberPrev(xlrec->newitemoff);
+ newitem = CopyIndexTuple(newitem);
+ itemid = PageGetItemId(origpage, origpagepostingoff);
+ oposting = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ oposting = _bt_prefix_decode(origpage, oposting, &obuf);
+ nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff);
+ }
+
+ leftitems = palloc_array(IndexTuple, PageGetMaxOffsetNumber(origpage) + 2);
+ rightitems[0] = NULL; /* not reused below */
+ {
+ IndexTuple dummyright[MaxIndexTuplesPerPage];
+ int dummynr;
+
+ _bt_split_leaf_collect(origpage, xlrec->firstrightoff,
+ xlrec->newitemoff, newitem, newitemonleft,
+ origpagepostingoff, nposting,
+ leftitems, &nleft, dummyright, &dummynr);
+ }
+
+ leftpage = PageGetTempPageCopySpecial(origpage);
+ lopaque = BTPageGetOpaque(leftpage);
+ lopaque->btpo_flags = BTP_LEAF | BTP_INCOMPLETE_SPLIT;
+ lopaque->btpo_prev = oopaque->btpo_prev;
+ lopaque->btpo_next = rightpagenumber;
+ lopaque->btpo_level = xlrec->level;
+ lopaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(leftpage, left_hikey, leftitems, nleft,
+ leftprefix, xlrec->leftprefixlen))
+ elog(ERROR, "failed to replay left half of a prefix leaf split");
+ PageRestoreTempPage(leftpage, origpage);
+ PageSetLSN(origpage, lsn);
+ MarkBufferDirty(origbuf);
+ pfree(leftitems);
+ }
+
+ /* Fix left-link of the page to the right of the new right sibling. */
+ if (spagenumber != P_NONE)
+ {
+ Buffer sbuf;
+
+ if (XLogReadBufferForRedo(record, 2, &sbuf) == BLK_NEEDS_REDO)
+ {
+ Page spage = BufferGetPage(sbuf);
+ BTPageOpaque spageop = BTPageGetOpaque(spage);
+
+ spageop->btpo_prev = rightpagenumber;
+ PageSetLSN(spage, lsn);
+ MarkBufferDirty(sbuf);
+ }
+ if (BufferIsValid(sbuf))
+ UnlockReleaseBuffer(sbuf);
+ }
+
+ UnlockReleaseBuffer(rbuf);
+ if (BufferIsValid(origbuf))
+ UnlockReleaseBuffer(origbuf);
+}
+
static void
btree_xlog_split(bool newitemonleft, XLogReaderState *record)
{
@@ -264,6 +469,18 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
if (!XLogRecGetBlockTagExtended(record, 2, NULL, NULL, &spagenumber, NULL))
spagenumber = P_NONE;
+ /*
+ * A prefix-compressed leaf split is replayed by rebuilding both halves
+ * from the pre-split original page, exactly as the primary did, so the
+ * coded pages come out byte-identical without a full-page image.
+ */
+ if (xlrec->flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ btree_xlog_split_prefix(record, origpagenumber, rightpagenumber,
+ spagenumber, newitemonleft);
+ return;
+ }
+
/*
* Clear the incomplete split flag on the appropriate child page one level
* down when origpage/buf is an internal page (there must have been
@@ -280,7 +497,7 @@ 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 the logged tuples. */
rbuf = XLogInitBufferForRedo(record, 1);
datapos = XLogRecGetBlockData(record, 1, &datalen);
rpage = BufferGetPage(rbuf);
@@ -468,6 +685,56 @@ btree_xlog_dedup(XLogReaderState *record)
BTDedupState state;
BTDedupInterval *intervals;
Page newpage;
+ bool pageprefix; /* source page is BTP_PREFIX */
+ BTItemBuf curbuf; /* decode scratch for the current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
+
+ if (xlrec->prefixlen > 0)
+ {
+ /*
+ * Prefix-dedup: the deduped result was carried as the chosen prefix
+ * bytes, then (unless rightmost) the high key, then the deduped
+ * plain items. Rebuild the coded page byte-for-byte the way the
+ * primary did, using the same layout routine as a prefix leaf split.
+ */
+ Size datalen;
+ char *datapos = XLogRecGetBlockData(record, 0, &datalen);
+ const char *prefix = datapos;
+ IndexTuple hikey = NULL;
+ IndexTuple items[MaxIndexTuplesPerPage];
+ int nitems = 0;
+
+ datapos += xlrec->prefixlen;
+ datalen -= xlrec->prefixlen;
+ if (xlrec->flags & XLH_DEDUP_NOT_RIGHTMOST)
+ {
+ hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(hikey));
+ datalen -= MAXALIGN(IndexTupleSize(hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nitems < MaxIndexTuplesPerPage);
+ items[nitems++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ newpage = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(newpage)->btpo_flags &= ~(BTP_PREFIX | BTP_HAS_GARBAGE);
+ if (!_bt_split_leaf_layout(newpage, hikey, items, nitems,
+ prefix, xlrec->prefixlen))
+ elog(ERROR, "failed to replay prefix deduplication");
+ PageRestoreTempPage(newpage, page);
+ PageSetLSN(page, lsn);
+ MarkBufferDirty(buf);
+ UnlockReleaseBuffer(buf);
+ return;
+ }
state = palloc_object(BTDedupStateData);
state->deduplicate = true; /* unused */
@@ -487,6 +754,19 @@ btree_xlog_dedup(XLogReaderState *record)
maxoff = PageGetMaxOffsetNumber(page);
newpage = PageGetTempPageCopySpecial(page);
+ /*
+ * The source page may be prefix-compressed even on the ordinary
+ * (non-prefix) dedup path: that happens when the primary deduplicated
+ * a prefix page but re-compression of the result declined, leaving a
+ * plain page (prefixlen == 0, no full-page image). Reproduce the
+ * primary: lay the result out plain and decode each coded data item
+ * before feeding it to the dedup state machine. The high key is a
+ * pivot and is never coded.
+ */
+ pageprefix = P_HASPREFIX(opaque);
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
if (!P_RIGHTMOST(opaque))
{
ItemId itemid = PageGetItemId(page, P_HIKEY);
@@ -505,8 +785,18 @@ btree_xlog_dedup(XLogReaderState *record)
ItemId itemid = PageGetItemId(page, offnum);
IndexTuple itup = (IndexTuple) PageGetItem(page, itemid);
+ /* Decode on a prefix source so keys/posting lists are real. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
+ {
+ /* base must survive the whole run; keep it in its own buffer */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
+ }
else if (state->nintervals < xlrec->nintervals &&
state->baseoff == intervals[state->nintervals].baseoff &&
state->nitems < intervals[state->nintervals].nitems)
@@ -516,12 +806,15 @@ btree_xlog_dedup(XLogReaderState *record)
}
else
{
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
Assert(state->nintervals == xlrec->nintervals);
Assert(memcmp(state->intervals, intervals,
state->nintervals * sizeof(BTDedupInterval)) == 0);
@@ -621,11 +914,23 @@ btree_xlog_vacuum(XLogReaderState *record)
if (xlrec->ndeleted > 0)
PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * Mirror the primary: an emptied prefix-compressed leaf drops its
+ * PREFIX item and the BTP_PREFIX flag (see _bt_delitems_vacuum).
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* Clear the vacuum cycle ID, and mark the page as not containing any
* LP_DEAD items
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..ca7e6852542 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,61 @@ 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 format and code).
+ *
+ * A leaf of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page holds one extra item, the PREFIX item, at BTPageGetPrefixOff (where
+ * its first data item would otherwise be); its data items start one offset
+ * later, so P_FIRSTDATAKEY -- which every loop over a page's data items
+ * starts from -- accounts for the flag. The PREFIX item is an
+ * IndexTupleData header (t_tid zero, t_info its size and nothing else, so
+ * that code walking a page's items by IndexTupleSize, such as split redo,
+ * steps over it) followed by 1..BTREE_PREFIX_MAX prefix bytes. The prefix is
+ * chosen only when a page is written whole (CREATE INDEX, or a half of a
+ * split) and never changes after that; a page's high key, which fixes the
+ * prefix item's offset, also changes only in a split.
+ *
+ * On a BTP_PREFIX page, every non-pivot tuple (plain or posting) whose first
+ * key column is not NULL stores that column coded, as a varlena (1-byte
+ * header when it fits one, else 4-byte) whose payload starts with a uint8
+ * `shared':
+ *
+ * shared <= BTREE_PREFIX_MAX: the value's data bytes are the page prefix's
+ * first `shared' bytes followed by the rest of the payload.
+ * The value's own varlena header is recomputed on decode (the
+ * form index_form_tuple gives it: 1-byte when it fits, else
+ * 4-byte uncompressed).
+ * shared == BTREE_PREFIX_RAW: the rest of the payload is the value exactly
+ * as stored, header included. Used for a value compressed
+ * inline, or whose header is not the canonical form above.
+ *
+ * The bytes after the first column (later key columns, INCLUDE columns, the
+ * posting list, and alignment padding) are kept as they are, moved to keep
+ * their alignment, or left out when they are only the key's closing zero
+ * padding (decode puts them back). A posting list follows the key at the new
+ * key end, and the posting offset in t_tid is changed to match. Pivots, NULL
+ * first columns, and the high key are stored as on any other page.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo can repeat them, and decoding gives back the plain
+ * tuple byte for byte.
+ */
+#define BTREE_PREFIX_MAX 126
+#define BTREE_PREFIX_RAW 127 /* shared count of a raw value */
+#define BTREE_PREFIX_SHARED_MASK 0x7F /* shared count bits */
+#define BTREE_PREFIX_REST 0x80 /* bytes after the column follow */
+
+/* A leaf whose tuples share less of its prefix than this, on average, has none. */
+#define BTREE_PREFIX_MIN_SHARED 4
+
+#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 +732,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.
@@ -1122,6 +1208,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 +1224,33 @@ 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))
+
+/*
+ * Size of one half (currTuples or markTuples) of an index-only scan's tuple
+ * storage. A plain leaf's live non-pivot tuples fit in one page, so BLCKSZ
+ * would suffice for them. A prefix-compressed (BTP_PREFIX) leaf can hold more
+ * logical data than fits plain: when its tuples are decoded for the scan they
+ * can exceed BLCKSZ. Because _bt_getitem() decodes based on the page flag,
+ * not on the current reloption, and a BTP_PREFIX page can persist after
+ * prefix_compression is turned off (the flag is cleared only when a page is
+ * split, emptied or dedup-rewritten -- never by ALTER INDEX), ANY btree index
+ * may contain such a page. The buffer must therefore always reserve the slack
+ * for a decoded page, independent of the live reloption; sizing it on the
+ * option would overflow currTuples/markTuples on a surviving prefix leaf.
+ *
+ * The bound is the most distinct index tuples a leaf can hold (each at least a
+ * line pointer plus a minimally coded tuple) times the most a single first
+ * column can regain on decode (the page prefix, at most BTREE_PREFIX_MAX
+ * bytes, MAXALIGNed).
+ */
+#define BTTupleStorageSize(relation) \
+ (BLCKSZ + (BLCKSZ / (int) MAXALIGN(sizeof(IndexTupleData) + 2)) * \
+ (int) MAXALIGN(BTREE_PREFIX_MAX))
/*
* Constant definition for progress reporting. Phase numbers must match
@@ -1202,7 +1316,8 @@ extern bool _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
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 Size _bt_dedup_finish_pending(Page newpage, BTDedupState state,
+ bool overflowok);
extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids,
int nhtids);
extern void _bt_update_posting(BTVacuumPosting vacposting);
@@ -1315,6 +1430,34 @@ 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 void _bt_page_set_prefix(Page page, const char *prefix, Size len);
+extern IndexTuple _bt_prefix_encode(Page page, IndexTuple itup);
+extern Size _bt_prefix_coded_size(Page page, IndexTuple itup);
+extern bool _bt_prefix_enabled(Relation index);
+extern const char *_bt_prefix_candidate(IndexTuple itup, Size *len);
+extern int _bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup);
+extern Size _bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix);
+extern void _bt_prefix_compress_page(Relation index, Page page);
+extern void _bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen);
+extern bool _bt_split_leaf_layout(Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *prefix, Size prefixlen);
+extern void _bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft,
+ OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright);
+
/*
* prototypes for functions in nbtvalidate.c
*/
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..285eac6f1aa 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -156,25 +156,59 @@ 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 */
+ uint16 flags; /* XLH_SPLIT_* below */
+ uint8 leftprefixlen; /* leaf: left half's prefix length (0 none) */
+ uint8 rightprefixlen; /* leaf: right half's prefix length (0 none) */
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, rightprefixlen) + sizeof(uint8))
+
+/*
+ * xl_btree_split flag bits.
+ *
+ * A prefix-compressed leaf split logs no full-page image in the common case:
+ * redo rebuilds each half the way the primary did. The left half is
+ * reconstructed from the pre-split original page (block 0) together with the
+ * logged new item and high key; the right half is reconstructed from its plain
+ * data items, which are logged as block-1 data. Each half's chosen prefix
+ * bytes are logged too (block 0 for the left half, block 1 for the right), and
+ * applied with _bt_split_leaf_layout(); redo cannot run the prefix-choosing
+ * heuristic itself, so the primary logs the prefix it chose.
+ * leftprefixlen/rightprefixlen give the lengths (0 means the half is not
+ * prefix-compressed). XLH_SPLIT_LEAFPREFIX marks a leaf split that uses this
+ * path at all (so redo rebuilds coded rather than replaying items).
+ */
+#define XLH_SPLIT_LEAFPREFIX 0x01 /* leaf split, rebuild halves coded */
+#define XLH_SPLIT_RFPI 0x08 /* right half carried by a full-page image
+ * (its plain items would overflow the WAL
+ * record); redo restores it from the image */
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
* merged together into posting list tuples.
*
- * The WAL record represents a deduplication pass for a leaf page. An array
- * of BTDedupInterval structs follows.
+ * The WAL record represents a deduplication pass for a leaf page. Normally
+ * (prefixlen == 0) an array of BTDedupInterval structs follows and redo
+ * replays them against the old page. When the page is prefix-compressed
+ * (prefixlen > 0) the deduped result is instead carried as the chosen prefix
+ * bytes (prefixlen) followed by, when not rightmost, the high key, then the
+ * deduped plain data items -- each MAXALIGN'd -- and redo lays them out coded
+ * with _bt_split_leaf_layout(), avoiding a full-page image.
*/
typedef struct xl_btree_dedup
{
uint16 nintervals;
+ uint8 prefixlen; /* >0: prefix-dedup payload, see above */
+ uint8 flags; /* XLH_DEDUP_* */
- /* DEDUPLICATION INTERVALS FOLLOW */
+ /* DEDUPLICATION INTERVALS or PREFIX PAYLOAD FOLLOW */
} xl_btree_dedup;
-#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, flags) + sizeof(uint8))
+
+/* xl_btree_dedup flag bits */
+#define XLH_DEDUP_NOT_RIGHTMOST 0x01 /* prefix payload includes a high key */
+#define XLH_DEDUP_RFPI 0x02 /* prefix page carried as a forced image */
/*
* This is what we need to know about page reuse within btree. This record
diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile
index 20a1ca6a92b..ca0779e9b57 100644
--- a/src/test/modules/nbtree/Makefile
+++ b/src/test/modules/nbtree/Makefile
@@ -3,7 +3,8 @@
EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck
REGRESS = nbtree_half_dead_pages \
- nbtree_incomplete_splits
+ nbtree_incomplete_splits \
+ nbtree_prefix_rfpi
ISOLATION = backwards-scan-concurrent-splits \
predicate-empty-index
diff --git a/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
new file mode 100644
index 00000000000..1614ad0186b
--- /dev/null
+++ b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
@@ -0,0 +1,77 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+ bt_index_parent_check
+-----------------------
+
+(1 row)
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+ forced_rfpi_ok
+----------------
+ t
+(1 row)
+
+reset enable_seqscan;
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+ injection_points_detach
+-------------------------
+
+(1 row)
+
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build
index b5dc026392e..d6a34e265be 100644
--- a/src/test/modules/nbtree/meson.build
+++ b/src/test/modules/nbtree/meson.build
@@ -12,6 +12,7 @@ tests += {
'sql': [
'nbtree_half_dead_pages',
'nbtree_incomplete_splits',
+ 'nbtree_prefix_rfpi',
],
},
'isolation': {
diff --git a/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
new file mode 100644
index 00000000000..45899e5796f
--- /dev/null
+++ b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
@@ -0,0 +1,59 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+reset enable_seqscan;
+
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out
index 7c53d598649..c1dae4b55ad 100644
--- a/src/test/regress/expected/btree_index.out
+++ b/src/test/regress/expected/btree_index.out
@@ -891,3 +891,259 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql
index 6990d7818da..6cd053a1596 100644
--- a/src/test/regress/sql/btree_index.sql
+++ b/src/test/regress/sql/btree_index.sql
@@ -520,3 +520,170 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b964e5ee907..c3fb56802f6 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,7 @@ BTPageStat
BTPageState
BTParallelScanDesc
BTPendingFSM
+BTPrefixPlan
BTReadPageState
BTScanInsert
BTScanInsertData
--
2.50.1
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-29 16:42 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-30 11:52 ` Re: nbtree: opt-in leaf key prefix compression Yao Feng <fengyao0087@gmail.com>
2026-10-30 15:28 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-11-03 17:48 ` Greg Burd <greg@burd.me>
2026-11-03 21:36 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
0 siblings, 1 reply; 12+ messages in thread
From: Greg Burd @ 2026-11-03 17:48 UTC (permalink / raw)
To: pgsql-hackers@lists.postgresql.org; +Cc: fengyao0087@gmail.com; pg@bowt.ie
Hello,
v7 attached, one real fix since v4, found by the sanitizer CI.
CI was red on the Linux Autoconf and 32-bit legs (the ones built with
-fsanitize=alignment,undefined -fno-sanitize-recover=all) while every
other leg (64-bit meson, macOS, Windows) was green. The standby's
startup process aborted (SIGABRT) replaying a prefix leaf split:
itup.h:74: runtime error: member access within misaligned address
... for type 'const struct IndexTupleData'
#1 btree_xlog_split_prefix nbtxlog.c
#2 btree_xlog_split
#3 btree_redo
The split/dedup WAL payload for the right half is [chosen prefix bytes]
[high key][data items]. I was advancing past the prefix bytes by the
raw prefix length, not a MAXALIGN'd length, so the IndexTuple high key
(and the items after it) landed on an unaligned offset. On x86-64 the
misaligned read happens to work, which is why plain cassert builds and
my own testing never caught it; the alignment sanitizer correctly
rejects it, and a big-endian or stricter-alignment platform would fault
for real.
Fix: MAXALIGN the prefix region on both the logging and the replay side,
in the split path (_bt_split / btree_xlog_split_prefix) and the matching
dedup path (_bt_dedup_pass / btree_xlog_dedup). The left-half prefix is
logged last and read back as raw bytes, never dereferenced as a tuple,
so it was already fine and is unchanged.
The lesson for me is that WAL-payload packing has to be tested under the
alignment sanitizer, not just cassert.
No other changes from v4. For the committer (I hope!), a BTREE_VERSION
and catalog version bump.
best.
-greg
Attachments:
v7-0001-nbtree-opt-in-leaf-key-prefix-compression.patch (text/plain, 170.4K) download
From a1df37c393d561959540fa05ca438ac711902f9b Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 15:25:58 -0400
Subject: [PATCH v7] nbtree: opt-in leaf key prefix compression
Add leaf key prefix compression to nbtree, controlled by a new
per-index storage parameter, prefix_compression, that is off by default.
When a leaf page is written as a whole -- during an index build, as a
split half, or as the result of a deduplication pass -- and its data
items share a leading byte-prefix of their first key column, the page is
rebuilt in the BTP_PREFIX format: a single PREFIX item after the high
key holds the shared bytes, and each data item is stored "coded" (its
copy of the shared prefix replaced by a one-byte length). Reads of a
leaf data item funnel through _bt_getitem(), which decodes on demand;
high keys and internal-page downlinks are never coded and are read with
the existing PageGetItem().
The feature is deliberately a per-page opt-in with no on-disk version
change: BTREE_VERSION and the catalog version are untouched. A page is
converted only when it is next written whole, never by an insert, VACUUM
or key-range change, so compression "faults forward" on an existing
index. Old-format and new-format pages coexist in the same index, which
keeps pg_upgrade, physical replication, and standbys working with no
REINDEX: a replica that has not yet seen a page's conversion still reads
it correctly, and turning the parameter off (ALTER INDEX ... SET) stops
new conversions while leaving existing coded pages readable.
Prefix compression is a separate mechanism from deduplication, not an
extension of it: deduplication collapses tuples whose whole key is
equal, while prefix compression factors out a leading run shared by
distinct keys (a URL or jsonb-path column is the motivating case, where
deduplication does not help). The two compose on one page -- a build
deduplicates first and then codes the survivors, so a duplicate-heavy
prefix index is no larger than a plain one. Bottom-up index deletion
runs on coded pages unchanged, since it only removes items. A runtime
deduplication pass on a coded page decodes first, and because a coded
page can hold more logical data than fits plain, the pass aborts cleanly
and the page splits instead when the decoded set would overflow.
WAL replay reproduces the coded bytes without resorting to full-page
images in the common paths. A Relation-free layout routine,
_bt_split_leaf_layout(), together with a single item-collection routine,
_bt_split_leaf_collect(), are shared by the primary (_bt_split) and redo
(btree_xlog_split) so both derive the same plain item sequence and code
it against the same logged prefix, emitting identical bytes. An
in-place insert logs the coded tuple; a split logs each half's chosen
prefix plus the right half's plain items; a prefix deduplication logs
the chosen prefix plus the deduped plain items. A bounded fallback
carries the page as a forced full-page image only when a pathologically
compressible page's plain payload would exceed one block; realistic data
never reaches that threshold, so the nbtree-prefix-force-rfpi injection
point and a dedicated test in src/test/modules/nbtree exercise the
fallback deterministically.
amcheck decodes every leaf data item, including under heapallindexed,
and checks the BTP_PREFIX structural invariants (flag only on leaf
pages, a well-formed PREFIX item at the expected offset); its test suite
covers a prefix-compressed index. The create_index documentation and
the nbtree README describe the parameter, the on-disk format, and the
relationship to deduplication. Tested with wal_consistency_checking=all
on a primary and a streaming standby across insert, split, deduplication
(including the forced-image fallback), delete and vacuum workloads,
through crash recovery, and across a pg_upgrade that carries both page
formats.
---
contrib/amcheck/expected/check_btree.out | 27 +
contrib/amcheck/sql/check_btree.sql | 18 +
contrib/amcheck/verify_nbtree.c | 120 ++-
doc/src/sgml/ref/create_index.sgml | 35 +
src/backend/access/common/reloptions.c | 10 +
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 97 +++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 269 +++++-
src/backend/access/nbtree/nbtinsert.c | 378 ++++++--
src/backend/access/nbtree/nbtpage.c | 31 +-
src/backend/access/nbtree/nbtprefix.c | 814 ++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 23 +-
src/backend/access/nbtree/nbtree.c | 6 +-
src/backend/access/nbtree/nbtsearch.c | 3 +-
src/backend/access/nbtree/nbtsort.c | 54 +-
src/backend/access/nbtree/nbtsplitloc.c | 84 +-
src/backend/access/nbtree/nbtutils.c | 4 +-
src/backend/access/nbtree/nbtxlog.c | 313 ++++++-
src/include/access/nbtree.h | 147 +++-
src/include/access/nbtxlog.h | 44 +-
src/test/modules/nbtree/Makefile | 3 +-
.../nbtree/expected/nbtree_prefix_rfpi.out | 77 ++
src/test/modules/nbtree/meson.build | 1 +
.../modules/nbtree/sql/nbtree_prefix_rfpi.sql | 59 ++
src/test/regress/expected/btree_index.out | 256 ++++++
src/test/regress/sql/btree_index.sql | 167 ++++
src/tools/pgindent/typedefs.list | 2 +
28 files changed, 2929 insertions(+), 115 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
create mode 100644 src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
create mode 100644 src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
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..18551483e88 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -158,7 +158,12 @@ typedef struct BtreeLastVisibleEntry
OffsetNumber offset; /* Offset on index block */
int postingIndex; /* Number in the posting list (-1 for
* non-deduplicated tuples) */
- ItemPointer tid; /* Heap tid */
+ ItemPointer tid; /* Heap tid, or NULL; points at tiddata */
+ ItemPointerData tiddata; /* Own storage for *tid: a prefix-coded leaf
+ * tuple is decoded into a scratch buffer that
+ * is reused on the next loop iteration, so we
+ * must keep the heap TID by value, not point
+ * into the (transient) decoded tuple */
} BtreeLastVisibleEntry;
/*
@@ -935,7 +940,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
lVis->postingIndex = i;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
}
}
}
@@ -961,7 +967,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
lVis->postingIndex = -1;
}
}
@@ -1229,6 +1236,16 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber max;
BTPageOpaque topaque;
+ /*
+ * Scratch space for decoding prefix-coded leaf tuples (_bt_getitem).
+ * itupbuf backs the current loop item; crossbuf backs the right sibling's
+ * first item in the cross-page unique check, which must coexist with the
+ * current item's state. A pivot, high key or non-prefix leaf item is
+ * returned by _bt_getitem unchanged (buffer unused).
+ */
+ BTItemBuf itupbuf;
+ BTItemBuf crossbuf;
+
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1266,6 +1283,54 @@ bt_target_page_check(BtreeCheckState *state)
}
}
+ /*
+ * Structural checks for a prefix-compressed (BTP_PREFIX) leaf. A reader
+ * trusts this format implicitly, so validate the parts that an out-of-
+ * place decode would otherwise read as garbage: the flag appears only on
+ * leaf pages, and the PREFIX item sits at the expected offset with a
+ * length in the legal range. The per-item key/content checks below run
+ * on the decoded tuple from _bt_getitem, which fails on a malformed coded
+ * item, so item-level coding is covered there.
+ */
+ if (P_HASPREFIX(topaque))
+ {
+ ItemId pitemid;
+ IndexTuple pitup;
+ Size prefixlen;
+ OffsetNumber prefixoff = BTPageGetPrefixOff(topaque);
+
+ if (!P_ISLEAF(topaque))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix compression flag set on non-leaf page in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u page lsn=%X/%08X.",
+ state->targetblock,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ if (prefixoff > max)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix-compressed page has no PREFIX item in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix offset=%u max offset=%u page lsn=%X/%08X.",
+ state->targetblock, prefixoff, max,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ pitemid = PageGetItemIdCareful(state, state->targetblock,
+ state->target, prefixoff);
+ pitup = (IndexTuple) PageGetItem(state->target, pitemid);
+ prefixlen = IndexTupleSize(pitup) - sizeof(IndexTupleData);
+ if (prefixlen < 1 || prefixlen > BTREE_PREFIX_MAX)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("PREFIX item has invalid length in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix length=%zu page lsn=%X/%08X.",
+ state->targetblock, prefixlen,
+ 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"
@@ -1293,8 +1358,15 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, state->targetblock,
state->target, offset);
- itup = (IndexTuple) PageGetItem(state->target, itemid);
- tupsize = IndexTupleSize(itup);
+
+ /*
+ * lp_len and the tuple size limit are physical properties of the item
+ * as stored, so they are checked against the raw on-page tuple (coded,
+ * on a BTP_PREFIX leaf). Every key/content check below instead uses
+ * the decoded tuple returned by _bt_getitem.
+ */
+ tupsize = IndexTupleSize((IndexTuple) PageGetItem(state->target, itemid));
+ itup = _bt_getitem(state->target, offset, &itupbuf);
/*
* lp_len should match the IndexTuple reported length exactly, since
@@ -1606,7 +1678,7 @@ 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), &itupbuf);
tid = BTreeTupleGetPointsToTID(itup);
nhtid = psprintf("(%u,%u)",
ItemPointerGetBlockNumberNoCheck(tid),
@@ -1793,7 +1865,7 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, rightblock_number,
rightpage,
rightfirstoffset);
- itup = (IndexTuple) PageGetItem(rightpage, itemid);
+ itup = _bt_getitem(rightpage, rightfirstoffset, &crossbuf);
bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis);
@@ -1857,6 +1929,8 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
BlockNumber targetnext;
Page rightpage;
OffsetNumber nline;
+ OffsetNumber rightoffset;
+ BTItemBuf firstbuf;
/* Determine target's next block number */
opaque = BTPageGetOpaque(state->target);
@@ -2016,6 +2090,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/* Return first data item (if any) */
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
P_FIRSTDATAKEY(opaque));
+ rightoffset = P_FIRSTDATAKEY(opaque);
*rightfirstoffset = P_FIRSTDATAKEY(opaque);
}
else if (!P_ISLEAF(opaque) &&
@@ -2027,6 +2102,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
*/
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
OffsetNumberNext(P_FIRSTDATAKEY(opaque)));
+ rightoffset = OffsetNumberNext(P_FIRSTDATAKEY(opaque));
}
else
{
@@ -2046,9 +2122,19 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/*
* Return first real item scankey. Note that this relies on right page
* memory remaining allocated.
+ *
+ * On a BTP_PREFIX leaf the first data item is prefix-coded, so decode it;
+ * because the returned scankey (built by _bt_mkscankey) references the
+ * tuple's own storage, and _bt_getitem decodes into a buffer local to
+ * this function, copy the decoded tuple into the caller-reset memory
+ * context so it outlives the call. A pivot or non-prefix item is
+ * returned by _bt_getitem unchanged (it lives in the right page, which
+ * remains allocated), so no copy is needed then.
*/
- firstitup = (IndexTuple) PageGetItem(rightpage, rightitem);
- return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ firstitup = _bt_getitem(rightpage, rightoffset, &firstbuf);
+ if (firstitup == (IndexTuple) PageGetItem(rightpage, rightitem))
+ return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ return bt_mkscankey_pivotsearch(state->rel, CopyIndexTuple(firstitup));
}
/*
@@ -3095,14 +3181,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);
@@ -3122,11 +3207,12 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key,
{
BTPageOpaque topaque;
IndexTuple ritup;
+ BTItemBuf ritupbuf;
int uppnkeyatts;
ItemPointer rheaptid;
bool nonpivot;
- ritup = (IndexTuple) PageGetItem(state->target, itemid);
+ ritup = _bt_getitem(state->target, upperbound, &ritupbuf);
topaque = BTPageGetOpaque(state->target);
nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque);
@@ -3218,14 +3304,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 */
@@ -3236,12 +3321,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key,
if (cmp == 0)
{
IndexTuple child;
+ BTItemBuf childbuf;
int uppnkeyatts;
ItemPointer childheaptid;
BTPageOpaque copaque;
bool nonpivot;
- child = (IndexTuple) PageGetItem(nontarget, itemid);
+ child = _bt_getitem(nontarget, upperbound, &childbuf);
copaque = BTPageGetOpaque(nontarget);
nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque);
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..7e66765c3d3 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -477,6 +477,41 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
</note>
</listitem>
</varlistentry>
+
+ <varlistentry id="index-reloption-prefix-compression" xreflabel="prefix_compression">
+ <term><literal>prefix_compression</literal> (<type>boolean</type>)
+ <indexterm>
+ <primary><varname>prefix_compression</varname> storage parameter</primary>
+ </indexterm>
+ </term>
+ <listitem>
+ <para>
+ Controls leaf key prefix compression. When set to
+ <literal>ON</literal>, and the index's first key column is of a
+ variable-length type, a leaf page may store a shared prefix of that
+ column once and record only each tuple's unshared remainder, which can
+ substantially shrink indexes on keys that share long leading bytes
+ (for example URLs or <type>jsonb</type> keys with common paths). Set
+ to <literal>ON</literal> or <literal>OFF</literal> to enable or disable
+ the optimization. The default is <literal>OFF</literal>.
+ </para>
+
+ <note>
+ <para>
+ 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 adopts a prefix only when
+ it is written as a whole (during an index build, or either half of a
+ page split), so turning <literal>prefix_compression</literal> on or off
+ via <command>ALTER INDEX</command> does not rewrite the index: existing
+ pages are left as they are and are converted the next time they are
+ written whole. No <command>REINDEX</command> is required, and indexes
+ upgraded with <application>pg_upgrade</application> keep working
+ unchanged.
+ </para>
+ </note>
+ </listitem>
+ </varlistentry>
</variablelist>
<para>
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..596e595f20b 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1081,3 +1081,100 @@ 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
+front-code the first key column of each of its data tuples against a per-page
+prefix, which shrinks indexes on keys that share long leading bytes (URLs,
+jsonb keys with common paths, zero-padded codes). Like deduplication, this
+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.
+
+A leaf that uses the technique carries the BTP_PREFIX page flag and one extra
+item, the PREFIX item, 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. The PREFIX item is an
+IndexTupleData header followed by 1..BTREE_PREFIX_MAX prefix bytes, shaped so
+that code which walks a page's items by IndexTupleSize (such as
+_bt_restore_page) steps over it. On such a page every non-pivot tuple (plain
+or posting) whose first key column is not NULL stores that column as a varlena
+whose payload begins with a one-byte count of how many leading bytes it shares
+with the page prefix, followed by the unshared remainder (or, for a value whose
+header is not the form index_form_tuple would reproduce, the whole value after
+a sentinel count). Trailing key columns, INCLUDE columns, a posting list and
+alignment padding follow, moved to preserve alignment; padding that is only the
+key's closing zeros is dropped and restored on decode. Pivots, NULL first
+columns and the high key are always stored uncompressed. Coding and decoding
+depend only on the page prefix and the tuple, never the catalog, so WAL redo
+reproduces them and decoding yields the original tuple byte for byte. All of
+nbtprefix.c implements this; _bt_getitem() is the one accessor through which
+readers obtain a decoded leaf data tuple.
+
+A page adopts a prefix only when it is written as a whole: a finished leaf
+during an index build, or either half of a leaf page split (built coded from
+the start by _bt_split_leaf_half, since a compressed leaf can hold more logical
+data than fits on a plain page, choosing each half's own prefix and falling
+back to a plain layout or the original page's prefix if its own does not fit).
+Crucially, no insert, VACUUM, deduplication or change of a page's key range
+ever rewrites a page to keep its prefix valid: a tuple that happens to share
+fewer bytes simply records a smaller shared count. 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 physical/logical replication keep
+working and existing indexes continue to work with no REINDEX. Turning the
+parameter on with ALTER INDEX does not rewrite anything; 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 (the flag is cleared
+only when a page is split, emptied or dedup-rewritten) and are still read back
+correctly with the parameter off, since every reader decodes based on the page
+flag, not on the current reloption. Scan-time workspace that must hold decoded
+tuples (the index-only-scan currTuples/markTuples buffers, see
+BTTupleStorageSize) is therefore sized for a decoded page on every btree index,
+not just those whose parameter is currently on.
+
+Leaf changes to a prefix page are replayed without a full-page image in the
+common case. Redo cannot run the prefix-choosing heuristic (it has no
+relcache), so the primary logs the prefix it chose and both sides lay the
+page out through one Relation-free routine, _bt_split_leaf_layout(), fed by
+one item-collection routine, _bt_split_leaf_collect(), that the primary
+(_bt_split) and redo (btree_xlog_split) both call. Because the two derive
+the plain item sequence the same way and code it against the same logged
+prefix, they produce byte-identical pages. An in-place insert logs the coded
+tuple (standard insert redo adds it verbatim); a split logs each half's
+chosen prefix plus the right half's plain items; a deduplication logs the
+chosen prefix plus the deduped plain items. A bounded fallback forces a
+full-page image only when a pathologically compressible page's plain payload
+would exceed BLCKSZ (so the logged payload can never exceed one block); redo
+then restores the image via BLK_RESTORED. When VACUUM or page deletion
+empties a prefix leaf, the PREFIX item is removed and the flag cleared so that
+the invariant that an emptied leaf holds only its high key (relied on by page
+deletion) still holds.
+
+Deduplication and bottom-up index deletion both run on BTP_PREFIX pages.
+Bottom-up deletion only removes items and never rewrites survivors, so it is
+safe on coded pages unchanged. A deduplication pass decodes the page's items,
+merges duplicates into posting lists and re-compresses the result as a whole;
+because a coded page can hold more logical data than fits plain, if the
+decoded set would overflow a plain page the pass aborts cleanly and the page
+splits instead, so no in-place rewrite is ever forced to fail. An index build
+deduplicates before coding the finished page, so a duplicate-heavy prefix
+index is no larger than a plain one.
+
+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 byte 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 (deduplicate first, then code the
+survivors), which is why a build applies both. Reusing the posting-list
+format to carry a shared prefix was considered and rejected: a posting list
+stores one key plus many TIDs, whereas prefix coding stores many keys that
+share a prefix, so the two would need different per-item layouts anyway, and
+overloading posting lists would entangle the deduplication and compression
+paths that are kept separate here on purpose.
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..e4b9bc47f90 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -19,6 +19,7 @@
#include "access/tableam.h"
#include "access/xloginsert.h"
#include "miscadmin.h"
+#include "utils/injection_point.h"
#include "utils/rel.h"
static void _bt_bottomupdel_finish_pending(Page page, BTDedupState state,
@@ -69,7 +70,19 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Size pagesaving PG_USED_FOR_ASSERTS_ONLY = 0;
bool singlevalstrat = false;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
XLogRecPtr recptr;
+ IndexTuple *dedup_plain = NULL; /* deduped plain items, for prefix WAL */
+ int dedup_nplain = 0;
+ IndexTuple dedup_hikey = NULL;
+ char dedup_prefix[BTREE_PREFIX_MAX];
+ Size dedup_prefixlen = 0;
+ bool dedup_islogprefix = false; /* page ended up prefix-coded */
+ char *dedup_walbuf = NULL; /* packed prefix-dedup WAL payload */
+ Size dedup_walbuflen = 0;
+ bool dedup_rfpi = false; /* fall back to a forced full-page image */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -120,7 +133,16 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
newpage = PageGetTempPageCopySpecial(page);
PageSetLSN(newpage, PageGetLSN(page));
- /* Copy high key, if any */
+ /*
+ * The rebuilt page is laid out plain here (clear any inherited BTP_PREFIX;
+ * PageGetTempPageCopySpecial copies the special area but not the PREFIX
+ * item). If the page was prefix-compressed it is re-compressed as a whole
+ * below, after deduplication, honouring Strategy 1.
+ */
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
+ /* Copy high key, if any (a pivot, stored plain on any page) */
if (!P_RIGHTMOST(opaque))
{
ItemId hitemid = PageGetItemId(page, P_HIKEY);
@@ -140,12 +162,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Assert(!ItemIdIsDead(itemid));
+ /* Decode on a prefix page so keys/posting lists are the real ones. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/*
* No previous/base tuple for the data item -- use the data item
- * as base tuple of pending posting list
+ * as base tuple of pending posting list. On a prefix page the
+ * base must survive the whole merge run (it is kept as a pointer
+ * in state and read by _bt_dedup_finish_pending), so keep it in a
+ * dedicated buffer rather than the per-iteration curbuf.
*/
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (state->deduplicate &&
@@ -170,7 +202,20 @@ _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);
+ /*
+ * On a prefix page the deduped items are laid out plain here; the
+ * decoded set can exceed one page even when the coded form fit, so
+ * tolerate an overflow and abort the whole pass (the caller then
+ * splits the page instead of deduplicating it).
+ */
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state,
+ pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
if (singlevalstrat)
{
@@ -198,12 +243,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
}
/* itup starts new pending posting list */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page,
+ (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
/* Handle the last item */
- pagesaving += _bt_dedup_finish_pending(newpage, state);
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state, pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
/*
* If no items suitable for deduplication were found, newpage must be
@@ -238,6 +293,113 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
nopaque->btpo_flags &= ~BTP_HAS_GARBAGE;
}
+ /*
+ * Re-apply prefix compression to the rebuilt (plain) page as a whole
+ * (Strategy 1), before the critical section since it allocates a scratch
+ * page. First capture the deduped plain items and high key: if the page
+ * does become prefix-compressed, redo rebuilds its coded bytes from these
+ * plus the chosen prefix (via _bt_split_leaf_layout), the same way a prefix
+ * leaf split is replayed -- no full-page image needed.
+ */
+ if (pageprefix)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(newpage);
+ OffsetNumber nmin = P_FIRSTDATAKEY(nopq);
+ OffsetNumber nmax = PageGetMaxOffsetNumber(newpage);
+
+ dedup_nplain = (nmax >= nmin) ? (int) nmax - (int) nmin + 1 : 0;
+ if (dedup_nplain > 0)
+ {
+ dedup_plain = palloc(dedup_nplain * sizeof(IndexTuple));
+ for (int i = 0; i < dedup_nplain; i++)
+ dedup_plain[i] = CopyIndexTuple((IndexTuple)
+ PageGetItem(newpage,
+ PageGetItemId(newpage, nmin + i)));
+ }
+ if (!P_RIGHTMOST(nopq))
+ {
+ ItemId hii = PageGetItemId(newpage, P_HIKEY);
+
+ dedup_hikey = CopyIndexTuple((IndexTuple) PageGetItem(newpage, hii));
+ }
+
+ _bt_prefix_compress_page(rel, newpage);
+
+ /* Did it actually compress? (It gives up if a coded item won't fit.) */
+ if (P_HASPREFIX(BTPageGetOpaque(newpage)))
+ {
+ const char *pfx = _bt_page_get_prefix(newpage, &dedup_prefixlen);
+
+ if (dedup_prefixlen > 0)
+ memcpy(dedup_prefix, pfx, dedup_prefixlen);
+ dedup_islogprefix = true;
+ }
+ }
+
+ /*
+ * Pack the prefix-dedup WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the chosen prefix bytes, then the high
+ * key (unless rightmost), then the deduped plain items, each MAXALIGN'd,
+ * in one contiguous buffer logged with a single rdata slot.
+ */
+ if (dedup_islogprefix)
+ {
+ Size off = MAXALIGN(dedup_prefixlen);
+
+ if (dedup_hikey != NULL)
+ off += MAXALIGN(IndexTupleSize(dedup_hikey));
+ for (int i = 0; i < dedup_nplain; i++)
+ off += MAXALIGN(IndexTupleSize(dedup_plain[i]));
+
+ /*
+ * A pathologically compressible page can hold more plain data than
+ * fits in one page; if the payload would be that large, fall back to
+ * a forced full-page image of the coded page (bounded at BLCKSZ).
+ */
+ if (off > BLCKSZ)
+ dedup_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ /* Let a test force the otherwise-rare full-page-image fallback. */
+ if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ dedup_rfpi = true;
+#endif
+ if (dedup_rfpi)
+ ; /* image forced; no payload buffer built */
+ else
+ {
+ dedup_walbuflen = off;
+ dedup_walbuf = palloc(Max(off, 1));
+
+ off = 0;
+ if (dedup_prefixlen > 0)
+ {
+ memcpy(dedup_walbuf, dedup_prefix, dedup_prefixlen);
+ if (MAXALIGN(dedup_prefixlen) != dedup_prefixlen)
+ memset(dedup_walbuf + dedup_prefixlen, 0,
+ MAXALIGN(dedup_prefixlen) - dedup_prefixlen);
+ off += MAXALIGN(dedup_prefixlen);
+ }
+ if (dedup_hikey != NULL)
+ {
+ Size hsz = IndexTupleSize(dedup_hikey);
+
+ memcpy(dedup_walbuf + off, dedup_hikey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(dedup_walbuf + off + hsz, 0, MAXALIGN(hsz) - hsz);
+ off += MAXALIGN(hsz);
+ }
+ for (int i = 0; i < dedup_nplain; i++)
+ {
+ Size sz = IndexTupleSize(dedup_plain[i]);
+
+ memcpy(dedup_walbuf + off, dedup_plain[i], sz);
+ if (MAXALIGN(sz) != sz)
+ memset(dedup_walbuf + off + sz, 0, MAXALIGN(sz) - sz);
+ off += MAXALIGN(sz);
+ }
+ }
+ }
+
START_CRIT_SECTION();
PageRestoreTempPage(newpage, page);
@@ -248,19 +410,38 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
{
xl_btree_dedup xlrec_dedup;
- xlrec_dedup.nintervals = state->nintervals;
+ xlrec_dedup.nintervals = dedup_islogprefix ? 0 : state->nintervals;
+ xlrec_dedup.prefixlen = (uint8) dedup_prefixlen;
+ xlrec_dedup.flags = (dedup_hikey != NULL) ? XLH_DEDUP_NOT_RIGHTMOST : 0;
+ if (dedup_rfpi)
+ xlrec_dedup.flags |= XLH_DEDUP_RFPI;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ (dedup_rfpi ? REGBUF_FORCE_IMAGE : 0));
XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
- /*
- * The intervals array is not in the buffer, but pretend that it is.
- * When XLogInsert stores the whole buffer, the array need not be
- * stored too.
- */
- XLogRegisterBufData(0, state->intervals,
- state->nintervals * sizeof(BTDedupInterval));
+ if (dedup_islogprefix)
+ {
+ /*
+ * Prefix-dedup: carry the chosen prefix and the deduped plain
+ * items so redo can lay out the coded page; no full-page image
+ * (unless the payload was too large, handled by the forced image).
+ */
+ if (!dedup_rfpi && dedup_walbuflen > 0)
+ XLogRegisterBufData(0, dedup_walbuf, dedup_walbuflen);
+ }
+ else
+ {
+ /*
+ * The intervals array is not in the buffer, but pretend that it
+ * is. When XLogInsert stores the whole buffer, the array need not
+ * be stored too.
+ */
+ XLogRegisterBufData(0, state->intervals,
+ state->nintervals * sizeof(BTDedupInterval));
+ }
recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DEDUP);
}
@@ -277,6 +458,20 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
/* cannot leak memory here */
pfree(state->htids);
pfree(state);
+ return;
+
+dedup_overflow:
+
+ /*
+ * A prefix page's deduped items did not fit when laid out plain. Leave
+ * the original page untouched and return without writing anything; the
+ * caller falls through to split the page. No critical section was
+ * entered and the buffer was not marked dirty, so the page is unchanged.
+ */
+ Assert(pageprefix);
+ pfree(newpage);
+ pfree(state->htids);
+ pfree(state);
}
/*
@@ -318,6 +513,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
TM_IndexDeleteOp delstate;
bool neverdedup;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -371,9 +569,24 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
Assert(!ItemIdIsDead(itemid));
+ /*
+ * On a prefix-compressed page decode the item so _bt_keep_natts_fast
+ * sees the real key. The base of a pending interval must survive the
+ * whole run (it is compared against each following item), so keep it
+ * in its own buffer; the current item uses a reused buffer. Heap TIDs
+ * and posting lists are preserved by coding, so _bt_dedup_start_pending
+ * and _bt_bottomupdel_finish_pending (which read only TIDs/sizes, by
+ * page offset) need no decode.
+ */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/* itup starts first pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
@@ -387,6 +600,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
_bt_bottomupdel_finish_pending(page, state, &delstate);
/* itup starts new pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
@@ -554,7 +770,7 @@ _bt_dedup_save_htid(BTDedupState state, IndexTuple itup)
* where no deduplication was possible.
*/
Size
-_bt_dedup_finish_pending(Page newpage, BTDedupState state)
+_bt_dedup_finish_pending(Page newpage, BTDedupState state, bool overflowok)
{
OffsetNumber tupoff;
Size tuplesz;
@@ -572,7 +788,11 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
spacesaving = 0;
}
@@ -591,13 +811,26 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(final)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ pfree(final);
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
pfree(final);
spacesaving = state->phystupsize - (tuplesz + sizeof(ItemIdData));
/* Increment nintervals, since we wrote a new posting list tuple */
state->nintervals++;
- Assert(spacesaving > 0 && spacesaving < BLCKSZ);
+
+ /*
+ * On an ordinary page the merged interval's tuples were all physically
+ * present on one page, so the saving is bounded by BLCKSZ. On a prefix
+ * page (overflowok) phystupsize is the DECODED size of those tuples,
+ * which can exceed a page (a coded page holds more logical data than
+ * fits plain), so only require the saving to be positive there.
+ */
+ Assert(spacesaving > 0 && (overflowok || spacesaving < BLCKSZ));
}
/* Reset state for next pending posting list */
@@ -783,16 +1016,22 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state,
OffsetNumber minoff, IndexTuple newitem)
{
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(BTPageGetOpaque(page));
ItemId itemid;
IndexTuple itup;
+ BTItemBuf buf;
itemid = PageGetItemId(page, minoff);
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &buf);
if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts)
{
itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page));
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &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..b187d1896d6 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -348,8 +348,8 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate)
P_ISLEAF(opaque) &&
!P_IGNORE(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
@@ -1135,6 +1135,11 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple codeditup; /* itup coded for the page, or itup */
+ Size codeditemsz; /* its MAXALIGNed size */
+ IndexTuple codednposting = NULL; /* nposting coded for the page */
+ BTItemBuf opostbuf; /* decode workspace for an existing posting */
+ bool pageprefix; /* target page is BTP_PREFIX */
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1143,6 +1148,7 @@ _bt_insertonpg(Relation rel,
isroot = P_ISROOT(opaque);
isrightmost = P_RIGHTMOST(opaque);
isonly = P_LEFTMOST(opaque) && P_RIGHTMOST(opaque);
+ pageprefix = P_HASPREFIX(opaque);
/* child buffer must be given iff inserting on an internal page */
Assert(isleaf == !BufferIsValid(cbuf));
@@ -1182,6 +1188,9 @@ _bt_insertonpg(Relation rel,
*/
Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage);
oposting = (IndexTuple) PageGetItem(page, itemid);
+ /* On a prefix page the existing posting is coded; decode for the swap. */
+ if (pageprefix)
+ oposting = _bt_prefix_decode(page, oposting, &opostbuf);
/*
* postingoff value comes from earlier call to _bt_binsrch_posting().
@@ -1213,19 +1222,50 @@ _bt_insertonpg(Relation rel,
newitemoff = OffsetNumberNext(newitemoff);
}
+ /*
+ * On a prefix-compressed page the new item is stored coded, which is at
+ * most MAXALIGN larger than plain and usually much smaller. Size the fit
+ * test and the placement by the coded size. (Split is handled on plain
+ * tuples; _bt_split re-compresses each half itself.)
+ */
+ if (pageprefix)
+ {
+ codeditup = _bt_prefix_encode(page, itup);
+ codeditemsz = MAXALIGN(IndexTupleSize(codeditup));
+ /*
+ * A posting-list split replaces the existing coded posting with a
+ * re-coded one; encode it here, before the critical section, since
+ * _bt_prefix_encode() allocates.
+ */
+ if (nposting != NULL)
+ codednposting = _bt_prefix_encode(page, nposting);
+ }
+ else
+ {
+ codeditup = itup;
+ codeditemsz = itemsz;
+ }
+
/*
* Do we need to split the page to fit the item on it?
*
* 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.
+ * only for the item and not for its line pointer. The coded item size is
+ * used; a posting-list split additionally changes the posting tuple's
+ * size, but that can only shrink or keep free space (the new posting has
+ * one fewer TID's worth of room reused by newitem), so the item fit test
+ * remains a safe upper bound.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < codeditemsz)
{
Buffer rbuf;
Assert(!split_only_page);
+ if (codeditup != itup)
+ pfree(codeditup);
+
/* split the buffer into left and right halves */
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
@@ -1295,9 +1335,25 @@ _bt_insertonpg(Relation rel,
START_CRIT_SECTION();
if (postingoff != 0)
- memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ {
+ if (pageprefix)
+ {
+ /*
+ * The existing posting is stored coded; replace its slot (at
+ * the offset just before newitemoff, since newitemoff was
+ * advanced past it above) with the coded new posting (encoded
+ * before the critical section), whose size may differ.
+ */
+ if (!PageIndexTupleOverwrite(page, OffsetNumberPrev(newitemoff),
+ codednposting, IndexTupleSize(codednposting)))
+ elog(PANIC, "failed to overwrite posting list in block %u in index \"%s\"",
+ BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+ }
+ else
+ memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ }
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, codeditup, codeditemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1381,8 +1437,30 @@ _bt_insertonpg(Relation rel,
}
}
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- if (postingoff == 0)
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ ((pageprefix && postingoff != 0) ? REGBUF_FORCE_IMAGE : 0));
+ if (pageprefix && postingoff != 0)
+ {
+ /*
+ * A posting-list split on a prefix page rewrites an existing
+ * coded posting and adds a coded item; rather than teach redo to
+ * re-code both, force a full-page image (this is a rare case).
+ * postingoff is still recorded for the record format.
+ */
+ upostingoff = postingoff;
+ XLogRegisterBufData(0, &upostingoff, sizeof(uint16));
+ }
+ else if (pageprefix)
+ {
+ /*
+ * Simple insert on a prefix page: log the CODED item bytes as
+ * they were placed on the page, so redo adds them verbatim at
+ * newitemoff -- no full-page image needed, and byte-identical.
+ */
+ XLogRegisterBufData(0, (char *) codeditup, IndexTupleSize(codeditup));
+ }
+ else if (postingoff == 0)
{
/* Just log itup from caller */
XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -1418,6 +1496,11 @@ _bt_insertonpg(Relation rel,
END_CRIT_SECTION();
+ if (codeditup != itup)
+ pfree(codeditup);
+ if (codednposting != NULL && codednposting != nposting)
+ pfree(codednposting);
+
/* Release subsidiary buffers */
if (BufferIsValid(metabuf))
_bt_relbuf(rel, metabuf);
@@ -1506,6 +1589,22 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
ItemId itemid;
IndexTuple firstright,
lefthighkey;
+ BTItemBuf frbuf; /* decode scratch for firstright */
+ BTItemBuf llbuf; /* decode scratch for lastleft */
+ IndexTuple *leftitems = NULL; /* leaf: plain items for the left half */
+ IndexTuple *rightitems = NULL; /* leaf: plain items for the right half */
+ int nleft = 0;
+ int nright = 0;
+ IndexTuple righthighkey = NULL; /* leaf right half's high key (NULL=rightmost) */
+ const char *origprefix = NULL;
+ Size origprefixlen = 0;
+ char leftprefix[BTREE_PREFIX_MAX]; /* chosen prefix of each leaf half */
+ char rightprefix[BTREE_PREFIX_MAX];
+ Size leftprefixlen = 0;
+ Size rightprefixlen = 0;
+ char *rightwalbuf = NULL; /* packed right-page WAL payload */
+ Size rightwalbuflen = 0;
+ bool force_rfpi = false; /* carry the right page as a forced image */
OffsetNumber firstrightoff;
OffsetNumber afterleftoff,
afterrightoff,
@@ -1575,7 +1674,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
lopaque->btpo_flags = oopaque->btpo_flags;
- lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
/* set flag in leftpage indicating that rightpage has no downlink yet */
lopaque->btpo_flags |= BTP_INCOMPLETE_SPLIT;
lopaque->btpo_prev = oopaque->btpo_prev;
@@ -1650,6 +1749,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
itemid = PageGetItemId(origpage, firstrightoff);
itemsz = ItemIdGetLength(itemid);
firstright = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ firstright = _bt_prefix_decode(origpage, firstright, &frbuf);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
}
@@ -1673,6 +1774,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque));
itemid = PageGetItemId(origpage, lastleftoff);
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ lastleft = _bt_prefix_decode(origpage, lastleft, &llbuf);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
}
@@ -1712,7 +1815,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
/*
- * Add new high key to leftpage
+ * Add new high key to leftpage. For a leaf split the left half is built
+ * as a whole below (so it can be prefix-compressed from the start, which
+ * a plain incremental build cannot represent when the page holds more
+ * logical data than fits plain); the high key is passed to the builder
+ * then. Internal halves are built incrementally here as before.
*/
afterleftoff = P_HIKEY;
@@ -1720,11 +1827,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(BTreeTupleGetNAtts(lefthighkey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(itemsz == MAXALIGN(IndexTupleSize(lefthighkey)));
- if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
- elog(ERROR, "failed to add high key to the left sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
- afterleftoff = OffsetNumberNext(afterleftoff);
+ if (isleaf)
+ {
+ /* The builder needs a stable copy; lefthighkey points into workspace. */
+ lefthighkey = CopyIndexTuple(lefthighkey);
+ leftitems = palloc_array(IndexTuple, maxoff + 2);
+ rightitems = palloc_array(IndexTuple, maxoff + 2);
+ if (P_HASPREFIX(oopaque))
+ origprefix = _bt_page_get_prefix(origpage, &origprefixlen);
+ }
+ else
+ {
+ if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the left sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterleftoff = OffsetNumberNext(afterleftoff);
+ }
/*
* Acquire a new right page to split into, now that left page has a new
@@ -1765,7 +1884,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
ropaque->btpo_flags = oopaque->btpo_flags;
- ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = oopaque->btpo_next;
ropaque->btpo_level = oopaque->btpo_level;
@@ -1782,21 +1901,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (!isrightmost)
{
- IndexTuple righthighkey;
+ ItemId hiitemid = PageGetItemId(origpage, P_HIKEY);
+ Size hiitemsz = ItemIdGetLength(hiitemid);
+ IndexTuple hikey = (IndexTuple) PageGetItem(origpage, hiitemid);
- itemid = PageGetItemId(origpage, P_HIKEY);
- itemsz = ItemIdGetLength(itemid);
- righthighkey = (IndexTuple) PageGetItem(origpage, itemid);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) > 0);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) <=
+ Assert(BTreeTupleGetNAtts(hikey, rel) > 0);
+ Assert(BTreeTupleGetNAtts(hikey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
- if (PageAddItem(rightpage, righthighkey, itemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ if (isleaf)
+ righthighkey = CopyIndexTuple(hikey); /* stable: origpage is
+ * overwritten by the left
+ * half before WAL logging */
+ else
{
- elog(ERROR, "failed to add high key to the right sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
+ if (PageAddItem(rightpage, hikey, hiitemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the right sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterrightoff = OffsetNumberNext(afterrightoff);
}
- afterrightoff = OffsetNumberNext(afterrightoff);
}
/*
@@ -1813,7 +1936,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*
* Note: we *must* insert at least the right page's items in item-number
* order, for the benefit of _bt_restore_page().
+ *
+ * A leaf split builds each half as a whole page below (so it can be
+ * prefix-compressed from the start), which redo must reproduce byte for
+ * byte. To keep the primary and redo in lock step, both derive the
+ * split's plain item arrays from the original page through the single
+ * shared routine _bt_split_leaf_collect(); the incremental loop here
+ * handles only the internal (!isleaf) case.
*/
+ if (isleaf)
+ {
+ _bt_split_leaf_collect(origpage, firstrightoff, newitemoff, newitem,
+ newitemonleft, origpagepostingoff, nposting,
+ leftitems, &nleft, rightitems, &nright);
+ }
+ else
for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
{
IndexTuple dataitem;
@@ -1837,24 +1974,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
{
Assert(newitemoff <= firstrightoff);
if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
- false))
- {
+ false))
elog(ERROR, "failed to add new item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
Assert(newitemoff >= firstrightoff);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1863,28 +1996,24 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (i < firstrightoff)
{
if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
- {
elog(ERROR, "failed to add old item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add old item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
- /* Handle case where newitem goes at the end of rightpage */
- if (i <= newitemoff)
+ /* Handle case where newitem goes at the end of rightpage (internal only) */
+ if (!isleaf && i <= newitemoff)
{
/*
* Can't have newitemonleft here; that would imply we were told to put
@@ -1893,15 +2022,93 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*/
Assert(!newitemonleft && newitemoff == maxoff + 1);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
+ /*
+ * Leaf halves are built as whole pages now, so they can be prefix-
+ * compressed from the start (Strategy 1: a page is faulted into the
+ * BTP_PREFIX format only when it is written whole). The plain item arrays
+ * were collected above by _bt_split_leaf_collect(), the same routine redo
+ * uses, so the primary and redo lay out identical bytes.
+ */
+ if (isleaf)
+ {
+ _bt_split_leaf_half(rel, leftpage, lefthighkey, leftitems, nleft,
+ origprefix, origprefixlen,
+ leftprefix, &leftprefixlen);
+ _bt_split_leaf_half(rel, rightpage, righthighkey, rightitems, nright,
+ origprefix, origprefixlen,
+ rightprefix, &rightprefixlen);
+
+ /*
+ * Build the right page's WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the right prefix bytes, then the
+ * right high key (unless rightmost), then the right half's plain items,
+ * each MAXALIGN'd, in one contiguous buffer logged with a single rdata
+ * slot. A very compressible right half whose plain items would exceed
+ * a page is instead carried as a full-page image (set below), so this
+ * buffer is bounded.
+ */
+ if (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0)
+ {
+ Size roff = MAXALIGN(rightprefixlen);
+
+ if (righthighkey != NULL)
+ roff += MAXALIGN(IndexTupleSize(righthighkey));
+ for (int ri = 0; ri < nright; ri++)
+ roff += MAXALIGN(IndexTupleSize(rightitems[ri]));
+ rightwalbuf = palloc(Max(roff, 1));
+ rightwalbuflen = roff;
+
+ /*
+ * Decide (before the critical section, since the injection-point
+ * cache load can allocate) whether to carry the right page as a
+ * forced full-page image: normally only when its plain payload
+ * would exceed a block, but a test can force the otherwise-rare
+ * path with the nbtree-prefix-force-rfpi injection point.
+ */
+ if (rightwalbuflen > BLCKSZ)
+ force_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ else if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ force_rfpi = true;
+#endif
+
+ roff = 0;
+ if (rightprefixlen > 0)
+ {
+ memcpy(rightwalbuf, rightprefix, rightprefixlen);
+ if (MAXALIGN(rightprefixlen) != rightprefixlen)
+ memset(rightwalbuf + rightprefixlen, 0,
+ MAXALIGN(rightprefixlen) - rightprefixlen);
+ roff += MAXALIGN(rightprefixlen);
+ }
+ if (righthighkey != NULL)
+ {
+ Size hsz = IndexTupleSize(righthighkey);
+
+ memcpy(rightwalbuf + roff, righthighkey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(rightwalbuf + roff + hsz, 0, MAXALIGN(hsz) - hsz);
+ roff += MAXALIGN(hsz);
+ }
+ for (int ri = 0; ri < nright; ri++)
+ {
+ Size rsz = IndexTupleSize(rightitems[ri]);
+
+ memcpy(rightwalbuf + roff, rightitems[ri], rsz);
+ if (MAXALIGN(rsz) != rsz)
+ memset(rightwalbuf + roff + rsz, 0, MAXALIGN(rsz) - rsz);
+ roff += MAXALIGN(rsz);
+ }
+ }
+ }
+
/*
* We have to grab the original right sibling (if any) and update its prev
* link. We are guaranteed that this is deadlock-free, since we couple
@@ -2005,17 +2212,71 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /*
+ * A leaf split that involves prefix compression (either half ends up
+ * compressed, or the pre-split page was compressed so its items are
+ * coded) is replayed by rebuilding both halves from the pre-split page,
+ * exactly as the primary did, rather than by replaying logged tuples --
+ * the coded physical layout would not match _bt_restore_page. No
+ * full-page image is needed; redo reconstructs the plain items from the
+ * original page (block 0) and lays each half out coded against the
+ * chosen prefix, whose length and bytes are carried in the record.
+ */
+ xlrec.flags = 0;
+ xlrec.leftprefixlen = 0;
+ xlrec.rightprefixlen = 0;
+ if (isleaf && (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0))
+ {
+ xlrec.flags |= XLH_SPLIT_LEAFPREFIX;
+ xlrec.leftprefixlen = (uint8) leftprefixlen;
+ xlrec.rightprefixlen = (uint8) rightprefixlen;
+
+ /*
+ * The right half is normally carried as its packed plain payload
+ * (redo lays it out coded), far cheaper than a full-page image.
+ * But a very compressible page can hold more plain data than fits
+ * in one page; force_rfpi (decided before the critical section)
+ * carries the coded right page as a bounded full-page image instead.
+ */
+ if (force_rfpi)
+ xlrec.flags |= XLH_SPLIT_RFPI;
+ }
+
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
+ /*
+ * The right page is rebuilt from scratch in redo. Ordinarily that is
+ * from the logged tuple region (REGBUF_WILL_INIT). A prefix-leaf split
+ * instead rebuilds it from its logged plain items, unless those would
+ * overflow the record (XLH_SPLIT_RFPI), in which case the coded page is
+ * carried as a forced full-page image.
+ */
+ XLogRegisterBuffer(1, rbuf,
+ (xlrec.flags & XLH_SPLIT_RFPI) ?
+ (REGBUF_STANDARD | REGBUF_FORCE_IMAGE) : REGBUF_WILL_INIT);
/* Log original right sibling, since we've changed its prev-pointer */
if (!isrightmost)
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
if (!isleaf)
XLogRegisterBuffer(3, cbuf, REGBUF_STANDARD);
+ /*
+ * For a prefix leaf split, carry each half's chosen prefix bytes, and
+ * (unless the right page is carried as a full-page image) the right
+ * high key and the right half's PLAIN items on block 1, so redo can lay
+ * the right page out coded without depending on block 0's state (block
+ * 0 may be restored from an automatic full-page image). The left half
+ * is rebuilt in redo from the original page (block 0) when it needs
+ * redo, or from its own FPI otherwise, so the left items are not logged.
+ */
+ if (xlrec.flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ if (!(xlrec.flags & XLH_SPLIT_RFPI) && rightwalbuflen > 0)
+ XLogRegisterBufData(1, rightwalbuf, rightwalbuflen);
+ }
+
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
@@ -2063,6 +2324,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
XLogRegisterBufData(0, lefthighkey,
MAXALIGN(IndexTupleSize(lefthighkey)));
+ /*
+ * For a prefix leaf split, the left half's chosen prefix bytes follow
+ * the high key on block 0 (redo reads newitem/orignewitem, then the
+ * high key, then this prefix).
+ */
+ if ((xlrec.flags & XLH_SPLIT_LEAFPREFIX) && leftprefixlen > 0)
+ XLogRegisterBufData(0, leftprefix, leftprefixlen);
+
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). The whole right page will be recreated.
@@ -2071,11 +2340,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the line pointers can be reconstructed. See comments for
- * _bt_restore_page().
+ * _bt_restore_page(). A prefix-compressed leaf split instead rebuilds
+ * the right page from the original page in redo (see XLH_SPLIT_LEAFPREFIX),
+ * so its tuple region is not logged.
*/
- XLogRegisterBufData(1,
- (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
- ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
+ if (!(xlrec.flags & XLH_SPLIT_LEAFPREFIX))
+ XLogRegisterBufData(1,
+ (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
+ ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
xlinfo = newitemonleft ? XLOG_BTREE_SPLIT_L : XLOG_BTREE_SPLIT_R;
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
@@ -2820,7 +3092,15 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
- /* Perform deduplication pass (when enabled and index-is-allequalimage) */
+ /*
+ * Perform deduplication pass (when enabled and index-is-allequalimage).
+ *
+ * On a prefix-compressed page the pass decodes items and lays the deduped
+ * result out plain before re-coding it; the decoded set can exceed one
+ * page even when the coded form fit, in which case the pass aborts without
+ * touching the page and we fall through to split it. When it does fit, the
+ * recompressed page's WAL replays without a full-page image.
+ */
if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
_bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz,
(indexUnchanged || uniquedup));
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f81..c755efc81b0 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1212,11 +1212,26 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * If VACUUM emptied a prefix-compressed leaf of all its data items, drop
+ * the PREFIX item too and clear BTP_PREFIX: an empty page carries no
+ * prefix, and page deletion relies on an emptied leaf holding only its
+ * high key (nbtpage.c's _bt_mark_page_halfdead). Recorded in the vacuum
+ * record so redo does the same.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
/*
@@ -2245,6 +2260,20 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
opaque = BTPageGetOpaque(page);
opaque->btpo_flags |= BTP_HALF_DEAD;
+ /*
+ * A prefix-compressed leaf being deleted may still carry its PREFIX item
+ * (e.g. emptied by simple deletion, which does not drop it). Remove it
+ * and clear BTP_PREFIX so the page holds only its high key, as the
+ * half-dead transition below requires. Redo (btree_xlog_mark_page_halfdead)
+ * reinitializes the page, so it needs no separate handling.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
MemSet(&trunctuple, 0, sizeof(IndexTupleData));
trunctuple.t_info = sizeof(IndexTupleData);
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 00000000000..c725f75cd92
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,814 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Leaf key prefix compression for Postgres btrees.
+ *
+ * A leaf page of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page front-codes the first key column of each non-pivot tuple against a
+ * per-page PREFIX item, so that a long key that repeats on a page costs
+ * little more than its unshared tail. The on-disk format is documented at
+ * BTP_PREFIX in nbtree.h; this file is the encoder, decoder, and the policy
+ * that chooses a page's prefix.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo repeats them, and decoding reproduces the plain
+ * tuple byte for byte. A tuple inserted later that shares less of the prefix
+ * just stores a smaller `shared' count, so no insert, VACUUM, deduplication
+ * or change of the page's key range ever has to rewrite the page to keep the
+ * prefix valid. The prefix is chosen only when a page is written as a whole
+ * (CREATE INDEX, or a half of a split).
+ *
+ * This is modeled on the leaf prefix compression of the BARK access method
+ * (src/backend/access/bark/barkutils.c).
+ *
+ *
+ * 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 "storage/bufpage.h"
+#include "utils/rel.h"
+
+/*
+ * Where each part of a tuple goes in its coded form. Coding and decoding
+ * both lay the tuple out from this, so the two cannot disagree.
+ */
+typedef struct BTPrefixPlan
+{
+ Size hoff; /* data offset: header and null bitmap */
+ Size c1; /* plain first column's size, with header */
+ Size keyend; /* end of the plain key (posting offset) */
+ uint8 code; /* first payload byte of the coded column */
+ const char *suffix; /* bytes stored after it */
+ Size suffixlen;
+ Size vsize; /* coded column's size, with header */
+ Size codedkeyend; /* end of the coded key */
+ Size total; /* size of the coded tuple */
+} BTPrefixPlan;
+
+/*
+ * Is `itup' stored coded on a BTP_PREFIX page? Only a non-pivot tuple (plain
+ * or posting) whose first column is not NULL; a pivot (high key, downlink)
+ * is stored as it is.
+ */
+static bool
+_bt_prefix_codable(IndexTuple itup)
+{
+ if (BTreeTupleIsPivot(itup))
+ return false;
+ return !IndexTupleHasNulls(itup) ||
+ !att_isnull(0, (uint8 *) itup + sizeof(IndexTupleData));
+}
+
+/*
+ * Does the varlena `val' have the header decoding would give it back: the
+ * 1-byte header if its data fit one, else an uncompressed 4-byte header?
+ */
+static bool
+_bt_prefix_canonical(const char *val)
+{
+ if (VARATT_IS_SHORT(val))
+ return !VARATT_IS_EXTERNAL(val);
+ return !VARATT_IS_EXTENDED(val) &&
+ VARSIZE(val) - VARHDRSZ + VARHDRSZ_SHORT > VARATT_SHORT_MAX;
+}
+
+/*
+ * End of a leaf tuple's key: the posting-list offset for a posting tuple, the
+ * whole tuple size otherwise. The bytes after the key (a posting list) are
+ * moved as they are.
+ */
+static Size
+_bt_prefix_keyend(IndexTuple itup)
+{
+ if (BTreeTupleIsPosting(itup))
+ return BTreeTupleGetPostingOffset(itup);
+ return IndexTupleSize(itup);
+}
+
+/*
+ * The size of a coded first column whose payload is `payload' bytes: a
+ * varlena with a 1-byte header when it fits one.
+ */
+static inline Size
+_bt_prefix_vsize(Size payload)
+{
+ if (payload + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ return payload + VARHDRSZ_SHORT;
+ return payload + VARHDRSZ;
+}
+
+/*
+ * Offset at which the bytes after the first column go in a coded tuple whose
+ * coded column ends at `vend', when in the plain tuple they start at
+ * `restorig': the first offset at or after `vend' that is the same distance
+ * past a MAXALIGN boundary.
+ */
+static inline Size
+_bt_prefix_restoff(Size vend, Size restorig)
+{
+ return vend + (MAXIMUM_ALIGNOF + restorig % MAXIMUM_ALIGNOF -
+ vend % MAXIMUM_ALIGNOF) % MAXIMUM_ALIGNOF;
+}
+
+/* Plan the coding of the codable tuple `itup' against `prefix'. */
+static void
+_bt_prefix_plan(const char *prefix, Size prefixlen, IndexTuple itup,
+ BTPrefixPlan *plan)
+{
+ const char *val;
+ Size restorig;
+ bool storerest;
+
+ plan->hoff = IndexInfoFindDataOffset(itup->t_info);
+ val = (const char *) itup + plan->hoff;
+ plan->c1 = VARSIZE_ANY(val);
+ plan->keyend = _bt_prefix_keyend(itup);
+
+ if (_bt_prefix_canonical(val))
+ {
+ const char *data = VARDATA_ANY(val);
+ Size datalen = VARSIZE_ANY_EXHDR(val);
+ Size limit = Min(prefixlen, datalen);
+ Size shared = 0;
+
+ while (shared < limit && prefix[shared] == data[shared])
+ shared++;
+ plan->code = (uint8) shared;
+ plan->suffix = data + shared;
+ plan->suffixlen = datalen - shared;
+ }
+ else
+ {
+ plan->code = BTREE_PREFIX_RAW;
+ plan->suffix = val;
+ plan->suffixlen = plan->c1;
+ }
+ plan->vsize = _bt_prefix_vsize(1 + plan->suffixlen);
+
+ /*
+ * The bytes after the first column can be left out when they are just the
+ * zero padding that ends the key.
+ */
+ restorig = plan->hoff + plan->c1;
+ storerest = plan->keyend != MAXALIGN(restorig);
+ for (Size i = restorig; !storerest && i < plan->keyend; i++)
+ storerest = ((const char *) itup)[i] != 0;
+
+ if (storerest)
+ {
+ plan->code |= BTREE_PREFIX_REST;
+ plan->codedkeyend = _bt_prefix_restoff(plan->hoff + plan->vsize,
+ restorig) +
+ (plan->keyend - restorig);
+ }
+ else
+ plan->codedkeyend = MAXALIGN(plan->hoff + plan->vsize);
+ plan->total = plan->codedkeyend + (IndexTupleSize(itup) - plan->keyend);
+}
+
+/*
+ * Copy n bytes, as memcpy. Decoding moves a few short runs of bytes per
+ * tuple; eight bytes at a time is several times faster than a libc memcpy
+ * for runs this short (the string-move startup cost dominates otherwise).
+ */
+static inline void
+_bt_prefix_copy(char *dst, const char *src, Size n)
+{
+ while (n >= sizeof(uint64))
+ {
+ memcpy(dst, src, sizeof(uint64));
+ dst += sizeof(uint64);
+ src += sizeof(uint64);
+ n -= sizeof(uint64);
+ }
+ while (n-- > 0)
+ *dst++ = *src++;
+}
+
+/* The prefix of a BTP_PREFIX page, and its length in *len. */
+const char *
+_bt_page_get_prefix(Page page, Size *len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ IndexTuple item;
+
+ Assert(P_HASPREFIX(opaque));
+ item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page,
+ BTPageGetPrefixOff(opaque)));
+ *len = IndexTupleSize(item) - sizeof(IndexTupleData);
+ return (const char *) item + sizeof(IndexTupleData);
+}
+
+/*
+ * Give `page', a leaf holding nothing yet but its high key (if it has one),
+ * the prefix `prefix' of `len' bytes: add the PREFIX item and set BTP_PREFIX.
+ * Its padding comes from the page's free space, which _bt_pageinit zeroed, so
+ * a page rebuilt in redo is the same.
+ */
+void
+_bt_page_set_prefix(Page page, const char *prefix, Size len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ union
+ {
+ IndexTupleData hdr;
+ char data[sizeof(IndexTupleData) + BTREE_PREFIX_MAX];
+ } item;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+ Assert(len >= 1 && len <= BTREE_PREFIX_MAX);
+ Assert(PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque) - 1);
+
+ memset(&item.hdr, 0, sizeof(IndexTupleData));
+ item.hdr.t_info = (unsigned short) (sizeof(IndexTupleData) + len);
+ memcpy(item.data + sizeof(IndexTupleData), prefix, len);
+ if (PageAddItem(page, item.data, sizeof(IndexTupleData) + len,
+ BTPageGetPrefixOff(opaque), false, false) ==
+ InvalidOffsetNumber)
+ elog(ERROR, "failed to add prefix item to btree page");
+ opaque->btpo_flags |= BTP_PREFIX;
+}
+
+/*
+ * The coded form of the leaf tuple `itup' for `page', palloc'd; `itup' itself
+ * when the page has no prefix or the tuple is stored plain.
+ */
+IndexTuple
+_bt_prefix_encode(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+ char *coded;
+ char *v;
+ char *payload;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ Assert(plan.total <= INDEX_SIZE_MASK);
+
+ coded = palloc0(plan.total);
+ memcpy(coded, itup, plan.hoff);
+ v = coded + plan.hoff;
+ if (plan.vsize <= VARATT_SHORT_MAX)
+ {
+ SET_VARSIZE_SHORT(v, plan.vsize);
+ payload = v + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ SET_VARSIZE(v, plan.vsize);
+ payload = v + VARHDRSZ;
+ }
+ payload[0] = (char) plan.code;
+ memcpy(payload + 1, plan.suffix, plan.suffixlen);
+ if (plan.code & BTREE_PREFIX_REST)
+ {
+ Size restorig = plan.hoff + plan.c1;
+
+ memcpy(coded + _bt_prefix_restoff(plan.hoff + plan.vsize, restorig),
+ (char *) itup + restorig, plan.keyend - restorig);
+ }
+ memcpy(coded + plan.codedkeyend, (char *) itup + plan.keyend,
+ IndexTupleSize(itup) - plan.keyend);
+
+ ((IndexTuple) coded)->t_info =
+ (itup->t_info & ~INDEX_SIZE_MASK) | (unsigned short) plan.total;
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) coded, BTreeTupleGetNPosting(itup),
+ plan.codedkeyend);
+
+#ifdef USE_ASSERT_CHECKING
+ {
+ BTItemBuf check;
+ IndexTuple decoded = _bt_prefix_decode(page, (IndexTuple) coded,
+ &check);
+
+ Assert(IndexTupleSize(decoded) == IndexTupleSize(itup) &&
+ memcmp(decoded, itup, IndexTupleSize(itup)) == 0);
+ }
+#endif
+
+ return (IndexTuple) coded;
+}
+
+/* The MAXALIGNed size `itup' takes on `page', coded if the page codes it. */
+Size
+_bt_prefix_coded_size(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return MAXALIGN(IndexTupleSize(itup));
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ return MAXALIGN(plan.total);
+}
+
+/*
+ * Decode the item `itup' of the BTP_PREFIX page `page' into `buf', and return
+ * it; an item stored plain is returned as it is. The inverse of
+ * _bt_prefix_encode, byte for byte. An item that does not decode within its
+ * own bounds, or claims more of the prefix than the page has, is reported as
+ * corruption.
+ */
+IndexTuple
+_bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf)
+{
+ const char *prefix;
+ Size prefixlen;
+ Size size = IndexTupleSize(itup);
+ Size hoff;
+ Size codedkeyend;
+ const char *v;
+ Size vsize = 0;
+ const char *payload;
+ Size paylen = 0;
+ uint8 code;
+ Size shared;
+ char *out = buf->data;
+ char *val = NULL;
+ Size c1;
+ Size restorig;
+ Size keyend;
+
+ if (!_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ hoff = IndexInfoFindDataOffset(itup->t_info);
+ codedkeyend = _bt_prefix_keyend(itup);
+ v = (const char *) itup + hoff;
+ if (hoff >= codedkeyend || codedkeyend > size ||
+ (vsize = VARSIZE_ANY(v)) > codedkeyend - hoff ||
+ (paylen = VARSIZE_ANY_EXHDR(v)) < 1)
+ goto corrupt;
+ payload = VARDATA_ANY(v);
+ code = (uint8) payload[0];
+ shared = code & BTREE_PREFIX_SHARED_MASK;
+
+ _bt_prefix_copy(out, (const char *) itup, hoff);
+ val = out + hoff;
+ if (shared == BTREE_PREFIX_RAW)
+ {
+ c1 = paylen - 1;
+ _bt_prefix_copy(val, payload + 1, c1);
+ if (c1 < VARHDRSZ_SHORT || VARSIZE_ANY(val) != c1)
+ goto corrupt;
+ }
+ else
+ {
+ Size datalen = shared + paylen - 1;
+ char *data;
+
+ if (hoff + VARHDRSZ + datalen > sizeof(BTItemBuf))
+ goto corrupt;
+ if (shared > prefixlen)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple shares %zu bytes of a %zu-byte page prefix",
+ shared, prefixlen)));
+ if (datalen + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ {
+ c1 = datalen + VARHDRSZ_SHORT;
+ SET_VARSIZE_SHORT(val, c1);
+ data = val + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ c1 = datalen + VARHDRSZ;
+ SET_VARSIZE(val, c1);
+ data = val + VARHDRSZ;
+ }
+ _bt_prefix_copy(data, prefix, shared);
+ _bt_prefix_copy(data + shared, payload + 1, paylen - 1);
+ }
+
+ restorig = hoff + c1;
+ if (code & BTREE_PREFIX_REST)
+ {
+ Size restoff = _bt_prefix_restoff(hoff + vsize, restorig);
+
+ if (restoff > codedkeyend)
+ goto corrupt;
+ keyend = restorig + (codedkeyend - restoff);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ _bt_prefix_copy(out + restorig, (const char *) itup + restoff,
+ codedkeyend - restoff);
+ }
+ else
+ {
+ keyend = MAXALIGN(restorig);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ memset(out + restorig, 0, keyend - restorig);
+ }
+ _bt_prefix_copy(out + keyend, (const char *) itup + codedkeyend,
+ size - codedkeyend);
+
+ ((IndexTuple) out)->t_info = (itup->t_info & ~INDEX_SIZE_MASK) |
+ (unsigned short) (keyend + (size - codedkeyend));
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) out, BTreeTupleGetNPosting(itup),
+ keyend);
+ return (IndexTuple) out;
+
+corrupt:
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple is not a valid prefix-coded tuple")));
+ return NULL; /* keep compiler quiet */
+}
+
+/*
+ * The bytes of `itup''s first column a page prefix may be taken from, and how
+ * many (at most BTREE_PREFIX_MAX) in *len; NULL when the tuple has none: it
+ * is not codable, its value is empty, or it would be stored raw.
+ */
+const char *
+_bt_prefix_candidate(IndexTuple itup, Size *len)
+{
+ const char *val;
+
+ if (!_bt_prefix_codable(itup))
+ return NULL;
+ val = (const char *) itup + IndexInfoFindDataOffset(itup->t_info);
+ if (!_bt_prefix_canonical(val) || VARSIZE_ANY_EXHDR(val) == 0)
+ return NULL;
+ *len = Min(VARSIZE_ANY_EXHDR(val), BTREE_PREFIX_MAX);
+ return VARDATA_ANY(val);
+}
+
+/*
+ * How many bytes of `prefix' the first column of `itup' would share on a page
+ * with that prefix: -1 when the tuple would not be coded, 0 when it would be
+ * stored raw.
+ */
+int
+_bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup)
+{
+ BTPrefixPlan plan;
+
+ if (!_bt_prefix_codable(itup))
+ return -1;
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ if ((plan.code & BTREE_PREFIX_SHARED_MASK) == BTREE_PREFIX_RAW)
+ return 0;
+ return plan.code & BTREE_PREFIX_SHARED_MASK;
+}
+
+/*
+ * May the leaves of `index' have a prefix? Only when the prefix_compression
+ * reloption is on and the first key column is of a varlena type.
+ */
+bool
+_bt_prefix_enabled(Relation index)
+{
+ return BTGetPrefixCompression(index) &&
+ TupleDescAttr(RelationGetDescr(index), 0)->attlen == -1;
+}
+
+/*
+ * Choose the prefix for a leaf about to be built from the `n' tuples `items',
+ * in key order: the first tuple's first-column bytes, cut to the longest run
+ * any later tuple shares, provided the tuples share at least
+ * BTREE_PREFIX_MIN_SHARED bytes of it on average. Returns the prefix's
+ * length, with *prefix pointing into items[0], or 0 for no prefix.
+ */
+Size
+_bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix)
+{
+ const char *cand;
+ Size candlen;
+ Size longest = 0;
+ Size total = 0;
+ int ncoded = 0;
+
+ if (!_bt_prefix_enabled(index) ||
+ (cand = _bt_prefix_candidate(items[0], &candlen)) == NULL)
+ return 0;
+ if (n == 1)
+ {
+ *prefix = cand;
+ return candlen;
+ }
+ for (int i = 1; i < n; i++)
+ {
+ int shared = _bt_prefix_shared(cand, candlen, items[i]);
+
+ if (shared < 0)
+ continue;
+ total += shared;
+ ncoded++;
+ longest = Max(longest, (Size) shared);
+ }
+ if (longest == 0 || total < (Size) BTREE_PREFIX_MIN_SHARED * ncoded)
+ return 0;
+ *prefix = cand;
+ return longest;
+}
+
+/*
+ * Fault a freshly finished leaf `page' forward into the BTP_PREFIX format if
+ * a prefix pays off (Strategy 1: compress a page only when it is written as a
+ * whole -- CREATE INDEX page finish, a split half, a dedup rewrite -- so that
+ * no insert, VACUUM or key-range change ever forces a page rewrite, and an
+ * old-format page is converted only when it is next written whole).
+ *
+ * On entry `page' is a complete leaf: its high key at P_HIKEY if it is not
+ * rightmost, then its data items, none of them coded (the page does not yet
+ * have BTP_PREFIX). If the data items share enough of a common first-column
+ * prefix, the page is rebuilt in place with the PREFIX item inserted after
+ * the high key and every data item coded against it; otherwise the page is
+ * left unchanged. Item order and the high key are preserved.
+ *
+ * Caller must hold the page's buffer exclusively (or own the page outright,
+ * as the build and split paths do with a temp page). No WAL is written here:
+ * callers log the resulting page (a build writes it whole; a split logs each
+ * half whole or by full-page image), and redo reaches the same bytes by
+ * running _bt_prefix_encode against the same prefix.
+ */
+void
+_bt_prefix_compress_page(Relation index, Page page)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber minoff;
+ OffsetNumber maxoff;
+ int n;
+ IndexTuple *items;
+ Size prefixlen;
+ const char *prefix;
+ Page tmp;
+ OffsetNumber o;
+ IndexTuple hikey = NULL;
+ Size hikeysz = 0;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+
+ if (!_bt_prefix_enabled(index))
+ return;
+
+ minoff = P_FIRSTDATAKEY(opaque);
+ maxoff = PageGetMaxOffsetNumber(page);
+ n = (int) maxoff - (int) minoff + 1;
+ if (n <= 0)
+ return;
+
+ items = (IndexTuple *) palloc(n * sizeof(IndexTuple));
+ for (int i = 0; i < n; i++)
+ items[i] = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, minoff + i));
+
+ prefixlen = _bt_prefix_choose(index, items, n, &prefix);
+ if (prefixlen == 0)
+ {
+ pfree(items);
+ return;
+ }
+
+ /* The high key (if any) stays at P_HIKEY, uncoded. */
+ if (!P_RIGHTMOST(opaque))
+ {
+ ItemId hii = PageGetItemId(page, P_HIKEY);
+
+ hikeysz = ItemIdGetLength(hii);
+ hikey = (IndexTuple) PageGetItem(page, hii);
+ }
+
+ /*
+ * Rebuild into a temp page: high key (if any), PREFIX item, then each
+ * data item coded. The coded form can be up to MAXALIGN larger than the
+ * plain one, but the PREFIX item replaces far more shared bytes than it
+ * adds, so a page that was not overfull stays within bounds; if a coded
+ * item somehow does not fit, leave the page uncompressed (always valid).
+ */
+ tmp = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(tmp)->btpo_flags &= ~BTP_PREFIX;
+ if (hikey != NULL &&
+ PageAddItem(tmp, (char *) hikey, hikeysz, P_HIKEY, false, false) ==
+ InvalidOffsetNumber)
+ goto giveup;
+ _bt_page_set_prefix(tmp, prefix, prefixlen);
+ o = OffsetNumberNext(PageGetMaxOffsetNumber(tmp));
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(tmp, items[i]);
+ bool ok;
+
+ ok = PageAddItem(tmp, (char *) coded, IndexTupleSize(coded), o++,
+ false, false) != InvalidOffsetNumber;
+ if (coded != items[i])
+ pfree(coded);
+ if (!ok)
+ goto giveup;
+ }
+
+ memcpy(page, tmp, BLCKSZ);
+ pfree(tmp);
+ pfree(items);
+ return;
+
+giveup:
+ pfree(tmp);
+ pfree(items);
+}
+
+/*
+ * Lay out one half of a leaf split on `page', which the caller has
+ * initialized (flags, links) and left empty: the high key `hikey' (NULL for
+ * a rightmost half), then the `n' plain tuples `items' in key order.
+ *
+ * The half is formed as a whole, so it may take a prefix of its own: its
+ * first item's leading bytes, if the items share enough (_bt_prefix_choose).
+ * Because a prefix-compressed leaf can hold more logical data than fits in a
+ * plain page, the half is laid out coded from the start rather than built
+ * plain and compressed afterwards. When the half does not fit with its own
+ * prefix (or shares too little to take one) it is laid out plain, and if that
+ * does not fit either, with `origprefix' (the splitting page's prefix, zero
+ * length when it had none), against which it fits by construction: the items
+ * keep the shares they had on the original page.
+ *
+ * Mirrors BARK's bark_split_leaf_half. Callers: _bt_split (both halves).
+ */
+void
+_bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen)
+{
+ BTPageOpaqueData saved = *BTPageGetOpaque(page);
+ const char *prefix[3];
+ Size prefixlen[3];
+ int ntries = 0;
+
+ Assert(P_ISLEAF(&saved) && !P_HASPREFIX(&saved));
+
+ /* Candidate prefixes to try, in order: own, none, the orig page's. */
+ if (_bt_prefix_enabled(index))
+ {
+ prefixlen[ntries] = _bt_prefix_choose(index, items, n, &prefix[ntries]);
+ if (prefixlen[ntries] > 0)
+ ntries++;
+ }
+ prefix[ntries] = NULL;
+ prefixlen[ntries++] = 0;
+ if (origprefixlen > 0)
+ {
+ prefix[ntries] = origprefix;
+ prefixlen[ntries++] = origprefixlen;
+ }
+
+ for (int t = 0; t < ntries; t++)
+ {
+ if (_bt_split_leaf_layout(page, hikey, items, n, prefix[t], prefixlen[t]))
+ {
+ /* Report the prefix that won, so the split can log it for redo. */
+ if (chosenprefixlen != NULL)
+ {
+ *chosenprefixlen = prefixlen[t];
+ if (prefixlen[t] > 0)
+ memcpy(chosenprefix, prefix[t], prefixlen[t]);
+ }
+ return;
+ }
+ /* restore the empty-page state for the next attempt */
+ _bt_pageinit(page, BLCKSZ);
+ *BTPageGetOpaque(page) = saved;
+ }
+ elog(ERROR, "failed to lay out a split half of index \"%s\"",
+ RelationGetRelationName(index));
+}
+
+/*
+ * Lay out one leaf split half on the caller-initialized empty `page' using a
+ * GIVEN prefix (`prefixlen' 0 for none): the high key `hikey' (NULL for a
+ * rightmost half), the PREFIX item, then the `n' plain tuples `items' coded
+ * against the prefix. Returns false if they do not fit (caller tries another
+ * prefix). This is Relation-free and deterministic in (hikey, items, prefix),
+ * so WAL redo calls it with the logged prefix to reproduce the primary's page
+ * byte for byte.
+ */
+bool
+_bt_split_leaf_layout(Page page, IndexTuple hikey, IndexTuple *items, int n,
+ const char *prefix, Size prefixlen)
+{
+ OffsetNumber o = P_HIKEY;
+ bool fit = true;
+
+ Assert(P_ISLEAF(BTPageGetOpaque(page)) && !P_HASPREFIX(BTPageGetOpaque(page)));
+ if (hikey != NULL &&
+ PageAddItem(page, (char *) hikey, MAXALIGN(IndexTupleSize(hikey)),
+ o++, false, false) == InvalidOffsetNumber)
+ return false;
+ if (prefixlen > 0)
+ {
+ _bt_page_set_prefix(page, prefix, prefixlen);
+ o++;
+ }
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(page, items[i]);
+
+ if (PageAddItem(page, (char *) coded, IndexTupleSize(coded),
+ o++, false, false) == InvalidOffsetNumber)
+ fit = false;
+ if (coded != items[i])
+ pfree(coded);
+ if (!fit)
+ return false;
+ }
+#ifdef USE_ASSERT_CHECKING
+ if (P_HASPREFIX(BTPageGetOpaque(page)))
+ {
+ OffsetNumber mx = PageGetMaxOffsetNumber(page);
+ OffsetNumber off;
+
+ for (off = P_FIRSTDATAKEY(BTPageGetOpaque(page)); off <= mx; off++)
+ {
+ BTItemBuf vb;
+
+ (void) _bt_getitem(page, off, &vb);
+ }
+ }
+#endif
+ return true;
+}
+
+/*
+ * Collect the plain (decoded) data items of a leaf split into the left and
+ * right arrays, in the exact order and with the exact substitutions that the
+ * split performs: the original page's data items split at `firstrightoff',
+ * with `newitem' inserted at `newitemoff' on the side given by `newitemonleft'
+ * and, for a posting-list split, the posting at `origpagepostingoff' replaced
+ * by `nposting'. `origpage' is the pre-split page (its items are decoded when
+ * it is a BTP_PREFIX page). The arrays receive pointers that are valid until
+ * the caller is done laying out the halves; items taken from the page are
+ * copied, so the caller owns and should reset the surrounding memory context.
+ *
+ * Both the primary (_bt_split) and WAL redo (btree_xlog_split) collect items
+ * this way and then lay out each half with _bt_split_leaf_half, so the two
+ * produce byte-identical coded pages without logging a full-page image.
+ */
+void
+_bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft, OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright)
+{
+ BTPageOpaque oopaque = BTPageGetOpaque(origpage);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(origpage);
+ bool hasprefix = P_HASPREFIX(oopaque);
+ OffsetNumber i;
+
+ *nleft = 0;
+ *nright = 0;
+ for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
+ {
+ ItemId itemid = PageGetItemId(origpage, i);
+ IndexTuple dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ BTItemBuf dibuf;
+
+ if (hasprefix)
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+
+ if (i == origpagepostingoff)
+ dataitem = nposting;
+ else if (i == newitemoff)
+ {
+ if (newitemonleft)
+ leftitems[(*nleft)++] = newitem;
+ else
+ rightitems[(*nright)++] = newitem;
+ }
+
+ if (i < firstrightoff)
+ leftitems[(*nleft)++] = CopyIndexTuple(dataitem);
+ else
+ rightitems[(*nright)++] = CopyIndexTuple(dataitem);
+ }
+ if (i <= newitemoff)
+ rightitems[(*nright)++] = newitem;
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..ffc5846e4c9 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -145,6 +145,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ BTItemBuf itupbuf; /* decode workspace for a loop's current item */
+ BTItemBuf finalbuf; /* decode workspace for a backward finaltup */
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -252,7 +254,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
continue;
}
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -353,9 +355,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,7 +415,7 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
else
tuple_alive = true;
- itup = (IndexTuple) PageGetItem(page, iid);
+ itup = _bt_getitem(page, offnum, &itupbuf);
Assert(!BTreeTupleIsPivot(itup));
pstate.offnum = offnum;
@@ -596,9 +596,10 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
BTScanOpaque so = (BTScanOpaque) scan->opaque;
Relation rel = scan->indexRelation;
TupleDesc tupdesc = RelationGetDescr(rel);
- ItemId iid;
IndexTuple firsttup,
lasttup;
+ BTItemBuf firstbuf,
+ lastbuf;
int startikey = 0,
arrayidx = 0,
firstchangingattnum;
@@ -616,12 +617,10 @@ _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);
+ 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 +2052,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
ScanDirection dir = pstate->dir;
OffsetNumber aheadoffnum;
IndexTuple ahead;
+ BTItemBuf aheadbuf; /* decode workspace for the look-ahead tuple */
Assert(!pstate->forcenonrequired);
@@ -2088,8 +2088,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..b8b8ec018f3 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -435,8 +435,10 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ Size half = BTTupleStorageSize(scan->indexRelation);
+
+ so->currTuples = (char *) palloc(half * 2);
+ so->markTuples = so->currTuples + half;
}
/*
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..f932ca33c3a 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -696,6 +696,7 @@ _bt_compare(Relation rel,
TupleDesc itupdesc = RelationGetDescr(rel);
BTPageOpaque opaque = BTPageGetOpaque(page);
IndexTuple itup;
+ BTItemBuf itupbuf;
ItemPointer heapTid;
ScanKey scankey;
int ncmpkey;
@@ -713,7 +714,7 @@ _bt_compare(Relation rel,
if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque))
return 1;
- itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ itup = _bt_getitem(page, offnum, &itupbuf);
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dcf4..9a8b372033e 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -241,6 +241,10 @@ typedef struct BTPageState
Size btps_lastextra; /* last item's extra posting list space */
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
+ /* Leaf prefix compression: the candidate prefix of the page being filled */
+ const char *btps_prefix; /* into the page's first data item, or NULL */
+ Size btps_prefixlen; /* its length (0 = no candidate) */
+ Size btps_codedsaved; /* bytes the page's items would save coded */
struct BTPageState *btps_next; /* link to parent level, if any */
} BTPageState;
@@ -856,7 +860,8 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
*/
Assert(last_truncextra == 0 || isleaf);
if (pgspc < itupsz + (isleaf ? MAXALIGN(sizeof(ItemPointerData)) : 0) ||
- (pgspc + last_truncextra < state->btps_full && last_off > P_FIRSTKEY))
+ (pgspc + last_truncextra + state->btps_codedsaved < state->btps_full &&
+ last_off > P_FIRSTKEY))
{
/*
* Finish off the page and write it out.
@@ -980,6 +985,15 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
nopaque->btpo_next = P_NONE; /* redundant */
}
+ /*
+ * The old leaf page is complete: fault it forward into the
+ * BTP_PREFIX format if a prefix pays off. Done after btps_lowkey
+ * (the downlink, a copy of the uncoded high key) is saved, since
+ * compression rewrites opage in place.
+ */
+ if (isleaf)
+ _bt_prefix_compress_page(wstate->index, opage);
+
/*
* Write out the old page. _bt_blwritepage takes ownership of the
* 'opage' buffer.
@@ -990,6 +1004,11 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
* Reset last_off to point to new page
*/
last_off = P_FIRSTKEY;
+
+ /* The new page starts with no prefix candidate and no savings yet. */
+ state->btps_prefix = NULL;
+ state->btps_prefixlen = 0;
+ state->btps_codedsaved = 0;
}
/*
@@ -1017,6 +1036,34 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_sortaddtup(npage, itupsz, itup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Leaf prefix compression accounting (Strategy 1): the page's eventual
+ * prefix is its first data item's first-column bytes; estimate how much
+ * room coding this page's items would free, so the fillfactor test above
+ * can pack more plain items onto a page that will shrink when it is
+ * compressed at finish. Items are still placed plain here; the estimate
+ * only relaxes the soft limit, never the hard fit check.
+ */
+ if (isleaf && _bt_prefix_enabled(wstate->index))
+ {
+ if (state->btps_prefix == NULL)
+ state->btps_prefix = _bt_prefix_candidate(itup,
+ &state->btps_prefixlen);
+ else
+ {
+ int shared = _bt_prefix_shared(state->btps_prefix,
+ state->btps_prefixlen, itup);
+
+ /*
+ * A coded item drops `shared' value bytes but keeps a 1-byte
+ * share count; count the net MAXALIGN-rounded saving.
+ */
+ if (shared > 1)
+ state->btps_codedsaved +=
+ MAXALIGN(itupsz) - MAXALIGN(itupsz - (shared - 1));
+ }
+ }
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1115,6 +1162,11 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
* back one slot. Then we can dump out the page.
*/
_bt_slideleft((Page) s->btps_buf);
+
+ /* Fault the finished rightmost leaf forward into BTP_PREFIX if it pays. */
+ if (s->btps_level == 0)
+ _bt_prefix_compress_page(wstate->index, (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/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8b2..14bee568e45 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -52,6 +52,9 @@ typedef struct
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
+ bool hasprefix; /* origpage is prefix-compressed (BTP_PREFIX) */
+ BTItemBuf lbuf; /* decode scratch for _bt_split_lastleft */
+ BTItemBuf rbuf; /* decode scratch for _bt_split_firstright */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
@@ -172,6 +175,23 @@ _bt_findsplitloc(Relation rel,
olddataitemstotal = rightspace - (int) PageGetExactFreeSpace(origpage);
leaffillfactor = BTGetFillFactor(rel);
+ /*
+ * On a prefix-compressed leaf the page carries an extra PREFIX item that
+ * is not a data item. The split is reasoned about in CODED (on-page)
+ * bytes -- the physical reality of the page and of each recompressed half
+ * -- so subtract only the PREFIX item's space from olddataitemstotal and
+ * use the on-page (coded) item sizes in the scan below. (Plain-size
+ * accounting is not usable here: a compressed leaf can hold far more
+ * logical data than two plain pages, so a plain split can be infeasible.)
+ */
+ if (P_HASPREFIX(opaque))
+ {
+ ItemId pii = PageGetItemId(origpage, BTPageGetPrefixOff(opaque));
+
+ olddataitemstotal -= (int) (MAXALIGN(ItemIdGetLength(pii)) +
+ sizeof(ItemIdData));
+ }
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
state.rel = rel;
@@ -185,6 +205,7 @@ _bt_findsplitloc(Relation rel,
state.olddataitemstotal = olddataitemstotal;
state.minfirstrightsz = SIZE_MAX;
state.newitemoff = newitemoff;
+ state.hasprefix = P_HASPREFIX(opaque);
/* newitem cannot be a posting list item */
Assert(!BTreeTupleIsPosting(newitem));
@@ -463,6 +484,19 @@ _bt_recsplitloc(FindSplitData *state,
newitemisfirstright = (firstrightoff == state->newitemoff &&
!newitemonleft);
+ /*
+ * Reject a split that would leave the left page with no data items at all
+ * (firstrightoff at the first data key, with newitem not on the left).
+ * Upstream's split-point heuristics never choose such a point for a plain
+ * page; on a prefix-compressed page the coded-size accounting can make it
+ * look attractive, and _bt_split cannot form a left high key from a
+ * nonexistent lastleft.
+ */
+ if (state->hasprefix &&
+ firstrightoff == P_FIRSTDATAKEY(BTPageGetOpaque(state->origpage)) &&
+ !newitemonleft)
+ return;
+
if (newitemisfirstright)
firstrightsz = state->newitemsz;
else
@@ -686,8 +720,12 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
if (state->newitemoff > maxoff)
{
+ BTItemBuf tbuf;
+
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ if (state->hasprefix)
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
@@ -714,12 +752,20 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
+ /* Do cheaper test first (t_tid and the posting bit survive coding) */
if (BTreeTupleIsPosting(tup) ||
!_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
return false;
/* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ if (state->hasprefix)
+ {
+ BTItemBuf tbuf;
+
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ }
+ else
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -1160,12 +1206,38 @@ static inline IndexTuple
_bt_split_lastleft(FindSplitData *state, SplitPoint *split)
{
ItemId itemid;
+ OffsetNumber off;
if (split->newitemonleft && split->firstrightoff == state->newitemoff)
return state->newitem;
- itemid = PageGetItemId(state->origpage,
- OffsetNumberPrev(split->firstrightoff));
+ off = OffsetNumberPrev(split->firstrightoff);
+
+ /*
+ * On a prefix-compressed leaf the PREFIX item sits just before the first
+ * data item. When the split boundary is at the first data key, the data
+ * predecessor is the high key on a plain page (a pivot, read plain); a
+ * rightmost page has none, so fall back to the first data item (this only
+ * feeds penalty estimation, never placement). Never read the PREFIX item
+ * as a data tuple.
+ */
+ if (state->hasprefix)
+ {
+ BTPageOpaque op = BTPageGetOpaque(state->origpage);
+
+ if (off == BTPageGetPrefixOff(op))
+ {
+ if (!P_RIGHTMOST(op))
+ return (IndexTuple) PageGetItem(state->origpage,
+ PageGetItemId(state->origpage, P_HIKEY));
+ off = P_FIRSTDATAKEY(op);
+ }
+ itemid = PageGetItemId(state->origpage, off);
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->lbuf);
+ }
+ itemid = PageGetItemId(state->origpage, off);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
@@ -1181,5 +1253,9 @@ _bt_split_firstright(FindSplitData *state, SplitPoint *split)
return state->newitem;
itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ if (state->hasprefix)
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->rbuf);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
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..1e582519237 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -243,6 +243,211 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting,
_bt_restore_meta(record, 2);
}
+/*
+ * Replay a prefix-compressed leaf page split.
+ *
+ * Rather than replay logged tuples (whose coded physical layout
+ * _bt_restore_page could not reproduce), rebuild both halves the way the
+ * primary did: collect the split's plain items from the pre-split original
+ * page (block 0) with _bt_split_leaf_collect, then lay each half out coded
+ * against its chosen prefix (carried in the record) with _bt_split_leaf_layout.
+ * The result is byte-identical to the primary, so no full-page image is
+ * needed. The record's block-0 data carries, in order: newitem (when it went
+ * on the left, or for a posting split), the left high key, and the left half's
+ * prefix bytes; block-1 data carries the right half's prefix bytes.
+ */
+static void
+btree_xlog_split_prefix(XLogReaderState *record, BlockNumber origpagenumber,
+ BlockNumber rightpagenumber, BlockNumber spagenumber,
+ bool newitemonleft)
+{
+ XLogRecPtr lsn = record->EndRecPtr;
+ xl_btree_split *xlrec = (xl_btree_split *) XLogRecGetData(record);
+ Buffer rbuf;
+ Buffer origbuf;
+ Page origpage;
+ BTPageOpaque oopaque;
+ char *datapos;
+ Size datalen;
+ IndexTuple newitem = NULL;
+ IndexTuple left_hikey;
+ IndexTuple right_hikey = NULL;
+ IndexTuple nposting = NULL;
+ Size newitemsz = 0;
+ const char *leftprefix = NULL;
+ const char *rightprefix = NULL;
+ IndexTuple rightitems[MaxIndexTuplesPerPage];
+ int nright = 0;
+ Page rightpage;
+ BTPageOpaque ropaque;
+ XLogRedoAction origaction;
+
+ /*
+ * The right (new) page is rebuilt entirely from data logged with the
+ * record, independent of block 0, because block 0 may be restored from an
+ * automatic full-page image (so its live contents after redo are the left
+ * half, not the pre-split page). Block-1 data is, in order: the right
+ * half's chosen prefix bytes (rightprefixlen), the right high key when the
+ * split is not rightmost (spagenumber != P_NONE), then the right half's
+ * plain data items in offset order (self-describing by IndexTupleSize).
+ */
+ datapos = XLogRecGetBlockData(record, 1, &datalen);
+ if (xlrec->flags & XLH_SPLIT_RFPI)
+ {
+ /*
+ * The right half's plain items would have overflowed the WAL record,
+ * so the primary carried the coded right page as a forced full-page
+ * image. Restore it from that image.
+ */
+ if (XLogReadBufferForRedo(record, 1, &rbuf) != BLK_RESTORED)
+ elog(ERROR, "prefix leaf split right page lacks its full-page image");
+ }
+ else
+ {
+ if (xlrec->rightprefixlen > 0)
+ {
+ rightprefix = datapos;
+ datapos += MAXALIGN(xlrec->rightprefixlen);
+ datalen -= MAXALIGN(xlrec->rightprefixlen);
+ }
+ if (spagenumber != P_NONE)
+ {
+ right_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(right_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(right_hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nright < MaxIndexTuplesPerPage);
+ rightitems[nright++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ rbuf = XLogInitBufferForRedo(record, 1);
+ rightpage = BufferGetPage(rbuf);
+ _bt_pageinit(rightpage, BufferGetPageSize(rbuf));
+ ropaque = BTPageGetOpaque(rightpage);
+ ropaque->btpo_prev = origpagenumber;
+ ropaque->btpo_next = spagenumber;
+ ropaque->btpo_level = xlrec->level;
+ ropaque->btpo_flags = BTP_LEAF;
+ ropaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(rightpage, right_hikey, rightitems, nright,
+ rightprefix, xlrec->rightprefixlen))
+ elog(ERROR, "failed to replay right half of a prefix leaf split");
+ PageSetLSN(rightpage, lsn);
+ }
+ MarkBufferDirty(rbuf);
+
+ /*
+ * Rebuild the left (original) page from the pre-split page, unless it was
+ * restored from a full-page image (then it is already the final left
+ * half). Block-0 data is: [newitem (left/posting)] [left high key]
+ * [left prefix]. The left plain items come from the pre-split page.
+ */
+ origaction = XLogReadBufferForRedo(record, 0, &origbuf);
+ origpage = BufferGetPage(origbuf);
+
+ if (origaction == BLK_NEEDS_REDO)
+ {
+ IndexTuple *leftitems;
+ int nleft;
+ OffsetNumber origpagepostingoff = InvalidOffsetNumber;
+ Page leftpage;
+ BTPageOpaque lopaque;
+
+ oopaque = BTPageGetOpaque(origpage);
+
+ datapos = XLogRecGetBlockData(record, 0, &datalen);
+ if (newitemonleft || xlrec->postingoff != 0)
+ {
+ newitem = (IndexTuple) datapos;
+ newitemsz = MAXALIGN(IndexTupleSize(newitem));
+ datapos += newitemsz;
+ datalen -= newitemsz;
+ }
+ left_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(left_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(left_hikey));
+ if (xlrec->leftprefixlen > 0)
+ {
+ leftprefix = datapos;
+ datapos += xlrec->leftprefixlen;
+ datalen -= xlrec->leftprefixlen;
+ }
+ Assert(datalen == 0);
+
+ if (xlrec->postingoff != 0)
+ {
+ ItemId itemid;
+ IndexTuple oposting;
+ BTItemBuf obuf;
+
+ origpagepostingoff = OffsetNumberPrev(xlrec->newitemoff);
+ newitem = CopyIndexTuple(newitem);
+ itemid = PageGetItemId(origpage, origpagepostingoff);
+ oposting = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ oposting = _bt_prefix_decode(origpage, oposting, &obuf);
+ nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff);
+ }
+
+ leftitems = palloc_array(IndexTuple, PageGetMaxOffsetNumber(origpage) + 2);
+ rightitems[0] = NULL; /* not reused below */
+ {
+ IndexTuple dummyright[MaxIndexTuplesPerPage];
+ int dummynr;
+
+ _bt_split_leaf_collect(origpage, xlrec->firstrightoff,
+ xlrec->newitemoff, newitem, newitemonleft,
+ origpagepostingoff, nposting,
+ leftitems, &nleft, dummyright, &dummynr);
+ }
+
+ leftpage = PageGetTempPageCopySpecial(origpage);
+ lopaque = BTPageGetOpaque(leftpage);
+ lopaque->btpo_flags = BTP_LEAF | BTP_INCOMPLETE_SPLIT;
+ lopaque->btpo_prev = oopaque->btpo_prev;
+ lopaque->btpo_next = rightpagenumber;
+ lopaque->btpo_level = xlrec->level;
+ lopaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(leftpage, left_hikey, leftitems, nleft,
+ leftprefix, xlrec->leftprefixlen))
+ elog(ERROR, "failed to replay left half of a prefix leaf split");
+ PageRestoreTempPage(leftpage, origpage);
+ PageSetLSN(origpage, lsn);
+ MarkBufferDirty(origbuf);
+ pfree(leftitems);
+ }
+
+ /* Fix left-link of the page to the right of the new right sibling. */
+ if (spagenumber != P_NONE)
+ {
+ Buffer sbuf;
+
+ if (XLogReadBufferForRedo(record, 2, &sbuf) == BLK_NEEDS_REDO)
+ {
+ Page spage = BufferGetPage(sbuf);
+ BTPageOpaque spageop = BTPageGetOpaque(spage);
+
+ spageop->btpo_prev = rightpagenumber;
+ PageSetLSN(spage, lsn);
+ MarkBufferDirty(sbuf);
+ }
+ if (BufferIsValid(sbuf))
+ UnlockReleaseBuffer(sbuf);
+ }
+
+ UnlockReleaseBuffer(rbuf);
+ if (BufferIsValid(origbuf))
+ UnlockReleaseBuffer(origbuf);
+}
+
static void
btree_xlog_split(bool newitemonleft, XLogReaderState *record)
{
@@ -264,6 +469,18 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
if (!XLogRecGetBlockTagExtended(record, 2, NULL, NULL, &spagenumber, NULL))
spagenumber = P_NONE;
+ /*
+ * A prefix-compressed leaf split is replayed by rebuilding both halves
+ * from the pre-split original page, exactly as the primary did, so the
+ * coded pages come out byte-identical without a full-page image.
+ */
+ if (xlrec->flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ btree_xlog_split_prefix(record, origpagenumber, rightpagenumber,
+ spagenumber, newitemonleft);
+ return;
+ }
+
/*
* Clear the incomplete split flag on the appropriate child page one level
* down when origpage/buf is an internal page (there must have been
@@ -280,7 +497,7 @@ 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 the logged tuples. */
rbuf = XLogInitBufferForRedo(record, 1);
datapos = XLogRecGetBlockData(record, 1, &datalen);
rpage = BufferGetPage(rbuf);
@@ -468,6 +685,56 @@ btree_xlog_dedup(XLogReaderState *record)
BTDedupState state;
BTDedupInterval *intervals;
Page newpage;
+ bool pageprefix; /* source page is BTP_PREFIX */
+ BTItemBuf curbuf; /* decode scratch for the current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
+
+ if (xlrec->prefixlen > 0)
+ {
+ /*
+ * Prefix-dedup: the deduped result was carried as the chosen prefix
+ * bytes, then (unless rightmost) the high key, then the deduped
+ * plain items. Rebuild the coded page byte-for-byte the way the
+ * primary did, using the same layout routine as a prefix leaf split.
+ */
+ Size datalen;
+ char *datapos = XLogRecGetBlockData(record, 0, &datalen);
+ const char *prefix = datapos;
+ IndexTuple hikey = NULL;
+ IndexTuple items[MaxIndexTuplesPerPage];
+ int nitems = 0;
+
+ datapos += MAXALIGN(xlrec->prefixlen);
+ datalen -= MAXALIGN(xlrec->prefixlen);
+ if (xlrec->flags & XLH_DEDUP_NOT_RIGHTMOST)
+ {
+ hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(hikey));
+ datalen -= MAXALIGN(IndexTupleSize(hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nitems < MaxIndexTuplesPerPage);
+ items[nitems++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ newpage = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(newpage)->btpo_flags &= ~(BTP_PREFIX | BTP_HAS_GARBAGE);
+ if (!_bt_split_leaf_layout(newpage, hikey, items, nitems,
+ prefix, xlrec->prefixlen))
+ elog(ERROR, "failed to replay prefix deduplication");
+ PageRestoreTempPage(newpage, page);
+ PageSetLSN(page, lsn);
+ MarkBufferDirty(buf);
+ UnlockReleaseBuffer(buf);
+ return;
+ }
state = palloc_object(BTDedupStateData);
state->deduplicate = true; /* unused */
@@ -487,6 +754,19 @@ btree_xlog_dedup(XLogReaderState *record)
maxoff = PageGetMaxOffsetNumber(page);
newpage = PageGetTempPageCopySpecial(page);
+ /*
+ * The source page may be prefix-compressed even on the ordinary
+ * (non-prefix) dedup path: that happens when the primary deduplicated
+ * a prefix page but re-compression of the result declined, leaving a
+ * plain page (prefixlen == 0, no full-page image). Reproduce the
+ * primary: lay the result out plain and decode each coded data item
+ * before feeding it to the dedup state machine. The high key is a
+ * pivot and is never coded.
+ */
+ pageprefix = P_HASPREFIX(opaque);
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
if (!P_RIGHTMOST(opaque))
{
ItemId itemid = PageGetItemId(page, P_HIKEY);
@@ -505,8 +785,18 @@ btree_xlog_dedup(XLogReaderState *record)
ItemId itemid = PageGetItemId(page, offnum);
IndexTuple itup = (IndexTuple) PageGetItem(page, itemid);
+ /* Decode on a prefix source so keys/posting lists are real. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
+ {
+ /* base must survive the whole run; keep it in its own buffer */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
+ }
else if (state->nintervals < xlrec->nintervals &&
state->baseoff == intervals[state->nintervals].baseoff &&
state->nitems < intervals[state->nintervals].nitems)
@@ -516,12 +806,15 @@ btree_xlog_dedup(XLogReaderState *record)
}
else
{
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
Assert(state->nintervals == xlrec->nintervals);
Assert(memcmp(state->intervals, intervals,
state->nintervals * sizeof(BTDedupInterval)) == 0);
@@ -621,11 +914,23 @@ btree_xlog_vacuum(XLogReaderState *record)
if (xlrec->ndeleted > 0)
PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * Mirror the primary: an emptied prefix-compressed leaf drops its
+ * PREFIX item and the BTP_PREFIX flag (see _bt_delitems_vacuum).
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* Clear the vacuum cycle ID, and mark the page as not containing any
* LP_DEAD items
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..ca7e6852542 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,61 @@ 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 format and code).
+ *
+ * A leaf of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page holds one extra item, the PREFIX item, at BTPageGetPrefixOff (where
+ * its first data item would otherwise be); its data items start one offset
+ * later, so P_FIRSTDATAKEY -- which every loop over a page's data items
+ * starts from -- accounts for the flag. The PREFIX item is an
+ * IndexTupleData header (t_tid zero, t_info its size and nothing else, so
+ * that code walking a page's items by IndexTupleSize, such as split redo,
+ * steps over it) followed by 1..BTREE_PREFIX_MAX prefix bytes. The prefix is
+ * chosen only when a page is written whole (CREATE INDEX, or a half of a
+ * split) and never changes after that; a page's high key, which fixes the
+ * prefix item's offset, also changes only in a split.
+ *
+ * On a BTP_PREFIX page, every non-pivot tuple (plain or posting) whose first
+ * key column is not NULL stores that column coded, as a varlena (1-byte
+ * header when it fits one, else 4-byte) whose payload starts with a uint8
+ * `shared':
+ *
+ * shared <= BTREE_PREFIX_MAX: the value's data bytes are the page prefix's
+ * first `shared' bytes followed by the rest of the payload.
+ * The value's own varlena header is recomputed on decode (the
+ * form index_form_tuple gives it: 1-byte when it fits, else
+ * 4-byte uncompressed).
+ * shared == BTREE_PREFIX_RAW: the rest of the payload is the value exactly
+ * as stored, header included. Used for a value compressed
+ * inline, or whose header is not the canonical form above.
+ *
+ * The bytes after the first column (later key columns, INCLUDE columns, the
+ * posting list, and alignment padding) are kept as they are, moved to keep
+ * their alignment, or left out when they are only the key's closing zero
+ * padding (decode puts them back). A posting list follows the key at the new
+ * key end, and the posting offset in t_tid is changed to match. Pivots, NULL
+ * first columns, and the high key are stored as on any other page.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo can repeat them, and decoding gives back the plain
+ * tuple byte for byte.
+ */
+#define BTREE_PREFIX_MAX 126
+#define BTREE_PREFIX_RAW 127 /* shared count of a raw value */
+#define BTREE_PREFIX_SHARED_MASK 0x7F /* shared count bits */
+#define BTREE_PREFIX_REST 0x80 /* bytes after the column follow */
+
+/* A leaf whose tuples share less of its prefix than this, on average, has none. */
+#define BTREE_PREFIX_MIN_SHARED 4
+
+#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 +732,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.
@@ -1122,6 +1208,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 +1224,33 @@ 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))
+
+/*
+ * Size of one half (currTuples or markTuples) of an index-only scan's tuple
+ * storage. A plain leaf's live non-pivot tuples fit in one page, so BLCKSZ
+ * would suffice for them. A prefix-compressed (BTP_PREFIX) leaf can hold more
+ * logical data than fits plain: when its tuples are decoded for the scan they
+ * can exceed BLCKSZ. Because _bt_getitem() decodes based on the page flag,
+ * not on the current reloption, and a BTP_PREFIX page can persist after
+ * prefix_compression is turned off (the flag is cleared only when a page is
+ * split, emptied or dedup-rewritten -- never by ALTER INDEX), ANY btree index
+ * may contain such a page. The buffer must therefore always reserve the slack
+ * for a decoded page, independent of the live reloption; sizing it on the
+ * option would overflow currTuples/markTuples on a surviving prefix leaf.
+ *
+ * The bound is the most distinct index tuples a leaf can hold (each at least a
+ * line pointer plus a minimally coded tuple) times the most a single first
+ * column can regain on decode (the page prefix, at most BTREE_PREFIX_MAX
+ * bytes, MAXALIGNed).
+ */
+#define BTTupleStorageSize(relation) \
+ (BLCKSZ + (BLCKSZ / (int) MAXALIGN(sizeof(IndexTupleData) + 2)) * \
+ (int) MAXALIGN(BTREE_PREFIX_MAX))
/*
* Constant definition for progress reporting. Phase numbers must match
@@ -1202,7 +1316,8 @@ extern bool _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
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 Size _bt_dedup_finish_pending(Page newpage, BTDedupState state,
+ bool overflowok);
extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids,
int nhtids);
extern void _bt_update_posting(BTVacuumPosting vacposting);
@@ -1315,6 +1430,34 @@ 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 void _bt_page_set_prefix(Page page, const char *prefix, Size len);
+extern IndexTuple _bt_prefix_encode(Page page, IndexTuple itup);
+extern Size _bt_prefix_coded_size(Page page, IndexTuple itup);
+extern bool _bt_prefix_enabled(Relation index);
+extern const char *_bt_prefix_candidate(IndexTuple itup, Size *len);
+extern int _bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup);
+extern Size _bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix);
+extern void _bt_prefix_compress_page(Relation index, Page page);
+extern void _bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen);
+extern bool _bt_split_leaf_layout(Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *prefix, Size prefixlen);
+extern void _bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft,
+ OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright);
+
/*
* prototypes for functions in nbtvalidate.c
*/
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..285eac6f1aa 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -156,25 +156,59 @@ 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 */
+ uint16 flags; /* XLH_SPLIT_* below */
+ uint8 leftprefixlen; /* leaf: left half's prefix length (0 none) */
+ uint8 rightprefixlen; /* leaf: right half's prefix length (0 none) */
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, rightprefixlen) + sizeof(uint8))
+
+/*
+ * xl_btree_split flag bits.
+ *
+ * A prefix-compressed leaf split logs no full-page image in the common case:
+ * redo rebuilds each half the way the primary did. The left half is
+ * reconstructed from the pre-split original page (block 0) together with the
+ * logged new item and high key; the right half is reconstructed from its plain
+ * data items, which are logged as block-1 data. Each half's chosen prefix
+ * bytes are logged too (block 0 for the left half, block 1 for the right), and
+ * applied with _bt_split_leaf_layout(); redo cannot run the prefix-choosing
+ * heuristic itself, so the primary logs the prefix it chose.
+ * leftprefixlen/rightprefixlen give the lengths (0 means the half is not
+ * prefix-compressed). XLH_SPLIT_LEAFPREFIX marks a leaf split that uses this
+ * path at all (so redo rebuilds coded rather than replaying items).
+ */
+#define XLH_SPLIT_LEAFPREFIX 0x01 /* leaf split, rebuild halves coded */
+#define XLH_SPLIT_RFPI 0x08 /* right half carried by a full-page image
+ * (its plain items would overflow the WAL
+ * record); redo restores it from the image */
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
* merged together into posting list tuples.
*
- * The WAL record represents a deduplication pass for a leaf page. An array
- * of BTDedupInterval structs follows.
+ * The WAL record represents a deduplication pass for a leaf page. Normally
+ * (prefixlen == 0) an array of BTDedupInterval structs follows and redo
+ * replays them against the old page. When the page is prefix-compressed
+ * (prefixlen > 0) the deduped result is instead carried as the chosen prefix
+ * bytes (prefixlen) followed by, when not rightmost, the high key, then the
+ * deduped plain data items -- each MAXALIGN'd -- and redo lays them out coded
+ * with _bt_split_leaf_layout(), avoiding a full-page image.
*/
typedef struct xl_btree_dedup
{
uint16 nintervals;
+ uint8 prefixlen; /* >0: prefix-dedup payload, see above */
+ uint8 flags; /* XLH_DEDUP_* */
- /* DEDUPLICATION INTERVALS FOLLOW */
+ /* DEDUPLICATION INTERVALS or PREFIX PAYLOAD FOLLOW */
} xl_btree_dedup;
-#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, flags) + sizeof(uint8))
+
+/* xl_btree_dedup flag bits */
+#define XLH_DEDUP_NOT_RIGHTMOST 0x01 /* prefix payload includes a high key */
+#define XLH_DEDUP_RFPI 0x02 /* prefix page carried as a forced image */
/*
* This is what we need to know about page reuse within btree. This record
diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile
index 20a1ca6a92b..ca0779e9b57 100644
--- a/src/test/modules/nbtree/Makefile
+++ b/src/test/modules/nbtree/Makefile
@@ -3,7 +3,8 @@
EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck
REGRESS = nbtree_half_dead_pages \
- nbtree_incomplete_splits
+ nbtree_incomplete_splits \
+ nbtree_prefix_rfpi
ISOLATION = backwards-scan-concurrent-splits \
predicate-empty-index
diff --git a/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
new file mode 100644
index 00000000000..1614ad0186b
--- /dev/null
+++ b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
@@ -0,0 +1,77 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+ bt_index_parent_check
+-----------------------
+
+(1 row)
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+ forced_rfpi_ok
+----------------
+ t
+(1 row)
+
+reset enable_seqscan;
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+ injection_points_detach
+-------------------------
+
+(1 row)
+
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build
index b5dc026392e..d6a34e265be 100644
--- a/src/test/modules/nbtree/meson.build
+++ b/src/test/modules/nbtree/meson.build
@@ -12,6 +12,7 @@ tests += {
'sql': [
'nbtree_half_dead_pages',
'nbtree_incomplete_splits',
+ 'nbtree_prefix_rfpi',
],
},
'isolation': {
diff --git a/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
new file mode 100644
index 00000000000..45899e5796f
--- /dev/null
+++ b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
@@ -0,0 +1,59 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+reset enable_seqscan;
+
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out
index 7c53d598649..c1dae4b55ad 100644
--- a/src/test/regress/expected/btree_index.out
+++ b/src/test/regress/expected/btree_index.out
@@ -891,3 +891,259 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql
index 6990d7818da..6cd053a1596 100644
--- a/src/test/regress/sql/btree_index.sql
+++ b/src/test/regress/sql/btree_index.sql
@@ -520,3 +520,170 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b964e5ee907..c3fb56802f6 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,7 @@ BTPageStat
BTPageState
BTParallelScanDesc
BTPendingFSM
+BTPrefixPlan
BTReadPageState
BTScanInsert
BTScanInsertData
--
2.54.0
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-29 16:42 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-30 11:52 ` Re: nbtree: opt-in leaf key prefix compression Yao Feng <fengyao0087@gmail.com>
2026-10-30 15:28 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-11-03 17:48 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-11-03 21:36 ` Greg Burd <greg@burd.me>
0 siblings, 0 replies; 12+ messages in thread
From: Greg Burd @ 2026-11-03 21:36 UTC (permalink / raw)
To: pgsql-hackers@lists.postgresql.org; +Cc: fengyao0087@gmail.com; pg@bowt.ie
Hello again,
v8 attached. This is the skip-decode follow-up I promised Yao, now
implemented and measured on bare metal.
Recap of the idea: a prefix leaf stores each data item's first key
column front-coded against a per-page prefix, and a scan decodes it
through _bt_getitem() on the way past. Yao pointed out (citing the
SIGMOD 2024 front-coding paper) that the decode CPU is the read-side
cost, and that a scan which only needs the heap TID (a plain IndexScan,
a BitmapIndexScan, COUNT(*)) shouldn't have to pay it. I agreed, with
one caveat: the tuple copy is already skipped for those scans, so the
cost that's actually left is the key *check*, and you can only skip that
where the scan keys are already known satisfied for the whole page.
What v8 does: _bt_readpage() now skips both the decode and the
_bt_checkkeys() call for a data item when all three hold:
- the scan is not an index-only scan (so->currTuples == NULL), so we
never materialize the tuple and need only its heap TID / posting
list -- both of which the coding leaves in place on the page; and
- every scan key is already proven satisfied for the whole page
(_bt_set_startikey advanced startikey to the end of the keys); and
- the scan has no array keys (array-key advancement reads the key
regardless of startikey).
In that state _bt_checkkeys()'s comparison loop starts past the last key
and reads nothing from the tuple, so the item is a guaranteed match and
continuescan is unchanged. We can take the TID straight from the
on-page tuple with no decode. I skip the whole _bt_checkkeys() call,
not just the decode: in an assert build it re-runs _bt_check_compare
from the first key to verify the startikey optimization, which would
read the still-coded key.
Index-only scans still decode, because they have to return the key. This
speeds up the "walking a known-matching run without returning the tuple"
case, not all scans.
Bare metal this time (c7i.metal-24xl, Xeon 8488C, performance governor,
turbo off, pinned to one core, THP off), 3M distinct keys sharing a
67-byte prefix, prefix index fully cached so this isolates decode CPU.
A/B is the same source built twice, once with the skip-decode gate and
once with it forced off, best-of-15 over two independent runs:
scan skip off skip on delta
------------------------ ---------- ---------- ------
bitmap index scan + count 304 ms 260 ms -14.6%
index scan + heap fetch 302 ms 241 ms -19.9%
index-only scan + count 277 ms 282 ms ~0 (gate off)
So it recovers most of the ~6-7% traversal cost I measured in the v5
read-side numbers, on exactly the scans that only want the TID, and the
index-only-scan path is unchanged within noise, the gate correctly never
fires there.
Yao's other two points (multi-column keys, and more than one prefix per
page for a leaf that straddles two groups) I still think are a separate,
larger piece of work. I'd rather land the first-column single-prefix
case first. No change to that position here.
best.
-greg
Attachments:
v8-0001-nbtree-opt-in-leaf-key-prefix-compression.patch (text/plain, 174.8K) download
From 81139bda291fffcbd6bfe91d516af3cb86f7ae86 Mon Sep 17 00:00:00 2001
From: Greg Burd <greg@burd.me>
Date: Mon, 26 Oct 2026 15:25:58 -0400
Subject: [PATCH v8] nbtree: opt-in leaf key prefix compression
Add leaf key prefix compression to nbtree, controlled by a new
per-index storage parameter, prefix_compression, that is off by default.
When a leaf page is written as a whole -- during an index build, as a
split half, or as the result of a deduplication pass -- and its data
items share a leading byte-prefix of their first key column, the page is
rebuilt in the BTP_PREFIX format: a single PREFIX item after the high
key holds the shared bytes, and each data item is stored "coded" (its
copy of the shared prefix replaced by a one-byte length). Reads of a
leaf data item funnel through _bt_getitem(), which decodes on demand;
high keys and internal-page downlinks are never coded and are read with
the existing PageGetItem().
The feature is deliberately a per-page opt-in with no on-disk version
change: BTREE_VERSION and the catalog version are untouched. A page is
converted only when it is next written whole, never by an insert, VACUUM
or key-range change, so compression "faults forward" on an existing
index. Old-format and new-format pages coexist in the same index, which
keeps pg_upgrade, physical replication, and standbys working with no
REINDEX: a replica that has not yet seen a page's conversion still reads
it correctly, and turning the parameter off (ALTER INDEX ... SET) stops
new conversions while leaving existing coded pages readable.
Prefix compression is a separate mechanism from deduplication, not an
extension of it: deduplication collapses tuples whose whole key is
equal, while prefix compression factors out a leading run shared by
distinct keys (a URL or jsonb-path column is the motivating case, where
deduplication does not help). The two compose on one page -- a build
deduplicates first and then codes the survivors, so a duplicate-heavy
prefix index is no larger than a plain one. Bottom-up index deletion
runs on coded pages unchanged, since it only removes items. A runtime
deduplication pass on a coded page decodes first, and because a coded
page can hold more logical data than fits plain, the pass aborts cleanly
and the page splits instead when the decoded set would overflow.
WAL replay reproduces the coded bytes without resorting to full-page
images in the common paths. A Relation-free layout routine,
_bt_split_leaf_layout(), together with a single item-collection routine,
_bt_split_leaf_collect(), are shared by the primary (_bt_split) and redo
(btree_xlog_split) so both derive the same plain item sequence and code
it against the same logged prefix, emitting identical bytes. An
in-place insert logs the coded tuple; a split logs each half's chosen
prefix plus the right half's plain items; a prefix deduplication logs
the chosen prefix plus the deduped plain items. A bounded fallback
carries the page as a forced full-page image only when a pathologically
compressible page's plain payload would exceed one block; realistic data
never reaches that threshold, so the nbtree-prefix-force-rfpi injection
point and a dedicated test in src/test/modules/nbtree exercise the
fallback deterministically.
A scan reading a coded leaf page skips decoding (and the key comparison
that would read the key) for any data item whose key it does not need:
when the scan is not an index-only scan and every scan key is already
known satisfied for the whole page, _bt_readpage() takes the heap TID
(and posting list, both stored uncoded) straight from the on-page tuple.
This recovers most of the decoding cost that prefix compression adds to
bitmap index scans and plain index scans over a matching range (-15% to
-20% on a cached 3M-row index of distinct long-prefixed keys);
index-only scans, which must return the key, decode as before.
amcheck decodes every leaf data item, including under heapallindexed,
and checks the BTP_PREFIX structural invariants (flag only on leaf
pages, a well-formed PREFIX item at the expected offset); its test suite
covers a prefix-compressed index. The create_index documentation and
the nbtree README describe the parameter, the on-disk format, and the
relationship to deduplication. Tested with wal_consistency_checking=all
on a primary and a streaming standby across insert, split, deduplication
(including the forced-image fallback), delete and vacuum workloads,
through crash recovery, and across a pg_upgrade that carries both page
formats.
---
contrib/amcheck/expected/check_btree.out | 27 +
contrib/amcheck/sql/check_btree.sql | 18 +
contrib/amcheck/verify_nbtree.c | 120 ++-
doc/src/sgml/ref/create_index.sgml | 35 +
src/backend/access/common/reloptions.c | 10 +
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 97 +++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 269 +++++-
src/backend/access/nbtree/nbtinsert.c | 378 ++++++--
src/backend/access/nbtree/nbtpage.c | 31 +-
src/backend/access/nbtree/nbtprefix.c | 814 ++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 111 ++-
src/backend/access/nbtree/nbtree.c | 6 +-
src/backend/access/nbtree/nbtsearch.c | 3 +-
src/backend/access/nbtree/nbtsort.c | 54 +-
src/backend/access/nbtree/nbtsplitloc.c | 84 +-
src/backend/access/nbtree/nbtutils.c | 4 +-
src/backend/access/nbtree/nbtxlog.c | 313 ++++++-
src/include/access/nbtree.h | 147 +++-
src/include/access/nbtxlog.h | 44 +-
src/test/modules/nbtree/Makefile | 3 +-
.../nbtree/expected/nbtree_prefix_rfpi.out | 77 ++
src/test/modules/nbtree/meson.build | 1 +
.../modules/nbtree/sql/nbtree_prefix_rfpi.sql | 59 ++
src/test/regress/expected/btree_index.out | 256 ++++++
src/test/regress/sql/btree_index.sql | 167 ++++
src/tools/pgindent/typedefs.list | 2 +
28 files changed, 3003 insertions(+), 129 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
create mode 100644 src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
create mode 100644 src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
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..18551483e88 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -158,7 +158,12 @@ typedef struct BtreeLastVisibleEntry
OffsetNumber offset; /* Offset on index block */
int postingIndex; /* Number in the posting list (-1 for
* non-deduplicated tuples) */
- ItemPointer tid; /* Heap tid */
+ ItemPointer tid; /* Heap tid, or NULL; points at tiddata */
+ ItemPointerData tiddata; /* Own storage for *tid: a prefix-coded leaf
+ * tuple is decoded into a scratch buffer that
+ * is reused on the next loop iteration, so we
+ * must keep the heap TID by value, not point
+ * into the (transient) decoded tuple */
} BtreeLastVisibleEntry;
/*
@@ -935,7 +940,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
lVis->postingIndex = i;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
}
}
}
@@ -961,7 +967,8 @@ bt_entry_unique_check(BtreeCheckState *state, IndexTuple itup,
lVis->blkno = targetblock;
lVis->offset = offset;
- lVis->tid = tid;
+ ItemPointerCopy(tid, &lVis->tiddata);
+ lVis->tid = &lVis->tiddata;
lVis->postingIndex = -1;
}
}
@@ -1229,6 +1236,16 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber max;
BTPageOpaque topaque;
+ /*
+ * Scratch space for decoding prefix-coded leaf tuples (_bt_getitem).
+ * itupbuf backs the current loop item; crossbuf backs the right sibling's
+ * first item in the cross-page unique check, which must coexist with the
+ * current item's state. A pivot, high key or non-prefix leaf item is
+ * returned by _bt_getitem unchanged (buffer unused).
+ */
+ BTItemBuf itupbuf;
+ BTItemBuf crossbuf;
+
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1266,6 +1283,54 @@ bt_target_page_check(BtreeCheckState *state)
}
}
+ /*
+ * Structural checks for a prefix-compressed (BTP_PREFIX) leaf. A reader
+ * trusts this format implicitly, so validate the parts that an out-of-
+ * place decode would otherwise read as garbage: the flag appears only on
+ * leaf pages, and the PREFIX item sits at the expected offset with a
+ * length in the legal range. The per-item key/content checks below run
+ * on the decoded tuple from _bt_getitem, which fails on a malformed coded
+ * item, so item-level coding is covered there.
+ */
+ if (P_HASPREFIX(topaque))
+ {
+ ItemId pitemid;
+ IndexTuple pitup;
+ Size prefixlen;
+ OffsetNumber prefixoff = BTPageGetPrefixOff(topaque);
+
+ if (!P_ISLEAF(topaque))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix compression flag set on non-leaf page in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u page lsn=%X/%08X.",
+ state->targetblock,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ if (prefixoff > max)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix-compressed page has no PREFIX item in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix offset=%u max offset=%u page lsn=%X/%08X.",
+ state->targetblock, prefixoff, max,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ pitemid = PageGetItemIdCareful(state, state->targetblock,
+ state->target, prefixoff);
+ pitup = (IndexTuple) PageGetItem(state->target, pitemid);
+ prefixlen = IndexTupleSize(pitup) - sizeof(IndexTupleData);
+ if (prefixlen < 1 || prefixlen > BTREE_PREFIX_MAX)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("PREFIX item has invalid length in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u prefix length=%zu page lsn=%X/%08X.",
+ state->targetblock, prefixlen,
+ 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"
@@ -1293,8 +1358,15 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, state->targetblock,
state->target, offset);
- itup = (IndexTuple) PageGetItem(state->target, itemid);
- tupsize = IndexTupleSize(itup);
+
+ /*
+ * lp_len and the tuple size limit are physical properties of the item
+ * as stored, so they are checked against the raw on-page tuple (coded,
+ * on a BTP_PREFIX leaf). Every key/content check below instead uses
+ * the decoded tuple returned by _bt_getitem.
+ */
+ tupsize = IndexTupleSize((IndexTuple) PageGetItem(state->target, itemid));
+ itup = _bt_getitem(state->target, offset, &itupbuf);
/*
* lp_len should match the IndexTuple reported length exactly, since
@@ -1606,7 +1678,7 @@ 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), &itupbuf);
tid = BTreeTupleGetPointsToTID(itup);
nhtid = psprintf("(%u,%u)",
ItemPointerGetBlockNumberNoCheck(tid),
@@ -1793,7 +1865,7 @@ bt_target_page_check(BtreeCheckState *state)
itemid = PageGetItemIdCareful(state, rightblock_number,
rightpage,
rightfirstoffset);
- itup = (IndexTuple) PageGetItem(rightpage, itemid);
+ itup = _bt_getitem(rightpage, rightfirstoffset, &crossbuf);
bt_entry_unique_check(state, itup, rightblock_number, rightfirstoffset, &lVis);
@@ -1857,6 +1929,8 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
BlockNumber targetnext;
Page rightpage;
OffsetNumber nline;
+ OffsetNumber rightoffset;
+ BTItemBuf firstbuf;
/* Determine target's next block number */
opaque = BTPageGetOpaque(state->target);
@@ -2016,6 +2090,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/* Return first data item (if any) */
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
P_FIRSTDATAKEY(opaque));
+ rightoffset = P_FIRSTDATAKEY(opaque);
*rightfirstoffset = P_FIRSTDATAKEY(opaque);
}
else if (!P_ISLEAF(opaque) &&
@@ -2027,6 +2102,7 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
*/
rightitem = PageGetItemIdCareful(state, targetnext, rightpage,
OffsetNumberNext(P_FIRSTDATAKEY(opaque)));
+ rightoffset = OffsetNumberNext(P_FIRSTDATAKEY(opaque));
}
else
{
@@ -2046,9 +2122,19 @@ bt_right_page_check_scankey(BtreeCheckState *state, OffsetNumber *rightfirstoffs
/*
* Return first real item scankey. Note that this relies on right page
* memory remaining allocated.
+ *
+ * On a BTP_PREFIX leaf the first data item is prefix-coded, so decode it;
+ * because the returned scankey (built by _bt_mkscankey) references the
+ * tuple's own storage, and _bt_getitem decodes into a buffer local to
+ * this function, copy the decoded tuple into the caller-reset memory
+ * context so it outlives the call. A pivot or non-prefix item is
+ * returned by _bt_getitem unchanged (it lives in the right page, which
+ * remains allocated), so no copy is needed then.
*/
- firstitup = (IndexTuple) PageGetItem(rightpage, rightitem);
- return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ firstitup = _bt_getitem(rightpage, rightoffset, &firstbuf);
+ if (firstitup == (IndexTuple) PageGetItem(rightpage, rightitem))
+ return bt_mkscankey_pivotsearch(state->rel, firstitup);
+ return bt_mkscankey_pivotsearch(state->rel, CopyIndexTuple(firstitup));
}
/*
@@ -3095,14 +3181,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);
@@ -3122,11 +3207,12 @@ invariant_l_offset(BtreeCheckState *state, BTScanInsert key,
{
BTPageOpaque topaque;
IndexTuple ritup;
+ BTItemBuf ritupbuf;
int uppnkeyatts;
ItemPointer rheaptid;
bool nonpivot;
- ritup = (IndexTuple) PageGetItem(state->target, itemid);
+ ritup = _bt_getitem(state->target, upperbound, &ritupbuf);
topaque = BTPageGetOpaque(state->target);
nonpivot = P_ISLEAF(topaque) && upperbound >= P_FIRSTDATAKEY(topaque);
@@ -3218,14 +3304,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 */
@@ -3236,12 +3321,13 @@ invariant_l_nontarget_offset(BtreeCheckState *state, BTScanInsert key,
if (cmp == 0)
{
IndexTuple child;
+ BTItemBuf childbuf;
int uppnkeyatts;
ItemPointer childheaptid;
BTPageOpaque copaque;
bool nonpivot;
- child = (IndexTuple) PageGetItem(nontarget, itemid);
+ child = _bt_getitem(nontarget, upperbound, &childbuf);
copaque = BTPageGetOpaque(nontarget);
nonpivot = P_ISLEAF(copaque) && upperbound >= P_FIRSTDATAKEY(copaque);
diff --git a/doc/src/sgml/ref/create_index.sgml b/doc/src/sgml/ref/create_index.sgml
index bb7505d171b..7e66765c3d3 100644
--- a/doc/src/sgml/ref/create_index.sgml
+++ b/doc/src/sgml/ref/create_index.sgml
@@ -477,6 +477,41 @@ CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] <replaceable class=
</note>
</listitem>
</varlistentry>
+
+ <varlistentry id="index-reloption-prefix-compression" xreflabel="prefix_compression">
+ <term><literal>prefix_compression</literal> (<type>boolean</type>)
+ <indexterm>
+ <primary><varname>prefix_compression</varname> storage parameter</primary>
+ </indexterm>
+ </term>
+ <listitem>
+ <para>
+ Controls leaf key prefix compression. When set to
+ <literal>ON</literal>, and the index's first key column is of a
+ variable-length type, a leaf page may store a shared prefix of that
+ column once and record only each tuple's unshared remainder, which can
+ substantially shrink indexes on keys that share long leading bytes
+ (for example URLs or <type>jsonb</type> keys with common paths). Set
+ to <literal>ON</literal> or <literal>OFF</literal> to enable or disable
+ the optimization. The default is <literal>OFF</literal>.
+ </para>
+
+ <note>
+ <para>
+ 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 adopts a prefix only when
+ it is written as a whole (during an index build, or either half of a
+ page split), so turning <literal>prefix_compression</literal> on or off
+ via <command>ALTER INDEX</command> does not rewrite the index: existing
+ pages are left as they are and are converted the next time they are
+ written whole. No <command>REINDEX</command> is required, and indexes
+ upgraded with <application>pg_upgrade</application> keep working
+ unchanged.
+ </para>
+ </note>
+ </listitem>
+ </varlistentry>
</variablelist>
<para>
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..596e595f20b 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1081,3 +1081,100 @@ 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
+front-code the first key column of each of its data tuples against a per-page
+prefix, which shrinks indexes on keys that share long leading bytes (URLs,
+jsonb keys with common paths, zero-padded codes). Like deduplication, this
+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.
+
+A leaf that uses the technique carries the BTP_PREFIX page flag and one extra
+item, the PREFIX item, 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. The PREFIX item is an
+IndexTupleData header followed by 1..BTREE_PREFIX_MAX prefix bytes, shaped so
+that code which walks a page's items by IndexTupleSize (such as
+_bt_restore_page) steps over it. On such a page every non-pivot tuple (plain
+or posting) whose first key column is not NULL stores that column as a varlena
+whose payload begins with a one-byte count of how many leading bytes it shares
+with the page prefix, followed by the unshared remainder (or, for a value whose
+header is not the form index_form_tuple would reproduce, the whole value after
+a sentinel count). Trailing key columns, INCLUDE columns, a posting list and
+alignment padding follow, moved to preserve alignment; padding that is only the
+key's closing zeros is dropped and restored on decode. Pivots, NULL first
+columns and the high key are always stored uncompressed. Coding and decoding
+depend only on the page prefix and the tuple, never the catalog, so WAL redo
+reproduces them and decoding yields the original tuple byte for byte. All of
+nbtprefix.c implements this; _bt_getitem() is the one accessor through which
+readers obtain a decoded leaf data tuple.
+
+A page adopts a prefix only when it is written as a whole: a finished leaf
+during an index build, or either half of a leaf page split (built coded from
+the start by _bt_split_leaf_half, since a compressed leaf can hold more logical
+data than fits on a plain page, choosing each half's own prefix and falling
+back to a plain layout or the original page's prefix if its own does not fit).
+Crucially, no insert, VACUUM, deduplication or change of a page's key range
+ever rewrites a page to keep its prefix valid: a tuple that happens to share
+fewer bytes simply records a smaller shared count. 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 physical/logical replication keep
+working and existing indexes continue to work with no REINDEX. Turning the
+parameter on with ALTER INDEX does not rewrite anything; 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 (the flag is cleared
+only when a page is split, emptied or dedup-rewritten) and are still read back
+correctly with the parameter off, since every reader decodes based on the page
+flag, not on the current reloption. Scan-time workspace that must hold decoded
+tuples (the index-only-scan currTuples/markTuples buffers, see
+BTTupleStorageSize) is therefore sized for a decoded page on every btree index,
+not just those whose parameter is currently on.
+
+Leaf changes to a prefix page are replayed without a full-page image in the
+common case. Redo cannot run the prefix-choosing heuristic (it has no
+relcache), so the primary logs the prefix it chose and both sides lay the
+page out through one Relation-free routine, _bt_split_leaf_layout(), fed by
+one item-collection routine, _bt_split_leaf_collect(), that the primary
+(_bt_split) and redo (btree_xlog_split) both call. Because the two derive
+the plain item sequence the same way and code it against the same logged
+prefix, they produce byte-identical pages. An in-place insert logs the coded
+tuple (standard insert redo adds it verbatim); a split logs each half's
+chosen prefix plus the right half's plain items; a deduplication logs the
+chosen prefix plus the deduped plain items. A bounded fallback forces a
+full-page image only when a pathologically compressible page's plain payload
+would exceed BLCKSZ (so the logged payload can never exceed one block); redo
+then restores the image via BLK_RESTORED. When VACUUM or page deletion
+empties a prefix leaf, the PREFIX item is removed and the flag cleared so that
+the invariant that an emptied leaf holds only its high key (relied on by page
+deletion) still holds.
+
+Deduplication and bottom-up index deletion both run on BTP_PREFIX pages.
+Bottom-up deletion only removes items and never rewrites survivors, so it is
+safe on coded pages unchanged. A deduplication pass decodes the page's items,
+merges duplicates into posting lists and re-compresses the result as a whole;
+because a coded page can hold more logical data than fits plain, if the
+decoded set would overflow a plain page the pass aborts cleanly and the page
+splits instead, so no in-place rewrite is ever forced to fail. An index build
+deduplicates before coding the finished page, so a duplicate-heavy prefix
+index is no larger than a plain one.
+
+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 byte 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 (deduplicate first, then code the
+survivors), which is why a build applies both. Reusing the posting-list
+format to carry a shared prefix was considered and rejected: a posting list
+stores one key plus many TIDs, whereas prefix coding stores many keys that
+share a prefix, so the two would need different per-item layouts anyway, and
+overloading posting lists would entangle the deduplication and compression
+paths that are kept separate here on purpose.
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..e4b9bc47f90 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -19,6 +19,7 @@
#include "access/tableam.h"
#include "access/xloginsert.h"
#include "miscadmin.h"
+#include "utils/injection_point.h"
#include "utils/rel.h"
static void _bt_bottomupdel_finish_pending(Page page, BTDedupState state,
@@ -69,7 +70,19 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Size pagesaving PG_USED_FOR_ASSERTS_ONLY = 0;
bool singlevalstrat = false;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
XLogRecPtr recptr;
+ IndexTuple *dedup_plain = NULL; /* deduped plain items, for prefix WAL */
+ int dedup_nplain = 0;
+ IndexTuple dedup_hikey = NULL;
+ char dedup_prefix[BTREE_PREFIX_MAX];
+ Size dedup_prefixlen = 0;
+ bool dedup_islogprefix = false; /* page ended up prefix-coded */
+ char *dedup_walbuf = NULL; /* packed prefix-dedup WAL payload */
+ Size dedup_walbuflen = 0;
+ bool dedup_rfpi = false; /* fall back to a forced full-page image */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -120,7 +133,16 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
newpage = PageGetTempPageCopySpecial(page);
PageSetLSN(newpage, PageGetLSN(page));
- /* Copy high key, if any */
+ /*
+ * The rebuilt page is laid out plain here (clear any inherited BTP_PREFIX;
+ * PageGetTempPageCopySpecial copies the special area but not the PREFIX
+ * item). If the page was prefix-compressed it is re-compressed as a whole
+ * below, after deduplication, honouring Strategy 1.
+ */
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
+ /* Copy high key, if any (a pivot, stored plain on any page) */
if (!P_RIGHTMOST(opaque))
{
ItemId hitemid = PageGetItemId(page, P_HIKEY);
@@ -140,12 +162,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
Assert(!ItemIdIsDead(itemid));
+ /* Decode on a prefix page so keys/posting lists are the real ones. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/*
* No previous/base tuple for the data item -- use the data item
- * as base tuple of pending posting list
+ * as base tuple of pending posting list. On a prefix page the
+ * base must survive the whole merge run (it is kept as a pointer
+ * in state and read by _bt_dedup_finish_pending), so keep it in a
+ * dedicated buffer rather than the per-iteration curbuf.
*/
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (state->deduplicate &&
@@ -170,7 +202,20 @@ _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);
+ /*
+ * On a prefix page the deduped items are laid out plain here; the
+ * decoded set can exceed one page even when the coded form fit, so
+ * tolerate an overflow and abort the whole pass (the caller then
+ * splits the page instead of deduplicating it).
+ */
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state,
+ pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
if (singlevalstrat)
{
@@ -198,12 +243,22 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
}
/* itup starts new pending posting list */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page,
+ (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum)),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
/* Handle the last item */
- pagesaving += _bt_dedup_finish_pending(newpage, state);
+ {
+ Size saved = _bt_dedup_finish_pending(newpage, state, pageprefix);
+
+ if (pageprefix && saved == (Size) -1)
+ goto dedup_overflow;
+ pagesaving += saved;
+ }
/*
* If no items suitable for deduplication were found, newpage must be
@@ -238,6 +293,113 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
nopaque->btpo_flags &= ~BTP_HAS_GARBAGE;
}
+ /*
+ * Re-apply prefix compression to the rebuilt (plain) page as a whole
+ * (Strategy 1), before the critical section since it allocates a scratch
+ * page. First capture the deduped plain items and high key: if the page
+ * does become prefix-compressed, redo rebuilds its coded bytes from these
+ * plus the chosen prefix (via _bt_split_leaf_layout), the same way a prefix
+ * leaf split is replayed -- no full-page image needed.
+ */
+ if (pageprefix)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(newpage);
+ OffsetNumber nmin = P_FIRSTDATAKEY(nopq);
+ OffsetNumber nmax = PageGetMaxOffsetNumber(newpage);
+
+ dedup_nplain = (nmax >= nmin) ? (int) nmax - (int) nmin + 1 : 0;
+ if (dedup_nplain > 0)
+ {
+ dedup_plain = palloc(dedup_nplain * sizeof(IndexTuple));
+ for (int i = 0; i < dedup_nplain; i++)
+ dedup_plain[i] = CopyIndexTuple((IndexTuple)
+ PageGetItem(newpage,
+ PageGetItemId(newpage, nmin + i)));
+ }
+ if (!P_RIGHTMOST(nopq))
+ {
+ ItemId hii = PageGetItemId(newpage, P_HIKEY);
+
+ dedup_hikey = CopyIndexTuple((IndexTuple) PageGetItem(newpage, hii));
+ }
+
+ _bt_prefix_compress_page(rel, newpage);
+
+ /* Did it actually compress? (It gives up if a coded item won't fit.) */
+ if (P_HASPREFIX(BTPageGetOpaque(newpage)))
+ {
+ const char *pfx = _bt_page_get_prefix(newpage, &dedup_prefixlen);
+
+ if (dedup_prefixlen > 0)
+ memcpy(dedup_prefix, pfx, dedup_prefixlen);
+ dedup_islogprefix = true;
+ }
+ }
+
+ /*
+ * Pack the prefix-dedup WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the chosen prefix bytes, then the high
+ * key (unless rightmost), then the deduped plain items, each MAXALIGN'd,
+ * in one contiguous buffer logged with a single rdata slot.
+ */
+ if (dedup_islogprefix)
+ {
+ Size off = MAXALIGN(dedup_prefixlen);
+
+ if (dedup_hikey != NULL)
+ off += MAXALIGN(IndexTupleSize(dedup_hikey));
+ for (int i = 0; i < dedup_nplain; i++)
+ off += MAXALIGN(IndexTupleSize(dedup_plain[i]));
+
+ /*
+ * A pathologically compressible page can hold more plain data than
+ * fits in one page; if the payload would be that large, fall back to
+ * a forced full-page image of the coded page (bounded at BLCKSZ).
+ */
+ if (off > BLCKSZ)
+ dedup_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ /* Let a test force the otherwise-rare full-page-image fallback. */
+ if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ dedup_rfpi = true;
+#endif
+ if (dedup_rfpi)
+ ; /* image forced; no payload buffer built */
+ else
+ {
+ dedup_walbuflen = off;
+ dedup_walbuf = palloc(Max(off, 1));
+
+ off = 0;
+ if (dedup_prefixlen > 0)
+ {
+ memcpy(dedup_walbuf, dedup_prefix, dedup_prefixlen);
+ if (MAXALIGN(dedup_prefixlen) != dedup_prefixlen)
+ memset(dedup_walbuf + dedup_prefixlen, 0,
+ MAXALIGN(dedup_prefixlen) - dedup_prefixlen);
+ off += MAXALIGN(dedup_prefixlen);
+ }
+ if (dedup_hikey != NULL)
+ {
+ Size hsz = IndexTupleSize(dedup_hikey);
+
+ memcpy(dedup_walbuf + off, dedup_hikey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(dedup_walbuf + off + hsz, 0, MAXALIGN(hsz) - hsz);
+ off += MAXALIGN(hsz);
+ }
+ for (int i = 0; i < dedup_nplain; i++)
+ {
+ Size sz = IndexTupleSize(dedup_plain[i]);
+
+ memcpy(dedup_walbuf + off, dedup_plain[i], sz);
+ if (MAXALIGN(sz) != sz)
+ memset(dedup_walbuf + off + sz, 0, MAXALIGN(sz) - sz);
+ off += MAXALIGN(sz);
+ }
+ }
+ }
+
START_CRIT_SECTION();
PageRestoreTempPage(newpage, page);
@@ -248,19 +410,38 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
{
xl_btree_dedup xlrec_dedup;
- xlrec_dedup.nintervals = state->nintervals;
+ xlrec_dedup.nintervals = dedup_islogprefix ? 0 : state->nintervals;
+ xlrec_dedup.prefixlen = (uint8) dedup_prefixlen;
+ xlrec_dedup.flags = (dedup_hikey != NULL) ? XLH_DEDUP_NOT_RIGHTMOST : 0;
+ if (dedup_rfpi)
+ xlrec_dedup.flags |= XLH_DEDUP_RFPI;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ (dedup_rfpi ? REGBUF_FORCE_IMAGE : 0));
XLogRegisterData(&xlrec_dedup, SizeOfBtreeDedup);
- /*
- * The intervals array is not in the buffer, but pretend that it is.
- * When XLogInsert stores the whole buffer, the array need not be
- * stored too.
- */
- XLogRegisterBufData(0, state->intervals,
- state->nintervals * sizeof(BTDedupInterval));
+ if (dedup_islogprefix)
+ {
+ /*
+ * Prefix-dedup: carry the chosen prefix and the deduped plain
+ * items so redo can lay out the coded page; no full-page image
+ * (unless the payload was too large, handled by the forced image).
+ */
+ if (!dedup_rfpi && dedup_walbuflen > 0)
+ XLogRegisterBufData(0, dedup_walbuf, dedup_walbuflen);
+ }
+ else
+ {
+ /*
+ * The intervals array is not in the buffer, but pretend that it
+ * is. When XLogInsert stores the whole buffer, the array need not
+ * be stored too.
+ */
+ XLogRegisterBufData(0, state->intervals,
+ state->nintervals * sizeof(BTDedupInterval));
+ }
recptr = XLogInsert(RM_BTREE_ID, XLOG_BTREE_DEDUP);
}
@@ -277,6 +458,20 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
/* cannot leak memory here */
pfree(state->htids);
pfree(state);
+ return;
+
+dedup_overflow:
+
+ /*
+ * A prefix page's deduped items did not fit when laid out plain. Leave
+ * the original page untouched and return without writing anything; the
+ * caller falls through to split the page. No critical section was
+ * entered and the buffer was not marked dirty, so the page is unchanged.
+ */
+ Assert(pageprefix);
+ pfree(newpage);
+ pfree(state->htids);
+ pfree(state);
}
/*
@@ -318,6 +513,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
TM_IndexDeleteOp delstate;
bool neverdedup;
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(opaque);
+ BTItemBuf curbuf; /* decode scratch for the loop's current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -371,9 +569,24 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
Assert(!ItemIdIsDead(itemid));
+ /*
+ * On a prefix-compressed page decode the item so _bt_keep_natts_fast
+ * sees the real key. The base of a pending interval must survive the
+ * whole run (it is compared against each following item), so keep it
+ * in its own buffer; the current item uses a reused buffer. Heap TIDs
+ * and posting lists are preserved by coding, so _bt_dedup_start_pending
+ * and _bt_bottomupdel_finish_pending (which read only TIDs/sizes, by
+ * page offset) need no decode.
+ */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
{
/* itup starts first pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
else if (_bt_keep_natts_fast(rel, state->base, itup) > nkeyatts &&
@@ -387,6 +600,9 @@ _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
_bt_bottomupdel_finish_pending(page, state, &delstate);
/* itup starts new pending interval */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
@@ -554,7 +770,7 @@ _bt_dedup_save_htid(BTDedupState state, IndexTuple itup)
* where no deduplication was possible.
*/
Size
-_bt_dedup_finish_pending(Page newpage, BTDedupState state)
+_bt_dedup_finish_pending(Page newpage, BTDedupState state, bool overflowok)
{
OffsetNumber tupoff;
Size tuplesz;
@@ -572,7 +788,11 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(state->base)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, state->base, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
spacesaving = 0;
}
@@ -591,13 +811,26 @@ _bt_dedup_finish_pending(Page newpage, BTDedupState state)
Assert(tuplesz == MAXALIGN(IndexTupleSize(final)));
Assert(tuplesz <= BTMaxItemSize);
if (PageAddItem(newpage, final, tuplesz, tupoff, false, false) == InvalidOffsetNumber)
+ {
+ pfree(final);
+ if (overflowok)
+ return (Size) -1;
elog(ERROR, "deduplication failed to add tuple to page");
+ }
pfree(final);
spacesaving = state->phystupsize - (tuplesz + sizeof(ItemIdData));
/* Increment nintervals, since we wrote a new posting list tuple */
state->nintervals++;
- Assert(spacesaving > 0 && spacesaving < BLCKSZ);
+
+ /*
+ * On an ordinary page the merged interval's tuples were all physically
+ * present on one page, so the saving is bounded by BLCKSZ. On a prefix
+ * page (overflowok) phystupsize is the DECODED size of those tuples,
+ * which can exceed a page (a coded page holds more logical data than
+ * fits plain), so only require the saving to be positive there.
+ */
+ Assert(spacesaving > 0 && (overflowok || spacesaving < BLCKSZ));
}
/* Reset state for next pending posting list */
@@ -783,16 +1016,22 @@ _bt_do_singleval(Relation rel, Page page, BTDedupState state,
OffsetNumber minoff, IndexTuple newitem)
{
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+ bool pageprefix = P_HASPREFIX(BTPageGetOpaque(page));
ItemId itemid;
IndexTuple itup;
+ BTItemBuf buf;
itemid = PageGetItemId(page, minoff);
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &buf);
if (_bt_keep_natts_fast(rel, newitem, itup) > nkeyatts)
{
itemid = PageGetItemId(page, PageGetMaxOffsetNumber(page));
itup = (IndexTuple) PageGetItem(page, itemid);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &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..b187d1896d6 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -348,8 +348,8 @@ _bt_search_insert(Relation rel, Relation heaprel, BTInsertState insertstate)
P_ISLEAF(opaque) &&
!P_IGNORE(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
@@ -1135,6 +1135,11 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple codeditup; /* itup coded for the page, or itup */
+ Size codeditemsz; /* its MAXALIGNed size */
+ IndexTuple codednposting = NULL; /* nposting coded for the page */
+ BTItemBuf opostbuf; /* decode workspace for an existing posting */
+ bool pageprefix; /* target page is BTP_PREFIX */
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1143,6 +1148,7 @@ _bt_insertonpg(Relation rel,
isroot = P_ISROOT(opaque);
isrightmost = P_RIGHTMOST(opaque);
isonly = P_LEFTMOST(opaque) && P_RIGHTMOST(opaque);
+ pageprefix = P_HASPREFIX(opaque);
/* child buffer must be given iff inserting on an internal page */
Assert(isleaf == !BufferIsValid(cbuf));
@@ -1182,6 +1188,9 @@ _bt_insertonpg(Relation rel,
*/
Assert(isleaf && itup_key->heapkeyspace && itup_key->allequalimage);
oposting = (IndexTuple) PageGetItem(page, itemid);
+ /* On a prefix page the existing posting is coded; decode for the swap. */
+ if (pageprefix)
+ oposting = _bt_prefix_decode(page, oposting, &opostbuf);
/*
* postingoff value comes from earlier call to _bt_binsrch_posting().
@@ -1213,19 +1222,50 @@ _bt_insertonpg(Relation rel,
newitemoff = OffsetNumberNext(newitemoff);
}
+ /*
+ * On a prefix-compressed page the new item is stored coded, which is at
+ * most MAXALIGN larger than plain and usually much smaller. Size the fit
+ * test and the placement by the coded size. (Split is handled on plain
+ * tuples; _bt_split re-compresses each half itself.)
+ */
+ if (pageprefix)
+ {
+ codeditup = _bt_prefix_encode(page, itup);
+ codeditemsz = MAXALIGN(IndexTupleSize(codeditup));
+ /*
+ * A posting-list split replaces the existing coded posting with a
+ * re-coded one; encode it here, before the critical section, since
+ * _bt_prefix_encode() allocates.
+ */
+ if (nposting != NULL)
+ codednposting = _bt_prefix_encode(page, nposting);
+ }
+ else
+ {
+ codeditup = itup;
+ codeditemsz = itemsz;
+ }
+
/*
* Do we need to split the page to fit the item on it?
*
* 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.
+ * only for the item and not for its line pointer. The coded item size is
+ * used; a posting-list split additionally changes the posting tuple's
+ * size, but that can only shrink or keep free space (the new posting has
+ * one fewer TID's worth of room reused by newitem), so the item fit test
+ * remains a safe upper bound.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < codeditemsz)
{
Buffer rbuf;
Assert(!split_only_page);
+ if (codeditup != itup)
+ pfree(codeditup);
+
/* split the buffer into left and right halves */
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
@@ -1295,9 +1335,25 @@ _bt_insertonpg(Relation rel,
START_CRIT_SECTION();
if (postingoff != 0)
- memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ {
+ if (pageprefix)
+ {
+ /*
+ * The existing posting is stored coded; replace its slot (at
+ * the offset just before newitemoff, since newitemoff was
+ * advanced past it above) with the coded new posting (encoded
+ * before the critical section), whose size may differ.
+ */
+ if (!PageIndexTupleOverwrite(page, OffsetNumberPrev(newitemoff),
+ codednposting, IndexTupleSize(codednposting)))
+ elog(PANIC, "failed to overwrite posting list in block %u in index \"%s\"",
+ BufferGetBlockNumber(buf), RelationGetRelationName(rel));
+ }
+ else
+ memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
+ }
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, codeditup, codeditemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1381,8 +1437,30 @@ _bt_insertonpg(Relation rel,
}
}
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- if (postingoff == 0)
+ XLogRegisterBuffer(0, buf,
+ REGBUF_STANDARD |
+ ((pageprefix && postingoff != 0) ? REGBUF_FORCE_IMAGE : 0));
+ if (pageprefix && postingoff != 0)
+ {
+ /*
+ * A posting-list split on a prefix page rewrites an existing
+ * coded posting and adds a coded item; rather than teach redo to
+ * re-code both, force a full-page image (this is a rare case).
+ * postingoff is still recorded for the record format.
+ */
+ upostingoff = postingoff;
+ XLogRegisterBufData(0, &upostingoff, sizeof(uint16));
+ }
+ else if (pageprefix)
+ {
+ /*
+ * Simple insert on a prefix page: log the CODED item bytes as
+ * they were placed on the page, so redo adds them verbatim at
+ * newitemoff -- no full-page image needed, and byte-identical.
+ */
+ XLogRegisterBufData(0, (char *) codeditup, IndexTupleSize(codeditup));
+ }
+ else if (postingoff == 0)
{
/* Just log itup from caller */
XLogRegisterBufData(0, itup, IndexTupleSize(itup));
@@ -1418,6 +1496,11 @@ _bt_insertonpg(Relation rel,
END_CRIT_SECTION();
+ if (codeditup != itup)
+ pfree(codeditup);
+ if (codednposting != NULL && codednposting != nposting)
+ pfree(codednposting);
+
/* Release subsidiary buffers */
if (BufferIsValid(metabuf))
_bt_relbuf(rel, metabuf);
@@ -1506,6 +1589,22 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
ItemId itemid;
IndexTuple firstright,
lefthighkey;
+ BTItemBuf frbuf; /* decode scratch for firstright */
+ BTItemBuf llbuf; /* decode scratch for lastleft */
+ IndexTuple *leftitems = NULL; /* leaf: plain items for the left half */
+ IndexTuple *rightitems = NULL; /* leaf: plain items for the right half */
+ int nleft = 0;
+ int nright = 0;
+ IndexTuple righthighkey = NULL; /* leaf right half's high key (NULL=rightmost) */
+ const char *origprefix = NULL;
+ Size origprefixlen = 0;
+ char leftprefix[BTREE_PREFIX_MAX]; /* chosen prefix of each leaf half */
+ char rightprefix[BTREE_PREFIX_MAX];
+ Size leftprefixlen = 0;
+ Size rightprefixlen = 0;
+ char *rightwalbuf = NULL; /* packed right-page WAL payload */
+ Size rightwalbuflen = 0;
+ bool force_rfpi = false; /* carry the right page as a forced image */
OffsetNumber firstrightoff;
OffsetNumber afterleftoff,
afterrightoff,
@@ -1575,7 +1674,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
lopaque->btpo_flags = oopaque->btpo_flags;
- lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ lopaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
/* set flag in leftpage indicating that rightpage has no downlink yet */
lopaque->btpo_flags |= BTP_INCOMPLETE_SPLIT;
lopaque->btpo_prev = oopaque->btpo_prev;
@@ -1650,6 +1749,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
itemid = PageGetItemId(origpage, firstrightoff);
itemsz = ItemIdGetLength(itemid);
firstright = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ firstright = _bt_prefix_decode(origpage, firstright, &frbuf);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
}
@@ -1673,6 +1774,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(lastleftoff >= P_FIRSTDATAKEY(oopaque));
itemid = PageGetItemId(origpage, lastleftoff);
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ lastleft = _bt_prefix_decode(origpage, lastleft, &llbuf);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
}
@@ -1712,7 +1815,11 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
/*
- * Add new high key to leftpage
+ * Add new high key to leftpage. For a leaf split the left half is built
+ * as a whole below (so it can be prefix-compressed from the start, which
+ * a plain incremental build cannot represent when the page holds more
+ * logical data than fits plain); the high key is passed to the builder
+ * then. Internal halves are built incrementally here as before.
*/
afterleftoff = P_HIKEY;
@@ -1720,11 +1827,23 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
Assert(BTreeTupleGetNAtts(lefthighkey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(itemsz == MAXALIGN(IndexTupleSize(lefthighkey)));
- if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
- elog(ERROR, "failed to add high key to the left sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
- afterleftoff = OffsetNumberNext(afterleftoff);
+ if (isleaf)
+ {
+ /* The builder needs a stable copy; lefthighkey points into workspace. */
+ lefthighkey = CopyIndexTuple(lefthighkey);
+ leftitems = palloc_array(IndexTuple, maxoff + 2);
+ rightitems = palloc_array(IndexTuple, maxoff + 2);
+ if (P_HASPREFIX(oopaque))
+ origprefix = _bt_page_get_prefix(origpage, &origprefixlen);
+ }
+ else
+ {
+ if (PageAddItem(leftpage, lefthighkey, itemsz, afterleftoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the left sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterleftoff = OffsetNumberNext(afterleftoff);
+ }
/*
* Acquire a new right page to split into, now that left page has a new
@@ -1765,7 +1884,7 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* and HAS_GARBAGE flags.
*/
ropaque->btpo_flags = oopaque->btpo_flags;
- ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE);
+ ropaque->btpo_flags &= ~(BTP_ROOT | BTP_SPLIT_END | BTP_HAS_GARBAGE | BTP_PREFIX);
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = oopaque->btpo_next;
ropaque->btpo_level = oopaque->btpo_level;
@@ -1782,21 +1901,25 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (!isrightmost)
{
- IndexTuple righthighkey;
+ ItemId hiitemid = PageGetItemId(origpage, P_HIKEY);
+ Size hiitemsz = ItemIdGetLength(hiitemid);
+ IndexTuple hikey = (IndexTuple) PageGetItem(origpage, hiitemid);
- itemid = PageGetItemId(origpage, P_HIKEY);
- itemsz = ItemIdGetLength(itemid);
- righthighkey = (IndexTuple) PageGetItem(origpage, itemid);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) > 0);
- Assert(BTreeTupleGetNAtts(righthighkey, rel) <=
+ Assert(BTreeTupleGetNAtts(hikey, rel) > 0);
+ Assert(BTreeTupleGetNAtts(hikey, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
- if (PageAddItem(rightpage, righthighkey, itemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ if (isleaf)
+ righthighkey = CopyIndexTuple(hikey); /* stable: origpage is
+ * overwritten by the left
+ * half before WAL logging */
+ else
{
- elog(ERROR, "failed to add high key to the right sibling"
- " while splitting block %u of index \"%s\"",
- origpagenumber, RelationGetRelationName(rel));
+ if (PageAddItem(rightpage, hikey, hiitemsz, afterrightoff, false, false) == InvalidOffsetNumber)
+ elog(ERROR, "failed to add high key to the right sibling"
+ " while splitting block %u of index \"%s\"",
+ origpagenumber, RelationGetRelationName(rel));
+ afterrightoff = OffsetNumberNext(afterrightoff);
}
- afterrightoff = OffsetNumberNext(afterrightoff);
}
/*
@@ -1813,7 +1936,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*
* Note: we *must* insert at least the right page's items in item-number
* order, for the benefit of _bt_restore_page().
+ *
+ * A leaf split builds each half as a whole page below (so it can be
+ * prefix-compressed from the start), which redo must reproduce byte for
+ * byte. To keep the primary and redo in lock step, both derive the
+ * split's plain item arrays from the original page through the single
+ * shared routine _bt_split_leaf_collect(); the incremental loop here
+ * handles only the internal (!isleaf) case.
*/
+ if (isleaf)
+ {
+ _bt_split_leaf_collect(origpage, firstrightoff, newitemoff, newitem,
+ newitemonleft, origpagepostingoff, nposting,
+ leftitems, &nleft, rightitems, &nright);
+ }
+ else
for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
{
IndexTuple dataitem;
@@ -1837,24 +1974,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
{
Assert(newitemoff <= firstrightoff);
if (!_bt_pgaddtup(leftpage, newitemsz, newitem, afterleftoff,
- false))
- {
+ false))
elog(ERROR, "failed to add new item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
Assert(newitemoff >= firstrightoff);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
@@ -1863,28 +1996,24 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (i < firstrightoff)
{
if (!_bt_pgaddtup(leftpage, itemsz, dataitem, afterleftoff, false))
- {
elog(ERROR, "failed to add old item to the left sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterleftoff = OffsetNumberNext(afterleftoff);
}
else
{
if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add old item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
}
- /* Handle case where newitem goes at the end of rightpage */
- if (i <= newitemoff)
+ /* Handle case where newitem goes at the end of rightpage (internal only) */
+ if (!isleaf && i <= newitemoff)
{
/*
* Can't have newitemonleft here; that would imply we were told to put
@@ -1893,15 +2022,93 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
*/
Assert(!newitemonleft && newitemoff == maxoff + 1);
if (!_bt_pgaddtup(rightpage, newitemsz, newitem, afterrightoff,
- afterrightoff == minusinfoff))
- {
+ afterrightoff == minusinfoff))
elog(ERROR, "failed to add new item to the right sibling"
" while splitting block %u of index \"%s\"",
origpagenumber, RelationGetRelationName(rel));
- }
afterrightoff = OffsetNumberNext(afterrightoff);
}
+ /*
+ * Leaf halves are built as whole pages now, so they can be prefix-
+ * compressed from the start (Strategy 1: a page is faulted into the
+ * BTP_PREFIX format only when it is written whole). The plain item arrays
+ * were collected above by _bt_split_leaf_collect(), the same routine redo
+ * uses, so the primary and redo lay out identical bytes.
+ */
+ if (isleaf)
+ {
+ _bt_split_leaf_half(rel, leftpage, lefthighkey, leftitems, nleft,
+ origprefix, origprefixlen,
+ leftprefix, &leftprefixlen);
+ _bt_split_leaf_half(rel, rightpage, righthighkey, rightitems, nright,
+ origprefix, origprefixlen,
+ rightprefix, &rightprefixlen);
+
+ /*
+ * Build the right page's WAL payload now, before the critical section
+ * (palloc is disallowed inside it): the right prefix bytes, then the
+ * right high key (unless rightmost), then the right half's plain items,
+ * each MAXALIGN'd, in one contiguous buffer logged with a single rdata
+ * slot. A very compressible right half whose plain items would exceed
+ * a page is instead carried as a full-page image (set below), so this
+ * buffer is bounded.
+ */
+ if (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0)
+ {
+ Size roff = MAXALIGN(rightprefixlen);
+
+ if (righthighkey != NULL)
+ roff += MAXALIGN(IndexTupleSize(righthighkey));
+ for (int ri = 0; ri < nright; ri++)
+ roff += MAXALIGN(IndexTupleSize(rightitems[ri]));
+ rightwalbuf = palloc(Max(roff, 1));
+ rightwalbuflen = roff;
+
+ /*
+ * Decide (before the critical section, since the injection-point
+ * cache load can allocate) whether to carry the right page as a
+ * forced full-page image: normally only when its plain payload
+ * would exceed a block, but a test can force the otherwise-rare
+ * path with the nbtree-prefix-force-rfpi injection point.
+ */
+ if (rightwalbuflen > BLCKSZ)
+ force_rfpi = true;
+#ifdef USE_INJECTION_POINTS
+ else if (IS_INJECTION_POINT_ATTACHED("nbtree-prefix-force-rfpi"))
+ force_rfpi = true;
+#endif
+
+ roff = 0;
+ if (rightprefixlen > 0)
+ {
+ memcpy(rightwalbuf, rightprefix, rightprefixlen);
+ if (MAXALIGN(rightprefixlen) != rightprefixlen)
+ memset(rightwalbuf + rightprefixlen, 0,
+ MAXALIGN(rightprefixlen) - rightprefixlen);
+ roff += MAXALIGN(rightprefixlen);
+ }
+ if (righthighkey != NULL)
+ {
+ Size hsz = IndexTupleSize(righthighkey);
+
+ memcpy(rightwalbuf + roff, righthighkey, hsz);
+ if (MAXALIGN(hsz) != hsz)
+ memset(rightwalbuf + roff + hsz, 0, MAXALIGN(hsz) - hsz);
+ roff += MAXALIGN(hsz);
+ }
+ for (int ri = 0; ri < nright; ri++)
+ {
+ Size rsz = IndexTupleSize(rightitems[ri]);
+
+ memcpy(rightwalbuf + roff, rightitems[ri], rsz);
+ if (MAXALIGN(rsz) != rsz)
+ memset(rightwalbuf + roff + rsz, 0, MAXALIGN(rsz) - rsz);
+ roff += MAXALIGN(rsz);
+ }
+ }
+ }
+
/*
* We have to grab the original right sibling (if any) and update its prev
* link. We are guaranteed that this is deadlock-free, since we couple
@@ -2005,17 +2212,71 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /*
+ * A leaf split that involves prefix compression (either half ends up
+ * compressed, or the pre-split page was compressed so its items are
+ * coded) is replayed by rebuilding both halves from the pre-split page,
+ * exactly as the primary did, rather than by replaying logged tuples --
+ * the coded physical layout would not match _bt_restore_page. No
+ * full-page image is needed; redo reconstructs the plain items from the
+ * original page (block 0) and lays each half out coded against the
+ * chosen prefix, whose length and bytes are carried in the record.
+ */
+ xlrec.flags = 0;
+ xlrec.leftprefixlen = 0;
+ xlrec.rightprefixlen = 0;
+ if (isleaf && (leftprefixlen > 0 || rightprefixlen > 0 || origprefixlen > 0))
+ {
+ xlrec.flags |= XLH_SPLIT_LEAFPREFIX;
+ xlrec.leftprefixlen = (uint8) leftprefixlen;
+ xlrec.rightprefixlen = (uint8) rightprefixlen;
+
+ /*
+ * The right half is normally carried as its packed plain payload
+ * (redo lays it out coded), far cheaper than a full-page image.
+ * But a very compressible page can hold more plain data than fits
+ * in one page; force_rfpi (decided before the critical section)
+ * carries the coded right page as a bounded full-page image instead.
+ */
+ if (force_rfpi)
+ xlrec.flags |= XLH_SPLIT_RFPI;
+ }
+
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
- XLogRegisterBuffer(1, rbuf, REGBUF_WILL_INIT);
+ /*
+ * The right page is rebuilt from scratch in redo. Ordinarily that is
+ * from the logged tuple region (REGBUF_WILL_INIT). A prefix-leaf split
+ * instead rebuilds it from its logged plain items, unless those would
+ * overflow the record (XLH_SPLIT_RFPI), in which case the coded page is
+ * carried as a forced full-page image.
+ */
+ XLogRegisterBuffer(1, rbuf,
+ (xlrec.flags & XLH_SPLIT_RFPI) ?
+ (REGBUF_STANDARD | REGBUF_FORCE_IMAGE) : REGBUF_WILL_INIT);
/* Log original right sibling, since we've changed its prev-pointer */
if (!isrightmost)
XLogRegisterBuffer(2, sbuf, REGBUF_STANDARD);
if (!isleaf)
XLogRegisterBuffer(3, cbuf, REGBUF_STANDARD);
+ /*
+ * For a prefix leaf split, carry each half's chosen prefix bytes, and
+ * (unless the right page is carried as a full-page image) the right
+ * high key and the right half's PLAIN items on block 1, so redo can lay
+ * the right page out coded without depending on block 0's state (block
+ * 0 may be restored from an automatic full-page image). The left half
+ * is rebuilt in redo from the original page (block 0) when it needs
+ * redo, or from its own FPI otherwise, so the left items are not logged.
+ */
+ if (xlrec.flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ if (!(xlrec.flags & XLH_SPLIT_RFPI) && rightwalbuflen > 0)
+ XLogRegisterBufData(1, rightwalbuf, rightwalbuflen);
+ }
+
/*
* Log the new item, if it was inserted on the left page. (If it was
* put on the right page, we don't need to explicitly WAL log it
@@ -2063,6 +2324,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
XLogRegisterBufData(0, lefthighkey,
MAXALIGN(IndexTupleSize(lefthighkey)));
+ /*
+ * For a prefix leaf split, the left half's chosen prefix bytes follow
+ * the high key on block 0 (redo reads newitem/orignewitem, then the
+ * high key, then this prefix).
+ */
+ if ((xlrec.flags & XLH_SPLIT_LEAFPREFIX) && leftprefixlen > 0)
+ XLogRegisterBufData(0, leftprefix, leftprefixlen);
+
/*
* Log the contents of the right page in the format understood by
* _bt_restore_page(). The whole right page will be recreated.
@@ -2071,11 +2340,14 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* some new func in page API. Note we only store the tuples
* themselves, knowing that they were inserted in item-number order
* and so the line pointers can be reconstructed. See comments for
- * _bt_restore_page().
+ * _bt_restore_page(). A prefix-compressed leaf split instead rebuilds
+ * the right page from the original page in redo (see XLH_SPLIT_LEAFPREFIX),
+ * so its tuple region is not logged.
*/
- XLogRegisterBufData(1,
- (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
- ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
+ if (!(xlrec.flags & XLH_SPLIT_LEAFPREFIX))
+ XLogRegisterBufData(1,
+ (char *) rightpage + ((PageHeader) rightpage)->pd_upper,
+ ((PageHeader) rightpage)->pd_special - ((PageHeader) rightpage)->pd_upper);
xlinfo = newitemonleft ? XLOG_BTREE_SPLIT_L : XLOG_BTREE_SPLIT_R;
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
@@ -2820,7 +3092,15 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
- /* Perform deduplication pass (when enabled and index-is-allequalimage) */
+ /*
+ * Perform deduplication pass (when enabled and index-is-allequalimage).
+ *
+ * On a prefix-compressed page the pass decodes items and lays the deduped
+ * result out plain before re-coding it; the decoded set can exceed one
+ * page even when the coded form fit, in which case the pass aborts without
+ * touching the page and we fall through to split it. When it does fit, the
+ * recompressed page's WAL replays without a full-page image.
+ */
if (BTGetDeduplicateItems(rel) && itup_key->allequalimage)
_bt_dedup_pass(rel, buffer, insertstate->itup, insertstate->itemsz,
(indexUnchanged || uniquedup));
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f81..c755efc81b0 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -1212,11 +1212,26 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * If VACUUM emptied a prefix-compressed leaf of all its data items, drop
+ * the PREFIX item too and clear BTP_PREFIX: an empty page carries no
+ * prefix, and page deletion relies on an emptied leaf holding only its
+ * high key (nbtpage.c's _bt_mark_page_halfdead). Recorded in the vacuum
+ * record so redo does the same.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
/*
@@ -2245,6 +2260,20 @@ _bt_mark_page_halfdead(Relation rel, Relation heaprel, Buffer leafbuf,
opaque = BTPageGetOpaque(page);
opaque->btpo_flags |= BTP_HALF_DEAD;
+ /*
+ * A prefix-compressed leaf being deleted may still carry its PREFIX item
+ * (e.g. emptied by simple deletion, which does not drop it). Remove it
+ * and clear BTP_PREFIX so the page holds only its high key, as the
+ * half-dead transition below requires. Redo (btree_xlog_mark_page_halfdead)
+ * reinitializes the page, so it needs no separate handling.
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
Assert(PageGetMaxOffsetNumber(page) == P_HIKEY);
MemSet(&trunctuple, 0, sizeof(IndexTupleData));
trunctuple.t_info = sizeof(IndexTupleData);
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 00000000000..c725f75cd92
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,814 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Leaf key prefix compression for Postgres btrees.
+ *
+ * A leaf page of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page front-codes the first key column of each non-pivot tuple against a
+ * per-page PREFIX item, so that a long key that repeats on a page costs
+ * little more than its unshared tail. The on-disk format is documented at
+ * BTP_PREFIX in nbtree.h; this file is the encoder, decoder, and the policy
+ * that chooses a page's prefix.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo repeats them, and decoding reproduces the plain
+ * tuple byte for byte. A tuple inserted later that shares less of the prefix
+ * just stores a smaller `shared' count, so no insert, VACUUM, deduplication
+ * or change of the page's key range ever has to rewrite the page to keep the
+ * prefix valid. The prefix is chosen only when a page is written as a whole
+ * (CREATE INDEX, or a half of a split).
+ *
+ * This is modeled on the leaf prefix compression of the BARK access method
+ * (src/backend/access/bark/barkutils.c).
+ *
+ *
+ * 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 "storage/bufpage.h"
+#include "utils/rel.h"
+
+/*
+ * Where each part of a tuple goes in its coded form. Coding and decoding
+ * both lay the tuple out from this, so the two cannot disagree.
+ */
+typedef struct BTPrefixPlan
+{
+ Size hoff; /* data offset: header and null bitmap */
+ Size c1; /* plain first column's size, with header */
+ Size keyend; /* end of the plain key (posting offset) */
+ uint8 code; /* first payload byte of the coded column */
+ const char *suffix; /* bytes stored after it */
+ Size suffixlen;
+ Size vsize; /* coded column's size, with header */
+ Size codedkeyend; /* end of the coded key */
+ Size total; /* size of the coded tuple */
+} BTPrefixPlan;
+
+/*
+ * Is `itup' stored coded on a BTP_PREFIX page? Only a non-pivot tuple (plain
+ * or posting) whose first column is not NULL; a pivot (high key, downlink)
+ * is stored as it is.
+ */
+static bool
+_bt_prefix_codable(IndexTuple itup)
+{
+ if (BTreeTupleIsPivot(itup))
+ return false;
+ return !IndexTupleHasNulls(itup) ||
+ !att_isnull(0, (uint8 *) itup + sizeof(IndexTupleData));
+}
+
+/*
+ * Does the varlena `val' have the header decoding would give it back: the
+ * 1-byte header if its data fit one, else an uncompressed 4-byte header?
+ */
+static bool
+_bt_prefix_canonical(const char *val)
+{
+ if (VARATT_IS_SHORT(val))
+ return !VARATT_IS_EXTERNAL(val);
+ return !VARATT_IS_EXTENDED(val) &&
+ VARSIZE(val) - VARHDRSZ + VARHDRSZ_SHORT > VARATT_SHORT_MAX;
+}
+
+/*
+ * End of a leaf tuple's key: the posting-list offset for a posting tuple, the
+ * whole tuple size otherwise. The bytes after the key (a posting list) are
+ * moved as they are.
+ */
+static Size
+_bt_prefix_keyend(IndexTuple itup)
+{
+ if (BTreeTupleIsPosting(itup))
+ return BTreeTupleGetPostingOffset(itup);
+ return IndexTupleSize(itup);
+}
+
+/*
+ * The size of a coded first column whose payload is `payload' bytes: a
+ * varlena with a 1-byte header when it fits one.
+ */
+static inline Size
+_bt_prefix_vsize(Size payload)
+{
+ if (payload + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ return payload + VARHDRSZ_SHORT;
+ return payload + VARHDRSZ;
+}
+
+/*
+ * Offset at which the bytes after the first column go in a coded tuple whose
+ * coded column ends at `vend', when in the plain tuple they start at
+ * `restorig': the first offset at or after `vend' that is the same distance
+ * past a MAXALIGN boundary.
+ */
+static inline Size
+_bt_prefix_restoff(Size vend, Size restorig)
+{
+ return vend + (MAXIMUM_ALIGNOF + restorig % MAXIMUM_ALIGNOF -
+ vend % MAXIMUM_ALIGNOF) % MAXIMUM_ALIGNOF;
+}
+
+/* Plan the coding of the codable tuple `itup' against `prefix'. */
+static void
+_bt_prefix_plan(const char *prefix, Size prefixlen, IndexTuple itup,
+ BTPrefixPlan *plan)
+{
+ const char *val;
+ Size restorig;
+ bool storerest;
+
+ plan->hoff = IndexInfoFindDataOffset(itup->t_info);
+ val = (const char *) itup + plan->hoff;
+ plan->c1 = VARSIZE_ANY(val);
+ plan->keyend = _bt_prefix_keyend(itup);
+
+ if (_bt_prefix_canonical(val))
+ {
+ const char *data = VARDATA_ANY(val);
+ Size datalen = VARSIZE_ANY_EXHDR(val);
+ Size limit = Min(prefixlen, datalen);
+ Size shared = 0;
+
+ while (shared < limit && prefix[shared] == data[shared])
+ shared++;
+ plan->code = (uint8) shared;
+ plan->suffix = data + shared;
+ plan->suffixlen = datalen - shared;
+ }
+ else
+ {
+ plan->code = BTREE_PREFIX_RAW;
+ plan->suffix = val;
+ plan->suffixlen = plan->c1;
+ }
+ plan->vsize = _bt_prefix_vsize(1 + plan->suffixlen);
+
+ /*
+ * The bytes after the first column can be left out when they are just the
+ * zero padding that ends the key.
+ */
+ restorig = plan->hoff + plan->c1;
+ storerest = plan->keyend != MAXALIGN(restorig);
+ for (Size i = restorig; !storerest && i < plan->keyend; i++)
+ storerest = ((const char *) itup)[i] != 0;
+
+ if (storerest)
+ {
+ plan->code |= BTREE_PREFIX_REST;
+ plan->codedkeyend = _bt_prefix_restoff(plan->hoff + plan->vsize,
+ restorig) +
+ (plan->keyend - restorig);
+ }
+ else
+ plan->codedkeyend = MAXALIGN(plan->hoff + plan->vsize);
+ plan->total = plan->codedkeyend + (IndexTupleSize(itup) - plan->keyend);
+}
+
+/*
+ * Copy n bytes, as memcpy. Decoding moves a few short runs of bytes per
+ * tuple; eight bytes at a time is several times faster than a libc memcpy
+ * for runs this short (the string-move startup cost dominates otherwise).
+ */
+static inline void
+_bt_prefix_copy(char *dst, const char *src, Size n)
+{
+ while (n >= sizeof(uint64))
+ {
+ memcpy(dst, src, sizeof(uint64));
+ dst += sizeof(uint64);
+ src += sizeof(uint64);
+ n -= sizeof(uint64);
+ }
+ while (n-- > 0)
+ *dst++ = *src++;
+}
+
+/* The prefix of a BTP_PREFIX page, and its length in *len. */
+const char *
+_bt_page_get_prefix(Page page, Size *len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ IndexTuple item;
+
+ Assert(P_HASPREFIX(opaque));
+ item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page,
+ BTPageGetPrefixOff(opaque)));
+ *len = IndexTupleSize(item) - sizeof(IndexTupleData);
+ return (const char *) item + sizeof(IndexTupleData);
+}
+
+/*
+ * Give `page', a leaf holding nothing yet but its high key (if it has one),
+ * the prefix `prefix' of `len' bytes: add the PREFIX item and set BTP_PREFIX.
+ * Its padding comes from the page's free space, which _bt_pageinit zeroed, so
+ * a page rebuilt in redo is the same.
+ */
+void
+_bt_page_set_prefix(Page page, const char *prefix, Size len)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ union
+ {
+ IndexTupleData hdr;
+ char data[sizeof(IndexTupleData) + BTREE_PREFIX_MAX];
+ } item;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+ Assert(len >= 1 && len <= BTREE_PREFIX_MAX);
+ Assert(PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque) - 1);
+
+ memset(&item.hdr, 0, sizeof(IndexTupleData));
+ item.hdr.t_info = (unsigned short) (sizeof(IndexTupleData) + len);
+ memcpy(item.data + sizeof(IndexTupleData), prefix, len);
+ if (PageAddItem(page, item.data, sizeof(IndexTupleData) + len,
+ BTPageGetPrefixOff(opaque), false, false) ==
+ InvalidOffsetNumber)
+ elog(ERROR, "failed to add prefix item to btree page");
+ opaque->btpo_flags |= BTP_PREFIX;
+}
+
+/*
+ * The coded form of the leaf tuple `itup' for `page', palloc'd; `itup' itself
+ * when the page has no prefix or the tuple is stored plain.
+ */
+IndexTuple
+_bt_prefix_encode(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+ char *coded;
+ char *v;
+ char *payload;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ Assert(plan.total <= INDEX_SIZE_MASK);
+
+ coded = palloc0(plan.total);
+ memcpy(coded, itup, plan.hoff);
+ v = coded + plan.hoff;
+ if (plan.vsize <= VARATT_SHORT_MAX)
+ {
+ SET_VARSIZE_SHORT(v, plan.vsize);
+ payload = v + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ SET_VARSIZE(v, plan.vsize);
+ payload = v + VARHDRSZ;
+ }
+ payload[0] = (char) plan.code;
+ memcpy(payload + 1, plan.suffix, plan.suffixlen);
+ if (plan.code & BTREE_PREFIX_REST)
+ {
+ Size restorig = plan.hoff + plan.c1;
+
+ memcpy(coded + _bt_prefix_restoff(plan.hoff + plan.vsize, restorig),
+ (char *) itup + restorig, plan.keyend - restorig);
+ }
+ memcpy(coded + plan.codedkeyend, (char *) itup + plan.keyend,
+ IndexTupleSize(itup) - plan.keyend);
+
+ ((IndexTuple) coded)->t_info =
+ (itup->t_info & ~INDEX_SIZE_MASK) | (unsigned short) plan.total;
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) coded, BTreeTupleGetNPosting(itup),
+ plan.codedkeyend);
+
+#ifdef USE_ASSERT_CHECKING
+ {
+ BTItemBuf check;
+ IndexTuple decoded = _bt_prefix_decode(page, (IndexTuple) coded,
+ &check);
+
+ Assert(IndexTupleSize(decoded) == IndexTupleSize(itup) &&
+ memcmp(decoded, itup, IndexTupleSize(itup)) == 0);
+ }
+#endif
+
+ return (IndexTuple) coded;
+}
+
+/* The MAXALIGNed size `itup' takes on `page', coded if the page codes it. */
+Size
+_bt_prefix_coded_size(Page page, IndexTuple itup)
+{
+ const char *prefix;
+ Size prefixlen;
+ BTPrefixPlan plan;
+
+ if (!P_HASPREFIX(BTPageGetOpaque(page)) ||
+ !_bt_prefix_codable(itup))
+ return MAXALIGN(IndexTupleSize(itup));
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ return MAXALIGN(plan.total);
+}
+
+/*
+ * Decode the item `itup' of the BTP_PREFIX page `page' into `buf', and return
+ * it; an item stored plain is returned as it is. The inverse of
+ * _bt_prefix_encode, byte for byte. An item that does not decode within its
+ * own bounds, or claims more of the prefix than the page has, is reported as
+ * corruption.
+ */
+IndexTuple
+_bt_prefix_decode(Page page, IndexTuple itup, BTItemBuf *buf)
+{
+ const char *prefix;
+ Size prefixlen;
+ Size size = IndexTupleSize(itup);
+ Size hoff;
+ Size codedkeyend;
+ const char *v;
+ Size vsize = 0;
+ const char *payload;
+ Size paylen = 0;
+ uint8 code;
+ Size shared;
+ char *out = buf->data;
+ char *val = NULL;
+ Size c1;
+ Size restorig;
+ Size keyend;
+
+ if (!_bt_prefix_codable(itup))
+ return itup;
+
+ prefix = _bt_page_get_prefix(page, &prefixlen);
+ hoff = IndexInfoFindDataOffset(itup->t_info);
+ codedkeyend = _bt_prefix_keyend(itup);
+ v = (const char *) itup + hoff;
+ if (hoff >= codedkeyend || codedkeyend > size ||
+ (vsize = VARSIZE_ANY(v)) > codedkeyend - hoff ||
+ (paylen = VARSIZE_ANY_EXHDR(v)) < 1)
+ goto corrupt;
+ payload = VARDATA_ANY(v);
+ code = (uint8) payload[0];
+ shared = code & BTREE_PREFIX_SHARED_MASK;
+
+ _bt_prefix_copy(out, (const char *) itup, hoff);
+ val = out + hoff;
+ if (shared == BTREE_PREFIX_RAW)
+ {
+ c1 = paylen - 1;
+ _bt_prefix_copy(val, payload + 1, c1);
+ if (c1 < VARHDRSZ_SHORT || VARSIZE_ANY(val) != c1)
+ goto corrupt;
+ }
+ else
+ {
+ Size datalen = shared + paylen - 1;
+ char *data;
+
+ if (hoff + VARHDRSZ + datalen > sizeof(BTItemBuf))
+ goto corrupt;
+ if (shared > prefixlen)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple shares %zu bytes of a %zu-byte page prefix",
+ shared, prefixlen)));
+ if (datalen + VARHDRSZ_SHORT <= VARATT_SHORT_MAX)
+ {
+ c1 = datalen + VARHDRSZ_SHORT;
+ SET_VARSIZE_SHORT(val, c1);
+ data = val + VARHDRSZ_SHORT;
+ }
+ else
+ {
+ c1 = datalen + VARHDRSZ;
+ SET_VARSIZE(val, c1);
+ data = val + VARHDRSZ;
+ }
+ _bt_prefix_copy(data, prefix, shared);
+ _bt_prefix_copy(data + shared, payload + 1, paylen - 1);
+ }
+
+ restorig = hoff + c1;
+ if (code & BTREE_PREFIX_REST)
+ {
+ Size restoff = _bt_prefix_restoff(hoff + vsize, restorig);
+
+ if (restoff > codedkeyend)
+ goto corrupt;
+ keyend = restorig + (codedkeyend - restoff);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ _bt_prefix_copy(out + restorig, (const char *) itup + restoff,
+ codedkeyend - restoff);
+ }
+ else
+ {
+ keyend = MAXALIGN(restorig);
+ if (keyend + (size - codedkeyend) > sizeof(BTItemBuf))
+ goto corrupt;
+ memset(out + restorig, 0, keyend - restorig);
+ }
+ _bt_prefix_copy(out + keyend, (const char *) itup + codedkeyend,
+ size - codedkeyend);
+
+ ((IndexTuple) out)->t_info = (itup->t_info & ~INDEX_SIZE_MASK) |
+ (unsigned short) (keyend + (size - codedkeyend));
+ if (BTreeTupleIsPosting(itup))
+ BTreeTupleSetPosting((IndexTuple) out, BTreeTupleGetNPosting(itup),
+ keyend);
+ return (IndexTuple) out;
+
+corrupt:
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("btree leaf tuple is not a valid prefix-coded tuple")));
+ return NULL; /* keep compiler quiet */
+}
+
+/*
+ * The bytes of `itup''s first column a page prefix may be taken from, and how
+ * many (at most BTREE_PREFIX_MAX) in *len; NULL when the tuple has none: it
+ * is not codable, its value is empty, or it would be stored raw.
+ */
+const char *
+_bt_prefix_candidate(IndexTuple itup, Size *len)
+{
+ const char *val;
+
+ if (!_bt_prefix_codable(itup))
+ return NULL;
+ val = (const char *) itup + IndexInfoFindDataOffset(itup->t_info);
+ if (!_bt_prefix_canonical(val) || VARSIZE_ANY_EXHDR(val) == 0)
+ return NULL;
+ *len = Min(VARSIZE_ANY_EXHDR(val), BTREE_PREFIX_MAX);
+ return VARDATA_ANY(val);
+}
+
+/*
+ * How many bytes of `prefix' the first column of `itup' would share on a page
+ * with that prefix: -1 when the tuple would not be coded, 0 when it would be
+ * stored raw.
+ */
+int
+_bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup)
+{
+ BTPrefixPlan plan;
+
+ if (!_bt_prefix_codable(itup))
+ return -1;
+ _bt_prefix_plan(prefix, prefixlen, itup, &plan);
+ if ((plan.code & BTREE_PREFIX_SHARED_MASK) == BTREE_PREFIX_RAW)
+ return 0;
+ return plan.code & BTREE_PREFIX_SHARED_MASK;
+}
+
+/*
+ * May the leaves of `index' have a prefix? Only when the prefix_compression
+ * reloption is on and the first key column is of a varlena type.
+ */
+bool
+_bt_prefix_enabled(Relation index)
+{
+ return BTGetPrefixCompression(index) &&
+ TupleDescAttr(RelationGetDescr(index), 0)->attlen == -1;
+}
+
+/*
+ * Choose the prefix for a leaf about to be built from the `n' tuples `items',
+ * in key order: the first tuple's first-column bytes, cut to the longest run
+ * any later tuple shares, provided the tuples share at least
+ * BTREE_PREFIX_MIN_SHARED bytes of it on average. Returns the prefix's
+ * length, with *prefix pointing into items[0], or 0 for no prefix.
+ */
+Size
+_bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix)
+{
+ const char *cand;
+ Size candlen;
+ Size longest = 0;
+ Size total = 0;
+ int ncoded = 0;
+
+ if (!_bt_prefix_enabled(index) ||
+ (cand = _bt_prefix_candidate(items[0], &candlen)) == NULL)
+ return 0;
+ if (n == 1)
+ {
+ *prefix = cand;
+ return candlen;
+ }
+ for (int i = 1; i < n; i++)
+ {
+ int shared = _bt_prefix_shared(cand, candlen, items[i]);
+
+ if (shared < 0)
+ continue;
+ total += shared;
+ ncoded++;
+ longest = Max(longest, (Size) shared);
+ }
+ if (longest == 0 || total < (Size) BTREE_PREFIX_MIN_SHARED * ncoded)
+ return 0;
+ *prefix = cand;
+ return longest;
+}
+
+/*
+ * Fault a freshly finished leaf `page' forward into the BTP_PREFIX format if
+ * a prefix pays off (Strategy 1: compress a page only when it is written as a
+ * whole -- CREATE INDEX page finish, a split half, a dedup rewrite -- so that
+ * no insert, VACUUM or key-range change ever forces a page rewrite, and an
+ * old-format page is converted only when it is next written whole).
+ *
+ * On entry `page' is a complete leaf: its high key at P_HIKEY if it is not
+ * rightmost, then its data items, none of them coded (the page does not yet
+ * have BTP_PREFIX). If the data items share enough of a common first-column
+ * prefix, the page is rebuilt in place with the PREFIX item inserted after
+ * the high key and every data item coded against it; otherwise the page is
+ * left unchanged. Item order and the high key are preserved.
+ *
+ * Caller must hold the page's buffer exclusively (or own the page outright,
+ * as the build and split paths do with a temp page). No WAL is written here:
+ * callers log the resulting page (a build writes it whole; a split logs each
+ * half whole or by full-page image), and redo reaches the same bytes by
+ * running _bt_prefix_encode against the same prefix.
+ */
+void
+_bt_prefix_compress_page(Relation index, Page page)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber minoff;
+ OffsetNumber maxoff;
+ int n;
+ IndexTuple *items;
+ Size prefixlen;
+ const char *prefix;
+ Page tmp;
+ OffsetNumber o;
+ IndexTuple hikey = NULL;
+ Size hikeysz = 0;
+
+ Assert(P_ISLEAF(opaque) && !P_HASPREFIX(opaque));
+
+ if (!_bt_prefix_enabled(index))
+ return;
+
+ minoff = P_FIRSTDATAKEY(opaque);
+ maxoff = PageGetMaxOffsetNumber(page);
+ n = (int) maxoff - (int) minoff + 1;
+ if (n <= 0)
+ return;
+
+ items = (IndexTuple *) palloc(n * sizeof(IndexTuple));
+ for (int i = 0; i < n; i++)
+ items[i] = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, minoff + i));
+
+ prefixlen = _bt_prefix_choose(index, items, n, &prefix);
+ if (prefixlen == 0)
+ {
+ pfree(items);
+ return;
+ }
+
+ /* The high key (if any) stays at P_HIKEY, uncoded. */
+ if (!P_RIGHTMOST(opaque))
+ {
+ ItemId hii = PageGetItemId(page, P_HIKEY);
+
+ hikeysz = ItemIdGetLength(hii);
+ hikey = (IndexTuple) PageGetItem(page, hii);
+ }
+
+ /*
+ * Rebuild into a temp page: high key (if any), PREFIX item, then each
+ * data item coded. The coded form can be up to MAXALIGN larger than the
+ * plain one, but the PREFIX item replaces far more shared bytes than it
+ * adds, so a page that was not overfull stays within bounds; if a coded
+ * item somehow does not fit, leave the page uncompressed (always valid).
+ */
+ tmp = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(tmp)->btpo_flags &= ~BTP_PREFIX;
+ if (hikey != NULL &&
+ PageAddItem(tmp, (char *) hikey, hikeysz, P_HIKEY, false, false) ==
+ InvalidOffsetNumber)
+ goto giveup;
+ _bt_page_set_prefix(tmp, prefix, prefixlen);
+ o = OffsetNumberNext(PageGetMaxOffsetNumber(tmp));
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(tmp, items[i]);
+ bool ok;
+
+ ok = PageAddItem(tmp, (char *) coded, IndexTupleSize(coded), o++,
+ false, false) != InvalidOffsetNumber;
+ if (coded != items[i])
+ pfree(coded);
+ if (!ok)
+ goto giveup;
+ }
+
+ memcpy(page, tmp, BLCKSZ);
+ pfree(tmp);
+ pfree(items);
+ return;
+
+giveup:
+ pfree(tmp);
+ pfree(items);
+}
+
+/*
+ * Lay out one half of a leaf split on `page', which the caller has
+ * initialized (flags, links) and left empty: the high key `hikey' (NULL for
+ * a rightmost half), then the `n' plain tuples `items' in key order.
+ *
+ * The half is formed as a whole, so it may take a prefix of its own: its
+ * first item's leading bytes, if the items share enough (_bt_prefix_choose).
+ * Because a prefix-compressed leaf can hold more logical data than fits in a
+ * plain page, the half is laid out coded from the start rather than built
+ * plain and compressed afterwards. When the half does not fit with its own
+ * prefix (or shares too little to take one) it is laid out plain, and if that
+ * does not fit either, with `origprefix' (the splitting page's prefix, zero
+ * length when it had none), against which it fits by construction: the items
+ * keep the shares they had on the original page.
+ *
+ * Mirrors BARK's bark_split_leaf_half. Callers: _bt_split (both halves).
+ */
+void
+_bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen)
+{
+ BTPageOpaqueData saved = *BTPageGetOpaque(page);
+ const char *prefix[3];
+ Size prefixlen[3];
+ int ntries = 0;
+
+ Assert(P_ISLEAF(&saved) && !P_HASPREFIX(&saved));
+
+ /* Candidate prefixes to try, in order: own, none, the orig page's. */
+ if (_bt_prefix_enabled(index))
+ {
+ prefixlen[ntries] = _bt_prefix_choose(index, items, n, &prefix[ntries]);
+ if (prefixlen[ntries] > 0)
+ ntries++;
+ }
+ prefix[ntries] = NULL;
+ prefixlen[ntries++] = 0;
+ if (origprefixlen > 0)
+ {
+ prefix[ntries] = origprefix;
+ prefixlen[ntries++] = origprefixlen;
+ }
+
+ for (int t = 0; t < ntries; t++)
+ {
+ if (_bt_split_leaf_layout(page, hikey, items, n, prefix[t], prefixlen[t]))
+ {
+ /* Report the prefix that won, so the split can log it for redo. */
+ if (chosenprefixlen != NULL)
+ {
+ *chosenprefixlen = prefixlen[t];
+ if (prefixlen[t] > 0)
+ memcpy(chosenprefix, prefix[t], prefixlen[t]);
+ }
+ return;
+ }
+ /* restore the empty-page state for the next attempt */
+ _bt_pageinit(page, BLCKSZ);
+ *BTPageGetOpaque(page) = saved;
+ }
+ elog(ERROR, "failed to lay out a split half of index \"%s\"",
+ RelationGetRelationName(index));
+}
+
+/*
+ * Lay out one leaf split half on the caller-initialized empty `page' using a
+ * GIVEN prefix (`prefixlen' 0 for none): the high key `hikey' (NULL for a
+ * rightmost half), the PREFIX item, then the `n' plain tuples `items' coded
+ * against the prefix. Returns false if they do not fit (caller tries another
+ * prefix). This is Relation-free and deterministic in (hikey, items, prefix),
+ * so WAL redo calls it with the logged prefix to reproduce the primary's page
+ * byte for byte.
+ */
+bool
+_bt_split_leaf_layout(Page page, IndexTuple hikey, IndexTuple *items, int n,
+ const char *prefix, Size prefixlen)
+{
+ OffsetNumber o = P_HIKEY;
+ bool fit = true;
+
+ Assert(P_ISLEAF(BTPageGetOpaque(page)) && !P_HASPREFIX(BTPageGetOpaque(page)));
+ if (hikey != NULL &&
+ PageAddItem(page, (char *) hikey, MAXALIGN(IndexTupleSize(hikey)),
+ o++, false, false) == InvalidOffsetNumber)
+ return false;
+ if (prefixlen > 0)
+ {
+ _bt_page_set_prefix(page, prefix, prefixlen);
+ o++;
+ }
+ for (int i = 0; i < n; i++)
+ {
+ IndexTuple coded = _bt_prefix_encode(page, items[i]);
+
+ if (PageAddItem(page, (char *) coded, IndexTupleSize(coded),
+ o++, false, false) == InvalidOffsetNumber)
+ fit = false;
+ if (coded != items[i])
+ pfree(coded);
+ if (!fit)
+ return false;
+ }
+#ifdef USE_ASSERT_CHECKING
+ if (P_HASPREFIX(BTPageGetOpaque(page)))
+ {
+ OffsetNumber mx = PageGetMaxOffsetNumber(page);
+ OffsetNumber off;
+
+ for (off = P_FIRSTDATAKEY(BTPageGetOpaque(page)); off <= mx; off++)
+ {
+ BTItemBuf vb;
+
+ (void) _bt_getitem(page, off, &vb);
+ }
+ }
+#endif
+ return true;
+}
+
+/*
+ * Collect the plain (decoded) data items of a leaf split into the left and
+ * right arrays, in the exact order and with the exact substitutions that the
+ * split performs: the original page's data items split at `firstrightoff',
+ * with `newitem' inserted at `newitemoff' on the side given by `newitemonleft'
+ * and, for a posting-list split, the posting at `origpagepostingoff' replaced
+ * by `nposting'. `origpage' is the pre-split page (its items are decoded when
+ * it is a BTP_PREFIX page). The arrays receive pointers that are valid until
+ * the caller is done laying out the halves; items taken from the page are
+ * copied, so the caller owns and should reset the surrounding memory context.
+ *
+ * Both the primary (_bt_split) and WAL redo (btree_xlog_split) collect items
+ * this way and then lay out each half with _bt_split_leaf_half, so the two
+ * produce byte-identical coded pages without logging a full-page image.
+ */
+void
+_bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft, OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright)
+{
+ BTPageOpaque oopaque = BTPageGetOpaque(origpage);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(origpage);
+ bool hasprefix = P_HASPREFIX(oopaque);
+ OffsetNumber i;
+
+ *nleft = 0;
+ *nright = 0;
+ for (i = P_FIRSTDATAKEY(oopaque); i <= maxoff; i = OffsetNumberNext(i))
+ {
+ ItemId itemid = PageGetItemId(origpage, i);
+ IndexTuple dataitem = (IndexTuple) PageGetItem(origpage, itemid);
+ BTItemBuf dibuf;
+
+ if (hasprefix)
+ dataitem = _bt_prefix_decode(origpage, dataitem, &dibuf);
+
+ if (i == origpagepostingoff)
+ dataitem = nposting;
+ else if (i == newitemoff)
+ {
+ if (newitemonleft)
+ leftitems[(*nleft)++] = newitem;
+ else
+ rightitems[(*nright)++] = newitem;
+ }
+
+ if (i < firstrightoff)
+ leftitems[(*nleft)++] = CopyIndexTuple(dataitem);
+ else
+ rightitems[(*nright)++] = CopyIndexTuple(dataitem);
+ }
+ if (i <= newitemoff)
+ rightitems[(*nright)++] = newitem;
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd8791..1c71c9bd997 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -130,6 +130,40 @@ static bool _bt_verify_keys_with_arraykeys(IndexScanDesc scan);
*
* Returns true if any matching items found on the page, false if none.
*/
+
+/*
+ * Can this scan skip decoding the prefix-coded key of a data item, and the
+ * _bt_checkkeys call that would read it?
+ *
+ * On a BTP_PREFIX leaf _bt_getitem() decodes the front-coded first key column
+ * of every data item, which is pure CPU work. That work -- and the
+ * _bt_checkkeys call it feeds -- is wasted when:
+ *
+ * (a) this is not an index-only scan (so->currTuples == NULL), so we never
+ * copy the tuple out and need only its heap TID / posting list, both of
+ * which coding leaves in place in the on-page tuple; and
+ * (b) _bt_checkkeys would read no key attribute anyway: every scan key is
+ * already proven satisfied for the whole page (startikey reached the
+ * end of the scan keys) and the scan has no array keys (array-key
+ * advancement reads the key regardless of startikey).
+ *
+ * In that state _bt_checkkeys' _bt_check_compare loop would start at
+ * ikey == numberOfKeys and compare nothing, returning "passes, continue"
+ * without touching the tuple. So the caller can treat the tuple as a match,
+ * leave continuescan true, and read the heap TID / posting list straight from
+ * the on-page tuple -- no decode, no _bt_checkkeys call.
+ *
+ * (We must skip the _bt_checkkeys call, not just the decode: in cassert
+ * builds _bt_checkkeys re-runs _bt_check_compare from ikey 0 to verify the
+ * startikey optimization, 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)
@@ -145,6 +179,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ BTItemBuf itupbuf; /* decode workspace for a loop's current item */
+ BTItemBuf finalbuf; /* decode workspace for a backward finaltup */
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -252,12 +288,27 @@ _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))
+ {
+ /*
+ * All scan keys satisfied page-wide and we need only the heap
+ * TID: read the on-page tuple without decoding, skip the
+ * key check, and treat it as a match (continuescan stays set).
+ */
+ itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ 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 +404,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 +464,30 @@ _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);
+ /* see matching comment in the forward loop above */
+ itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ Assert(!BTreeTupleIsPivot(itup));
+ pstate.offnum = offnum;
+ passes_quals = true;
+ }
+ else
+ {
+ itup = _bt_getitem(page, offnum, &itupbuf);
+ Assert(!BTreeTupleIsPivot(itup));
+
+ pstate.offnum = offnum;
+ 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,9 +656,10 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
BTScanOpaque so = (BTScanOpaque) scan->opaque;
Relation rel = scan->indexRelation;
TupleDesc tupdesc = RelationGetDescr(rel);
- ItemId iid;
IndexTuple firsttup,
lasttup;
+ BTItemBuf firstbuf,
+ lastbuf;
int startikey = 0,
arrayidx = 0,
firstchangingattnum;
@@ -616,12 +677,10 @@ _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);
+ 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 +2112,7 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
ScanDirection dir = pstate->dir;
OffsetNumber aheadoffnum;
IndexTuple ahead;
+ BTItemBuf aheadbuf; /* decode workspace for the look-ahead tuple */
Assert(!pstate->forcenonrequired);
@@ -2088,8 +2148,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..b8b8ec018f3 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -435,8 +435,10 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ Size half = BTTupleStorageSize(scan->indexRelation);
+
+ so->currTuples = (char *) palloc(half * 2);
+ so->markTuples = so->currTuples + half;
}
/*
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c6f..f932ca33c3a 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -696,6 +696,7 @@ _bt_compare(Relation rel,
TupleDesc itupdesc = RelationGetDescr(rel);
BTPageOpaque opaque = BTPageGetOpaque(page);
IndexTuple itup;
+ BTItemBuf itupbuf;
ItemPointer heapTid;
ScanKey scankey;
int ncmpkey;
@@ -713,7 +714,7 @@ _bt_compare(Relation rel,
if (!P_ISLEAF(opaque) && offnum == P_FIRSTDATAKEY(opaque))
return 1;
- itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+ itup = _bt_getitem(page, offnum, &itupbuf);
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dcf4..9a8b372033e 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -241,6 +241,10 @@ typedef struct BTPageState
Size btps_lastextra; /* last item's extra posting list space */
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
+ /* Leaf prefix compression: the candidate prefix of the page being filled */
+ const char *btps_prefix; /* into the page's first data item, or NULL */
+ Size btps_prefixlen; /* its length (0 = no candidate) */
+ Size btps_codedsaved; /* bytes the page's items would save coded */
struct BTPageState *btps_next; /* link to parent level, if any */
} BTPageState;
@@ -856,7 +860,8 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
*/
Assert(last_truncextra == 0 || isleaf);
if (pgspc < itupsz + (isleaf ? MAXALIGN(sizeof(ItemPointerData)) : 0) ||
- (pgspc + last_truncextra < state->btps_full && last_off > P_FIRSTKEY))
+ (pgspc + last_truncextra + state->btps_codedsaved < state->btps_full &&
+ last_off > P_FIRSTKEY))
{
/*
* Finish off the page and write it out.
@@ -980,6 +985,15 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
nopaque->btpo_next = P_NONE; /* redundant */
}
+ /*
+ * The old leaf page is complete: fault it forward into the
+ * BTP_PREFIX format if a prefix pays off. Done after btps_lowkey
+ * (the downlink, a copy of the uncoded high key) is saved, since
+ * compression rewrites opage in place.
+ */
+ if (isleaf)
+ _bt_prefix_compress_page(wstate->index, opage);
+
/*
* Write out the old page. _bt_blwritepage takes ownership of the
* 'opage' buffer.
@@ -990,6 +1004,11 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
* Reset last_off to point to new page
*/
last_off = P_FIRSTKEY;
+
+ /* The new page starts with no prefix candidate and no savings yet. */
+ state->btps_prefix = NULL;
+ state->btps_prefixlen = 0;
+ state->btps_codedsaved = 0;
}
/*
@@ -1017,6 +1036,34 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_sortaddtup(npage, itupsz, itup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Leaf prefix compression accounting (Strategy 1): the page's eventual
+ * prefix is its first data item's first-column bytes; estimate how much
+ * room coding this page's items would free, so the fillfactor test above
+ * can pack more plain items onto a page that will shrink when it is
+ * compressed at finish. Items are still placed plain here; the estimate
+ * only relaxes the soft limit, never the hard fit check.
+ */
+ if (isleaf && _bt_prefix_enabled(wstate->index))
+ {
+ if (state->btps_prefix == NULL)
+ state->btps_prefix = _bt_prefix_candidate(itup,
+ &state->btps_prefixlen);
+ else
+ {
+ int shared = _bt_prefix_shared(state->btps_prefix,
+ state->btps_prefixlen, itup);
+
+ /*
+ * A coded item drops `shared' value bytes but keeps a 1-byte
+ * share count; count the net MAXALIGN-rounded saving.
+ */
+ if (shared > 1)
+ state->btps_codedsaved +=
+ MAXALIGN(itupsz) - MAXALIGN(itupsz - (shared - 1));
+ }
+ }
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1115,6 +1162,11 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
* back one slot. Then we can dump out the page.
*/
_bt_slideleft((Page) s->btps_buf);
+
+ /* Fault the finished rightmost leaf forward into BTP_PREFIX if it pays. */
+ if (s->btps_level == 0)
+ _bt_prefix_compress_page(wstate->index, (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/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8b2..14bee568e45 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -52,6 +52,9 @@ typedef struct
int rightspace; /* space available for items on right page */
int olddataitemstotal; /* space taken by old items */
Size minfirstrightsz; /* smallest firstright size */
+ bool hasprefix; /* origpage is prefix-compressed (BTP_PREFIX) */
+ BTItemBuf lbuf; /* decode scratch for _bt_split_lastleft */
+ BTItemBuf rbuf; /* decode scratch for _bt_split_firstright */
/* candidate split point data */
int maxsplits; /* maximum number of splits */
@@ -172,6 +175,23 @@ _bt_findsplitloc(Relation rel,
olddataitemstotal = rightspace - (int) PageGetExactFreeSpace(origpage);
leaffillfactor = BTGetFillFactor(rel);
+ /*
+ * On a prefix-compressed leaf the page carries an extra PREFIX item that
+ * is not a data item. The split is reasoned about in CODED (on-page)
+ * bytes -- the physical reality of the page and of each recompressed half
+ * -- so subtract only the PREFIX item's space from olddataitemstotal and
+ * use the on-page (coded) item sizes in the scan below. (Plain-size
+ * accounting is not usable here: a compressed leaf can hold far more
+ * logical data than two plain pages, so a plain split can be infeasible.)
+ */
+ if (P_HASPREFIX(opaque))
+ {
+ ItemId pii = PageGetItemId(origpage, BTPageGetPrefixOff(opaque));
+
+ olddataitemstotal -= (int) (MAXALIGN(ItemIdGetLength(pii)) +
+ sizeof(ItemIdData));
+ }
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
state.rel = rel;
@@ -185,6 +205,7 @@ _bt_findsplitloc(Relation rel,
state.olddataitemstotal = olddataitemstotal;
state.minfirstrightsz = SIZE_MAX;
state.newitemoff = newitemoff;
+ state.hasprefix = P_HASPREFIX(opaque);
/* newitem cannot be a posting list item */
Assert(!BTreeTupleIsPosting(newitem));
@@ -463,6 +484,19 @@ _bt_recsplitloc(FindSplitData *state,
newitemisfirstright = (firstrightoff == state->newitemoff &&
!newitemonleft);
+ /*
+ * Reject a split that would leave the left page with no data items at all
+ * (firstrightoff at the first data key, with newitem not on the left).
+ * Upstream's split-point heuristics never choose such a point for a plain
+ * page; on a prefix-compressed page the coded-size accounting can make it
+ * look attractive, and _bt_split cannot form a left high key from a
+ * nonexistent lastleft.
+ */
+ if (state->hasprefix &&
+ firstrightoff == P_FIRSTDATAKEY(BTPageGetOpaque(state->origpage)) &&
+ !newitemonleft)
+ return;
+
if (newitemisfirstright)
firstrightsz = state->newitemsz;
else
@@ -686,8 +720,12 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
if (state->newitemoff > maxoff)
{
+ BTItemBuf tbuf;
+
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ if (state->hasprefix)
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
@@ -714,12 +752,20 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
*/
itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
+ /* Do cheaper test first (t_tid and the posting bit survive coding) */
if (BTreeTupleIsPosting(tup) ||
!_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
return false;
/* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ if (state->hasprefix)
+ {
+ BTItemBuf tbuf;
+
+ tup = _bt_prefix_decode(state->origpage, tup, &tbuf);
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ }
+ else
+ keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -1160,12 +1206,38 @@ static inline IndexTuple
_bt_split_lastleft(FindSplitData *state, SplitPoint *split)
{
ItemId itemid;
+ OffsetNumber off;
if (split->newitemonleft && split->firstrightoff == state->newitemoff)
return state->newitem;
- itemid = PageGetItemId(state->origpage,
- OffsetNumberPrev(split->firstrightoff));
+ off = OffsetNumberPrev(split->firstrightoff);
+
+ /*
+ * On a prefix-compressed leaf the PREFIX item sits just before the first
+ * data item. When the split boundary is at the first data key, the data
+ * predecessor is the high key on a plain page (a pivot, read plain); a
+ * rightmost page has none, so fall back to the first data item (this only
+ * feeds penalty estimation, never placement). Never read the PREFIX item
+ * as a data tuple.
+ */
+ if (state->hasprefix)
+ {
+ BTPageOpaque op = BTPageGetOpaque(state->origpage);
+
+ if (off == BTPageGetPrefixOff(op))
+ {
+ if (!P_RIGHTMOST(op))
+ return (IndexTuple) PageGetItem(state->origpage,
+ PageGetItemId(state->origpage, P_HIKEY));
+ off = P_FIRSTDATAKEY(op);
+ }
+ itemid = PageGetItemId(state->origpage, off);
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->lbuf);
+ }
+ itemid = PageGetItemId(state->origpage, off);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
@@ -1181,5 +1253,9 @@ _bt_split_firstright(FindSplitData *state, SplitPoint *split)
return state->newitem;
itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ if (state->hasprefix)
+ return _bt_prefix_decode(state->origpage,
+ (IndexTuple) PageGetItem(state->origpage, itemid),
+ &state->rbuf);
return (IndexTuple) PageGetItem(state->origpage, itemid);
}
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..1e582519237 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -243,6 +243,211 @@ btree_xlog_insert(bool isleaf, bool ismeta, bool posting,
_bt_restore_meta(record, 2);
}
+/*
+ * Replay a prefix-compressed leaf page split.
+ *
+ * Rather than replay logged tuples (whose coded physical layout
+ * _bt_restore_page could not reproduce), rebuild both halves the way the
+ * primary did: collect the split's plain items from the pre-split original
+ * page (block 0) with _bt_split_leaf_collect, then lay each half out coded
+ * against its chosen prefix (carried in the record) with _bt_split_leaf_layout.
+ * The result is byte-identical to the primary, so no full-page image is
+ * needed. The record's block-0 data carries, in order: newitem (when it went
+ * on the left, or for a posting split), the left high key, and the left half's
+ * prefix bytes; block-1 data carries the right half's prefix bytes.
+ */
+static void
+btree_xlog_split_prefix(XLogReaderState *record, BlockNumber origpagenumber,
+ BlockNumber rightpagenumber, BlockNumber spagenumber,
+ bool newitemonleft)
+{
+ XLogRecPtr lsn = record->EndRecPtr;
+ xl_btree_split *xlrec = (xl_btree_split *) XLogRecGetData(record);
+ Buffer rbuf;
+ Buffer origbuf;
+ Page origpage;
+ BTPageOpaque oopaque;
+ char *datapos;
+ Size datalen;
+ IndexTuple newitem = NULL;
+ IndexTuple left_hikey;
+ IndexTuple right_hikey = NULL;
+ IndexTuple nposting = NULL;
+ Size newitemsz = 0;
+ const char *leftprefix = NULL;
+ const char *rightprefix = NULL;
+ IndexTuple rightitems[MaxIndexTuplesPerPage];
+ int nright = 0;
+ Page rightpage;
+ BTPageOpaque ropaque;
+ XLogRedoAction origaction;
+
+ /*
+ * The right (new) page is rebuilt entirely from data logged with the
+ * record, independent of block 0, because block 0 may be restored from an
+ * automatic full-page image (so its live contents after redo are the left
+ * half, not the pre-split page). Block-1 data is, in order: the right
+ * half's chosen prefix bytes (rightprefixlen), the right high key when the
+ * split is not rightmost (spagenumber != P_NONE), then the right half's
+ * plain data items in offset order (self-describing by IndexTupleSize).
+ */
+ datapos = XLogRecGetBlockData(record, 1, &datalen);
+ if (xlrec->flags & XLH_SPLIT_RFPI)
+ {
+ /*
+ * The right half's plain items would have overflowed the WAL record,
+ * so the primary carried the coded right page as a forced full-page
+ * image. Restore it from that image.
+ */
+ if (XLogReadBufferForRedo(record, 1, &rbuf) != BLK_RESTORED)
+ elog(ERROR, "prefix leaf split right page lacks its full-page image");
+ }
+ else
+ {
+ if (xlrec->rightprefixlen > 0)
+ {
+ rightprefix = datapos;
+ datapos += MAXALIGN(xlrec->rightprefixlen);
+ datalen -= MAXALIGN(xlrec->rightprefixlen);
+ }
+ if (spagenumber != P_NONE)
+ {
+ right_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(right_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(right_hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nright < MaxIndexTuplesPerPage);
+ rightitems[nright++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ rbuf = XLogInitBufferForRedo(record, 1);
+ rightpage = BufferGetPage(rbuf);
+ _bt_pageinit(rightpage, BufferGetPageSize(rbuf));
+ ropaque = BTPageGetOpaque(rightpage);
+ ropaque->btpo_prev = origpagenumber;
+ ropaque->btpo_next = spagenumber;
+ ropaque->btpo_level = xlrec->level;
+ ropaque->btpo_flags = BTP_LEAF;
+ ropaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(rightpage, right_hikey, rightitems, nright,
+ rightprefix, xlrec->rightprefixlen))
+ elog(ERROR, "failed to replay right half of a prefix leaf split");
+ PageSetLSN(rightpage, lsn);
+ }
+ MarkBufferDirty(rbuf);
+
+ /*
+ * Rebuild the left (original) page from the pre-split page, unless it was
+ * restored from a full-page image (then it is already the final left
+ * half). Block-0 data is: [newitem (left/posting)] [left high key]
+ * [left prefix]. The left plain items come from the pre-split page.
+ */
+ origaction = XLogReadBufferForRedo(record, 0, &origbuf);
+ origpage = BufferGetPage(origbuf);
+
+ if (origaction == BLK_NEEDS_REDO)
+ {
+ IndexTuple *leftitems;
+ int nleft;
+ OffsetNumber origpagepostingoff = InvalidOffsetNumber;
+ Page leftpage;
+ BTPageOpaque lopaque;
+
+ oopaque = BTPageGetOpaque(origpage);
+
+ datapos = XLogRecGetBlockData(record, 0, &datalen);
+ if (newitemonleft || xlrec->postingoff != 0)
+ {
+ newitem = (IndexTuple) datapos;
+ newitemsz = MAXALIGN(IndexTupleSize(newitem));
+ datapos += newitemsz;
+ datalen -= newitemsz;
+ }
+ left_hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(left_hikey));
+ datalen -= MAXALIGN(IndexTupleSize(left_hikey));
+ if (xlrec->leftprefixlen > 0)
+ {
+ leftprefix = datapos;
+ datapos += xlrec->leftprefixlen;
+ datalen -= xlrec->leftprefixlen;
+ }
+ Assert(datalen == 0);
+
+ if (xlrec->postingoff != 0)
+ {
+ ItemId itemid;
+ IndexTuple oposting;
+ BTItemBuf obuf;
+
+ origpagepostingoff = OffsetNumberPrev(xlrec->newitemoff);
+ newitem = CopyIndexTuple(newitem);
+ itemid = PageGetItemId(origpage, origpagepostingoff);
+ oposting = (IndexTuple) PageGetItem(origpage, itemid);
+ if (P_HASPREFIX(oopaque))
+ oposting = _bt_prefix_decode(origpage, oposting, &obuf);
+ nposting = _bt_swap_posting(newitem, oposting, xlrec->postingoff);
+ }
+
+ leftitems = palloc_array(IndexTuple, PageGetMaxOffsetNumber(origpage) + 2);
+ rightitems[0] = NULL; /* not reused below */
+ {
+ IndexTuple dummyright[MaxIndexTuplesPerPage];
+ int dummynr;
+
+ _bt_split_leaf_collect(origpage, xlrec->firstrightoff,
+ xlrec->newitemoff, newitem, newitemonleft,
+ origpagepostingoff, nposting,
+ leftitems, &nleft, dummyright, &dummynr);
+ }
+
+ leftpage = PageGetTempPageCopySpecial(origpage);
+ lopaque = BTPageGetOpaque(leftpage);
+ lopaque->btpo_flags = BTP_LEAF | BTP_INCOMPLETE_SPLIT;
+ lopaque->btpo_prev = oopaque->btpo_prev;
+ lopaque->btpo_next = rightpagenumber;
+ lopaque->btpo_level = xlrec->level;
+ lopaque->btpo_cycleid = 0;
+ if (!_bt_split_leaf_layout(leftpage, left_hikey, leftitems, nleft,
+ leftprefix, xlrec->leftprefixlen))
+ elog(ERROR, "failed to replay left half of a prefix leaf split");
+ PageRestoreTempPage(leftpage, origpage);
+ PageSetLSN(origpage, lsn);
+ MarkBufferDirty(origbuf);
+ pfree(leftitems);
+ }
+
+ /* Fix left-link of the page to the right of the new right sibling. */
+ if (spagenumber != P_NONE)
+ {
+ Buffer sbuf;
+
+ if (XLogReadBufferForRedo(record, 2, &sbuf) == BLK_NEEDS_REDO)
+ {
+ Page spage = BufferGetPage(sbuf);
+ BTPageOpaque spageop = BTPageGetOpaque(spage);
+
+ spageop->btpo_prev = rightpagenumber;
+ PageSetLSN(spage, lsn);
+ MarkBufferDirty(sbuf);
+ }
+ if (BufferIsValid(sbuf))
+ UnlockReleaseBuffer(sbuf);
+ }
+
+ UnlockReleaseBuffer(rbuf);
+ if (BufferIsValid(origbuf))
+ UnlockReleaseBuffer(origbuf);
+}
+
static void
btree_xlog_split(bool newitemonleft, XLogReaderState *record)
{
@@ -264,6 +469,18 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
if (!XLogRecGetBlockTagExtended(record, 2, NULL, NULL, &spagenumber, NULL))
spagenumber = P_NONE;
+ /*
+ * A prefix-compressed leaf split is replayed by rebuilding both halves
+ * from the pre-split original page, exactly as the primary did, so the
+ * coded pages come out byte-identical without a full-page image.
+ */
+ if (xlrec->flags & XLH_SPLIT_LEAFPREFIX)
+ {
+ btree_xlog_split_prefix(record, origpagenumber, rightpagenumber,
+ spagenumber, newitemonleft);
+ return;
+ }
+
/*
* Clear the incomplete split flag on the appropriate child page one level
* down when origpage/buf is an internal page (there must have been
@@ -280,7 +497,7 @@ 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 the logged tuples. */
rbuf = XLogInitBufferForRedo(record, 1);
datapos = XLogRecGetBlockData(record, 1, &datalen);
rpage = BufferGetPage(rbuf);
@@ -468,6 +685,56 @@ btree_xlog_dedup(XLogReaderState *record)
BTDedupState state;
BTDedupInterval *intervals;
Page newpage;
+ bool pageprefix; /* source page is BTP_PREFIX */
+ BTItemBuf curbuf; /* decode scratch for the current item */
+ BTItemBuf basebuf; /* decode scratch kept for the pending base */
+
+ if (xlrec->prefixlen > 0)
+ {
+ /*
+ * Prefix-dedup: the deduped result was carried as the chosen prefix
+ * bytes, then (unless rightmost) the high key, then the deduped
+ * plain items. Rebuild the coded page byte-for-byte the way the
+ * primary did, using the same layout routine as a prefix leaf split.
+ */
+ Size datalen;
+ char *datapos = XLogRecGetBlockData(record, 0, &datalen);
+ const char *prefix = datapos;
+ IndexTuple hikey = NULL;
+ IndexTuple items[MaxIndexTuplesPerPage];
+ int nitems = 0;
+
+ datapos += MAXALIGN(xlrec->prefixlen);
+ datalen -= MAXALIGN(xlrec->prefixlen);
+ if (xlrec->flags & XLH_DEDUP_NOT_RIGHTMOST)
+ {
+ hikey = (IndexTuple) datapos;
+ datapos += MAXALIGN(IndexTupleSize(hikey));
+ datalen -= MAXALIGN(IndexTupleSize(hikey));
+ }
+ while (datalen > 0)
+ {
+ IndexTuple it = (IndexTuple) datapos;
+ Size sz = MAXALIGN(IndexTupleSize(it));
+
+ Assert(nitems < MaxIndexTuplesPerPage);
+ items[nitems++] = it;
+ datapos += sz;
+ datalen -= sz;
+ }
+ Assert(datalen == 0);
+
+ newpage = PageGetTempPageCopySpecial(page);
+ BTPageGetOpaque(newpage)->btpo_flags &= ~(BTP_PREFIX | BTP_HAS_GARBAGE);
+ if (!_bt_split_leaf_layout(newpage, hikey, items, nitems,
+ prefix, xlrec->prefixlen))
+ elog(ERROR, "failed to replay prefix deduplication");
+ PageRestoreTempPage(newpage, page);
+ PageSetLSN(page, lsn);
+ MarkBufferDirty(buf);
+ UnlockReleaseBuffer(buf);
+ return;
+ }
state = palloc_object(BTDedupStateData);
state->deduplicate = true; /* unused */
@@ -487,6 +754,19 @@ btree_xlog_dedup(XLogReaderState *record)
maxoff = PageGetMaxOffsetNumber(page);
newpage = PageGetTempPageCopySpecial(page);
+ /*
+ * The source page may be prefix-compressed even on the ordinary
+ * (non-prefix) dedup path: that happens when the primary deduplicated
+ * a prefix page but re-compression of the result declined, leaving a
+ * plain page (prefixlen == 0, no full-page image). Reproduce the
+ * primary: lay the result out plain and decode each coded data item
+ * before feeding it to the dedup state machine. The high key is a
+ * pivot and is never coded.
+ */
+ pageprefix = P_HASPREFIX(opaque);
+ if (pageprefix)
+ BTPageGetOpaque(newpage)->btpo_flags &= ~BTP_PREFIX;
+
if (!P_RIGHTMOST(opaque))
{
ItemId itemid = PageGetItemId(page, P_HIKEY);
@@ -505,8 +785,18 @@ btree_xlog_dedup(XLogReaderState *record)
ItemId itemid = PageGetItemId(page, offnum);
IndexTuple itup = (IndexTuple) PageGetItem(page, itemid);
+ /* Decode on a prefix source so keys/posting lists are real. */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, itup, &curbuf);
+
if (offnum == minoff)
+ {
+ /* base must survive the whole run; keep it in its own buffer */
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
+ }
else if (state->nintervals < xlrec->nintervals &&
state->baseoff == intervals[state->nintervals].baseoff &&
state->nitems < intervals[state->nintervals].nitems)
@@ -516,12 +806,15 @@ btree_xlog_dedup(XLogReaderState *record)
}
else
{
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
+ if (pageprefix)
+ itup = _bt_prefix_decode(page, (IndexTuple) PageGetItem(page, itemid),
+ &basebuf);
_bt_dedup_start_pending(state, itup, offnum);
}
}
- _bt_dedup_finish_pending(newpage, state);
+ _bt_dedup_finish_pending(newpage, state, false);
Assert(state->nintervals == xlrec->nintervals);
Assert(memcmp(state->intervals, intervals,
state->nintervals * sizeof(BTDedupInterval)) == 0);
@@ -621,11 +914,23 @@ btree_xlog_vacuum(XLogReaderState *record)
if (xlrec->ndeleted > 0)
PageIndexMultiDelete(page, (OffsetNumber *) ptr, xlrec->ndeleted);
+ opaque = BTPageGetOpaque(page);
+
+ /*
+ * Mirror the primary: an emptied prefix-compressed leaf drops its
+ * PREFIX item and the BTP_PREFIX flag (see _bt_delitems_vacuum).
+ */
+ if (P_HASPREFIX(opaque) &&
+ PageGetMaxOffsetNumber(page) == BTPageGetPrefixOff(opaque))
+ {
+ PageIndexTupleDelete(page, BTPageGetPrefixOff(opaque));
+ opaque->btpo_flags &= ~BTP_PREFIX;
+ }
+
/*
* Clear the vacuum cycle ID, and mark the page as not containing any
* LP_DEAD items
*/
- opaque = BTPageGetOpaque(page);
opaque->btpo_cycleid = 0;
opaque->btpo_flags &= ~BTP_HAS_GARBAGE;
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1af..ca7e6852542 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,61 @@ 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 format and code).
+ *
+ * A leaf of an index whose prefix_compression reloption is on, and whose
+ * first key column is of a varlena type, may be flagged BTP_PREFIX. Such a
+ * page holds one extra item, the PREFIX item, at BTPageGetPrefixOff (where
+ * its first data item would otherwise be); its data items start one offset
+ * later, so P_FIRSTDATAKEY -- which every loop over a page's data items
+ * starts from -- accounts for the flag. The PREFIX item is an
+ * IndexTupleData header (t_tid zero, t_info its size and nothing else, so
+ * that code walking a page's items by IndexTupleSize, such as split redo,
+ * steps over it) followed by 1..BTREE_PREFIX_MAX prefix bytes. The prefix is
+ * chosen only when a page is written whole (CREATE INDEX, or a half of a
+ * split) and never changes after that; a page's high key, which fixes the
+ * prefix item's offset, also changes only in a split.
+ *
+ * On a BTP_PREFIX page, every non-pivot tuple (plain or posting) whose first
+ * key column is not NULL stores that column coded, as a varlena (1-byte
+ * header when it fits one, else 4-byte) whose payload starts with a uint8
+ * `shared':
+ *
+ * shared <= BTREE_PREFIX_MAX: the value's data bytes are the page prefix's
+ * first `shared' bytes followed by the rest of the payload.
+ * The value's own varlena header is recomputed on decode (the
+ * form index_form_tuple gives it: 1-byte when it fits, else
+ * 4-byte uncompressed).
+ * shared == BTREE_PREFIX_RAW: the rest of the payload is the value exactly
+ * as stored, header included. Used for a value compressed
+ * inline, or whose header is not the canonical form above.
+ *
+ * The bytes after the first column (later key columns, INCLUDE columns, the
+ * posting list, and alignment padding) are kept as they are, moved to keep
+ * their alignment, or left out when they are only the key's closing zero
+ * padding (decode puts them back). A posting list follows the key at the new
+ * key end, and the posting offset in t_tid is changed to match. Pivots, NULL
+ * first columns, and the high key are stored as on any other page.
+ *
+ * Coding and decoding depend only on the page's prefix and the tuple, not on
+ * the catalog, so WAL redo can repeat them, and decoding gives back the plain
+ * tuple byte for byte.
+ */
+#define BTREE_PREFIX_MAX 126
+#define BTREE_PREFIX_RAW 127 /* shared count of a raw value */
+#define BTREE_PREFIX_SHARED_MASK 0x7F /* shared count bits */
+#define BTREE_PREFIX_REST 0x80 /* bytes after the column follow */
+
+/* A leaf whose tuples share less of its prefix than this, on average, has none. */
+#define BTREE_PREFIX_MIN_SHARED 4
+
+#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 +732,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.
@@ -1122,6 +1208,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 +1224,33 @@ 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))
+
+/*
+ * Size of one half (currTuples or markTuples) of an index-only scan's tuple
+ * storage. A plain leaf's live non-pivot tuples fit in one page, so BLCKSZ
+ * would suffice for them. A prefix-compressed (BTP_PREFIX) leaf can hold more
+ * logical data than fits plain: when its tuples are decoded for the scan they
+ * can exceed BLCKSZ. Because _bt_getitem() decodes based on the page flag,
+ * not on the current reloption, and a BTP_PREFIX page can persist after
+ * prefix_compression is turned off (the flag is cleared only when a page is
+ * split, emptied or dedup-rewritten -- never by ALTER INDEX), ANY btree index
+ * may contain such a page. The buffer must therefore always reserve the slack
+ * for a decoded page, independent of the live reloption; sizing it on the
+ * option would overflow currTuples/markTuples on a surviving prefix leaf.
+ *
+ * The bound is the most distinct index tuples a leaf can hold (each at least a
+ * line pointer plus a minimally coded tuple) times the most a single first
+ * column can regain on decode (the page prefix, at most BTREE_PREFIX_MAX
+ * bytes, MAXALIGNed).
+ */
+#define BTTupleStorageSize(relation) \
+ (BLCKSZ + (BLCKSZ / (int) MAXALIGN(sizeof(IndexTupleData) + 2)) * \
+ (int) MAXALIGN(BTREE_PREFIX_MAX))
/*
* Constant definition for progress reporting. Phase numbers must match
@@ -1202,7 +1316,8 @@ extern bool _bt_bottomupdel_pass(Relation rel, Buffer buf, Relation heapRel,
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 Size _bt_dedup_finish_pending(Page newpage, BTDedupState state,
+ bool overflowok);
extern IndexTuple _bt_form_posting(IndexTuple base, const ItemPointerData *htids,
int nhtids);
extern void _bt_update_posting(BTVacuumPosting vacposting);
@@ -1315,6 +1430,34 @@ 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 void _bt_page_set_prefix(Page page, const char *prefix, Size len);
+extern IndexTuple _bt_prefix_encode(Page page, IndexTuple itup);
+extern Size _bt_prefix_coded_size(Page page, IndexTuple itup);
+extern bool _bt_prefix_enabled(Relation index);
+extern const char *_bt_prefix_candidate(IndexTuple itup, Size *len);
+extern int _bt_prefix_shared(const char *prefix, Size prefixlen, IndexTuple itup);
+extern Size _bt_prefix_choose(Relation index, IndexTuple *items, int n,
+ const char **prefix);
+extern void _bt_prefix_compress_page(Relation index, Page page);
+extern void _bt_split_leaf_half(Relation index, Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *origprefix, Size origprefixlen,
+ char *chosenprefix, Size *chosenprefixlen);
+extern bool _bt_split_leaf_layout(Page page, IndexTuple hikey,
+ IndexTuple *items, int n,
+ const char *prefix, Size prefixlen);
+extern void _bt_split_leaf_collect(Page origpage, OffsetNumber firstrightoff,
+ OffsetNumber newitemoff, IndexTuple newitem,
+ bool newitemonleft,
+ OffsetNumber origpagepostingoff,
+ IndexTuple nposting,
+ IndexTuple *leftitems, int *nleft,
+ IndexTuple *rightitems, int *nright);
+
/*
* prototypes for functions in nbtvalidate.c
*/
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27fe8..285eac6f1aa 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -156,25 +156,59 @@ 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 */
+ uint16 flags; /* XLH_SPLIT_* below */
+ uint8 leftprefixlen; /* leaf: left half's prefix length (0 none) */
+ uint8 rightprefixlen; /* leaf: right half's prefix length (0 none) */
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, rightprefixlen) + sizeof(uint8))
+
+/*
+ * xl_btree_split flag bits.
+ *
+ * A prefix-compressed leaf split logs no full-page image in the common case:
+ * redo rebuilds each half the way the primary did. The left half is
+ * reconstructed from the pre-split original page (block 0) together with the
+ * logged new item and high key; the right half is reconstructed from its plain
+ * data items, which are logged as block-1 data. Each half's chosen prefix
+ * bytes are logged too (block 0 for the left half, block 1 for the right), and
+ * applied with _bt_split_leaf_layout(); redo cannot run the prefix-choosing
+ * heuristic itself, so the primary logs the prefix it chose.
+ * leftprefixlen/rightprefixlen give the lengths (0 means the half is not
+ * prefix-compressed). XLH_SPLIT_LEAFPREFIX marks a leaf split that uses this
+ * path at all (so redo rebuilds coded rather than replaying items).
+ */
+#define XLH_SPLIT_LEAFPREFIX 0x01 /* leaf split, rebuild halves coded */
+#define XLH_SPLIT_RFPI 0x08 /* right half carried by a full-page image
+ * (its plain items would overflow the WAL
+ * record); redo restores it from the image */
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
* merged together into posting list tuples.
*
- * The WAL record represents a deduplication pass for a leaf page. An array
- * of BTDedupInterval structs follows.
+ * The WAL record represents a deduplication pass for a leaf page. Normally
+ * (prefixlen == 0) an array of BTDedupInterval structs follows and redo
+ * replays them against the old page. When the page is prefix-compressed
+ * (prefixlen > 0) the deduped result is instead carried as the chosen prefix
+ * bytes (prefixlen) followed by, when not rightmost, the high key, then the
+ * deduped plain data items -- each MAXALIGN'd -- and redo lays them out coded
+ * with _bt_split_leaf_layout(), avoiding a full-page image.
*/
typedef struct xl_btree_dedup
{
uint16 nintervals;
+ uint8 prefixlen; /* >0: prefix-dedup payload, see above */
+ uint8 flags; /* XLH_DEDUP_* */
- /* DEDUPLICATION INTERVALS FOLLOW */
+ /* DEDUPLICATION INTERVALS or PREFIX PAYLOAD FOLLOW */
} xl_btree_dedup;
-#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, nintervals) + sizeof(uint16))
+#define SizeOfBtreeDedup (offsetof(xl_btree_dedup, flags) + sizeof(uint8))
+
+/* xl_btree_dedup flag bits */
+#define XLH_DEDUP_NOT_RIGHTMOST 0x01 /* prefix payload includes a high key */
+#define XLH_DEDUP_RFPI 0x02 /* prefix page carried as a forced image */
/*
* This is what we need to know about page reuse within btree. This record
diff --git a/src/test/modules/nbtree/Makefile b/src/test/modules/nbtree/Makefile
index 20a1ca6a92b..ca0779e9b57 100644
--- a/src/test/modules/nbtree/Makefile
+++ b/src/test/modules/nbtree/Makefile
@@ -3,7 +3,8 @@
EXTRA_INSTALL = src/test/modules/injection_points contrib/amcheck
REGRESS = nbtree_half_dead_pages \
- nbtree_incomplete_splits
+ nbtree_incomplete_splits \
+ nbtree_prefix_rfpi
ISOLATION = backwards-scan-concurrent-splits \
predicate-empty-index
diff --git a/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
new file mode 100644
index 00000000000..1614ad0186b
--- /dev/null
+++ b/src/test/modules/nbtree/expected/nbtree_prefix_rfpi.out
@@ -0,0 +1,77 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+ injection_points_set_local
+----------------------------
+
+(1 row)
+
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+ injection_points_attach
+-------------------------
+
+(1 row)
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+ bt_index_parent_check
+-----------------------
+
+(1 row)
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+ forced_rfpi_ok
+----------------
+ t
+(1 row)
+
+reset enable_seqscan;
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+ injection_points_detach
+-------------------------
+
+(1 row)
+
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/modules/nbtree/meson.build b/src/test/modules/nbtree/meson.build
index b5dc026392e..d6a34e265be 100644
--- a/src/test/modules/nbtree/meson.build
+++ b/src/test/modules/nbtree/meson.build
@@ -12,6 +12,7 @@ tests += {
'sql': [
'nbtree_half_dead_pages',
'nbtree_incomplete_splits',
+ 'nbtree_prefix_rfpi',
],
},
'isolation': {
diff --git a/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
new file mode 100644
index 00000000000..45899e5796f
--- /dev/null
+++ b/src/test/modules/nbtree/sql/nbtree_prefix_rfpi.sql
@@ -0,0 +1,59 @@
+--
+-- Test the forced full-page-image fallback for prefix-compressed leaves.
+--
+-- A prefix-compressed leaf can, in principle, hold more logical (decoded)
+-- data than fits on a plain page. When a split's right half or a
+-- deduplication rewrite would have to log more than one page of plain
+-- items, the WAL record instead carries the coded page as a forced
+-- full-page image (XLH_SPLIT_RFPI / XLH_DEDUP_RFPI). Realistic data never
+-- reaches that threshold -- the split-point balance keeps a half well under
+-- a page -- so the only way to exercise the path deterministically is the
+-- nbtree-prefix-force-rfpi injection point, which forces the fallback for
+-- every prefix split and deduplication regardless of size.
+--
+-- This verifies the primary produces a valid index on that path; byte-for-
+-- byte redo of the forced image is covered by the recovery/streaming tests
+-- run with wal_consistency_checking.
+--
+set client_min_messages TO 'warning';
+create extension if not exists injection_points;
+create extension if not exists amcheck;
+reset client_min_messages;
+
+-- Local to this process, so a concurrent backend is unaffected.
+SELECT injection_points_set_local();
+SELECT injection_points_attach('nbtree-prefix-force-rfpi', 'notice');
+
+-- Keys with a long shared leading prefix so the leaves compress and split.
+create table nbtree_prefix_rfpi(u text collate "C") with (autovacuum_enabled = off);
+create index nbtree_prefix_rfpi_u_idx on nbtree_prefix_rfpi (u)
+ with (prefix_compression = on);
+
+-- Distinct keys: many splits, each right half carried as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- Duplicate-heavy keys: runtime deduplication, re-coded page as a forced image.
+insert into nbtree_prefix_rfpi
+ select 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ from generate_series(1, 20000) g;
+
+-- The index must be structurally valid and agree with the heap.
+SELECT bt_index_parent_check('nbtree_prefix_rfpi_u_idx'::regclass, true, true);
+
+-- An index-only scan of the key column must equal the independent oracle.
+set enable_seqscan = off;
+SELECT (SELECT md5(string_agg(u, '|' ORDER BY u)) FROM nbtree_prefix_rfpi) =
+ md5(string_agg(x, '|' ORDER BY x)) AS forced_rfpi_ok
+ FROM (SELECT 'https://example.com/path/to/resource/' || lpad(g::text, 8, '0') AS x
+ FROM generate_series(1, 20000) g
+ UNION ALL
+ SELECT 'https://example.com/path/to/resource/' || lpad((g % 200)::text, 8, '0')
+ FROM generate_series(1, 20000) g) oracle;
+reset enable_seqscan;
+
+SELECT injection_points_detach('nbtree-prefix-force-rfpi');
+drop table nbtree_prefix_rfpi;
+drop extension amcheck;
+drop extension injection_points;
diff --git a/src/test/regress/expected/btree_index.out b/src/test/regress/expected/btree_index.out
index 7c53d598649..c1dae4b55ad 100644
--- a/src/test/regress/expected/btree_index.out
+++ b/src/test/regress/expected/btree_index.out
@@ -891,3 +891,259 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/test/regress/sql/btree_index.sql b/src/test/regress/sql/btree_index.sql
index 6990d7818da..6cd053a1596 100644
--- a/src/test/regress/sql/btree_index.sql
+++ b/src/test/regress/sql/btree_index.sql
@@ -520,3 +520,170 @@ 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 sharing exactly 125/126/127 bytes (the boundary
+-- where the one-byte shared count saturates: BTREE_PREFIX_MAX is 126,
+-- BTREE_PREFIX_RAW is 127), 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 at the 125/126/127 shared-byte 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 first column is legal and silently stores no prefix; 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 decodes the coded items, merges the duplicate TIDs
+-- into posting lists and re-codes the page. The decoded set can exceed one
+-- page even when the coded form fit, in which case the pass aborts and the
+-- page splits instead; either way the result must be correct.
+--
+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;
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 553162c0810..1f958312110 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,7 @@ BTPageStat
BTPageState
BTParallelScanDesc
BTPendingFSM
+BTPrefixPlan
BTReadPageState
BTScanInsert
BTScanInsertData
--
2.54.0
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-29 16:42 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-30 11:52 ` Re: nbtree: opt-in leaf key prefix compression Yao Feng <fengyao0087@gmail.com>
@ 2026-11-04 18:08 ` Greg Burd <greg@burd.me>
1 sibling, 0 replies; 12+ messages in thread
From: Greg Burd @ 2026-11-04 18:08 UTC (permalink / raw)
To: fengyao0087@gmail.com; +Cc: pgsql-hackers@lists.postgresql.org; pg@bowt.ie
On Fri, 30 Oct 2026 19:52:00 +0800, Yao Feng <fengyao0087@gmail.com> wrote:
> Hi Greg,
Hey Yao, I wanted to follow-up again on a suggestion you made.
> Thanks for working on this, took a look at the v4 patch.
>
> I think this is very worthwhile work. FWIW, a recent SIGMOD paper on
> B-tree compression (https://dl.acm.org/doi/10.1145/3654972) noted that
> while front-coding saves a lot of space for strings, decompression CPU cost
> tends to be the main bottleneck for queries on modern hardware. Looking at
> the v4 benchmarks, they mostly cover index size and write throughput -- did
> you have a chance to measure read-side performance (e.g. index scans, point
> lookups)?
>
> A few quick questions from having played with something similar a while
> back:
>
> 1. Skipping decompression when index tuples aren't returned:
> Right now _bt_readpage() eagerly calls _bt_getitem() to decode every leaf
> tuple. That's necessary when the scan actually needs to return the index
> tuple (like IndexOnlyScan), but for operations that don't return tuples and
> only care about the heap TID -- such as a plain IndexScan, BitmapIndexScan,
> or COUNT(*) -- could we skip decompression?
>
> 2. Multi-column indexes:
> The patch requires column 0 to be a varlena. I'm curious if you considered
> supporting subsequent columns (e.g. for something like `(tenant_id int, url
> text)`), or if keeping it strictly to the first column was just a natural
> place to draw the line on complexity for now?
This is a good idea and if I dismissed it earlier I apologize. But it
will be tricky to do. Each column used to form a key may need a
different key prefix, or maybe more than one can share a key prefix on a
page.
I'll have to think about this and do some research to find out how this
has been approached by other databases in the past.
> 3. Prefix boundaries within a page:
> With one prefix per page, if a page spans two different domains (say, a
> bunch of `http://example.com/1` <http://example.com/1;, `.../2` followed by
> `http://sample.org/1` <http://sample.org/1;, `.../2`), the second group
> only shares "http://";. Just curious if you've considered this kind of case?
I have not, in that case you'd ideally like multiple prefixes for the
same column. Then you have a different set of issues to manage, which
prefix belongs to a tuple? I'll have to dig on this one too.
> Curious to hear your thoughts.
>
> --
> Yao Feng
thanks for the ideas,
-greg
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-11-03 21:48 ` Peter Geoghegan <pg@bowt.ie>
2026-11-04 14:31 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
1 sibling, 1 reply; 12+ messages in thread
From: Peter Geoghegan @ 2026-11-03 21:48 UTC (permalink / raw)
To: Greg Burd <greg@burd.me>; +Cc: pgsql-hackers@lists.postgresql.org
On Mon, Oct 26, 2026 at 4:25 PM Greg Burd <greg@burd.me> wrote:
> This is a patch that adds leaf key prefix compression to nbtree, behind
> a new per-index storage parameter, prefix_compression, off by default.
> It's something I started a long time ago when primarily working on
> JSONB-like data storage/indexing/etc. in Postgres when a colleague
> pointed out that it didn't yet exist and his measurements showed that it
> should. He wasn't wrong. It's a solid win for performance and overhead
> IMO, but YMMV.
I also have a vibe-coded patch adding prefix compression to nbtree,
from back in April. I had completely forgotten about it until you
reminded me. I attach it here now.
In the spirit of the age, I also had Claude do a comparative review of
both of our patches. I also attach that here. I skimmed it a bit,
which helped me to remember bits and pieces about the prompt I used
back in April. Apparently my patch doesn't compose with deduplication,
while yours does. On the other hand, your patch cannot handle prefixes
unless the prefix column is of a varlena type. So there are cases
where one performs much better than the other, and vice-versa. That's
what I would have guessed.
> The patch is one commit against master. I'd appreciate review of the
> approach and the code, and I'm happy to work through whatever it turns
> up.
We're now living in a world where the cost of producing a prototype
like this has plummeted. The community's culture hasn't figured out
how to deal with this yet. I personally don't have much of a clue what
to do about it, but I am sure that certain things need to be assessed
very differently now. Generating a patch that, in some technical and
superficial sense, converges on being a complete implementation of
some complicated optimization means very little now.
Posting a huge AI generated patch that nobody will read could still
make sense if there were doubts about the general efficacy of a
particular technique, as a kind of informal proof of its feasibility,
but I don't think that applies here. It's fairly obvious that prefix
compression can cut the storage overhead of indexes by 3x or more in
sympathetic cases, at the cost of CPU overhead for decompression, and
lots of complexity.
The best feedback I can offer is that I think you should have started
with a very high level discussion of the architectural trade-offs. You
said "indexes on keys that share a long leading prefix (URLs,
reverse-DNS names, a leading JSONB path, a tenant-id prefix) spend
most of their bytes storing that prefix over and over", which is
obviously true, but you neglected to mention why you felt that was the
use case to target. Do users actually use nbtree indexes for JSONB
paths? I have to admit that I didn't know that.
It seems likely that a design such as yours, which omits column-level
compression, will never incorporate it later on once committed. Why do
you think that's the best trade-off? I personally tend to doubt that
it is, but I've given it little thought so who knows?
More generally, can you paint a detailed picture of what motivated you
to work on this? I wouldn't expect that kind of background with an
optimization that was an unalloyed good, and required no changes to
the on-disk format. But for something like this, which would commit us
to supporting its compressed representation forever, a detailed
picture of how it affects users is completely essential. A really
solid understanding of the trade-offs and the entire design space is
hugely important.
There are complex trade-offs around whether and to what extent prefix
compression works by compressing things in terms of the index key
space versus on a more ad-hoc basis. As Modern B-Tree Techniques puts
it: "Code simplicity argues for truncating the same prefix from all
entries in a B-tree node. Moreover, one can apply prefix truncation
based on the actual keys currently held in a node or based on the
possible key range as defined by the separator keys in parent (and
possibly other ancestor) pages."
Where does this design fit under his taxonomy? It looks like you're
not relying on the compressed prefix to act like a low key. Why was
that? What's the trade-off around picking a base key from the tuples
present when the page is "written as a whole"? What happens if, later
on, more tuples are inserted that are within the page's key space but
not compressible using its base key? Obviously you just don't compress
those tuples, but why is that generally okay?
You placed a lot of emphasis on your WAL logging strategy, and how
prefix compression needs to work during recovery, but I don't think
that's the place to start. When it comes to lower-level details I'd be
much more concerned about guarantees that a legal page split will
always be available, even in extreme pathological cases. But that's
really a facet of the on-disk representation itself, and the design as
it relates to that.
BTW, what is src/backend/access/bark/barkutils.c?
--
Peter Geoghegan
Attachments: download all (4 files, .tar.gz)
prefix-compression-design-comparison.md (text/markdown, 24.2K) download
# nbtree leaf prefix compression: two designs compared
Branches compared, both built on the same master base commit:
* `greg-prefix-compression`: one commit, "nbtree: opt-in leaf key prefix
compression" (Greg Burd). Per-page byte prefix of the first key column,
enabled by a reloption.
* `prefix-compression`: three commits, "Add prefix compression to nbtree leaf
pages", "amcheck: verify prefix-compressed nbtree leaf pages", "pageinspect:
decode compressed follower TIDs in bt_page_items" (Peter Geoghegan).
Anchor/follower groups that omit the leading key columns, enabled
automatically for eligible indexes.
Both branches were read in full, built with assertions, and measured on the
same data (section 9). Every number below comes from those runs; every
reading-based claim is marked as such.
## 1. Summary
The two designs remove different redundancy, so each wins where the other
does nothing:
| Workload (1M rows) | master | greg-prefix-compression | prefix-compression |
|--------------------------------------------|-------:|------------------------:|-------------------:|
| `(url)` 1M distinct 45-byte keys, built | 8286 | 7544 (leaf density 34%)| 8286 (ineligible) |
| `(url)` same keys, random-order inserts | 10769 | 3889 | 10769 (ineligible) |
| `(k, id)` 1000 keys x 1000 rows, built | 9462 | 8580 (leaf density 42%)| 2796 |
| `(k, id)` same rows, random-order inserts | 12398 | 4863 | 3763 |
| `(a, b, c)` int4, built | 3853 | 3853 (first col not varlena) | 2865 |
| `(a, b)` int4, 10 rows per key, built | 1157 | 1157 (deduplicated) | 2749 (no dedup) |
Sizes are in 8 KB pages. The master column is the greg branch with the
reloption off, which leaves nbtree unchanged.
Strongest point of each design:
* greg-prefix-compression: a coded tuple is self-contained given the page
prefix. Coding needs no catalog, keeps `t_tid` and posting lists intact,
never forces a page rewrite on insert or delete, composes with
deduplication, and is a per-page opt-in with no metapage or version change.
* prefix-compression: a follower has zero bytes of overhead, pages are dense
from CREATE INDEX on, groups adapt per page ("drift"), and it covers the
multi-column shape (TPC-C style keys) that the other design cannot touch.
Weakest point of each design:
* greg-prefix-compression: CREATE INDEX does not shrink the index. A built
leaf holds one plain page of tuples, then gets coded, so built leaves are
34-42% full and the page count drops only by the relaxed fillfactor. The
no-image split redo is bypassed whenever the on-page compression ratio
exceeds 2x: in the URL workload 1540 of 1564 splits carried a forced
full-page image. Every index-only scan on every btree reserves 144 KB
instead of 16 KB.
* prefix-compression: it is automatic with no opt-out, it turns off
deduplication for the whole index and bottom-up deletion for every
compressed page, so a two-int4-column index with duplicates grows 2.4x
against master. Deleting an anchor forces a full-page image at VACUUM or
simple-deletion time (3.0 MB of images against 0.25 MB of master records
for a scattered delete of 1/7 of the rows). The split-time promotion can,
in principle, fail to fit; the branch says so.
## 2. What is compressed
greg-prefix-compression codes the bytes of the first key column against one
prefix per leaf page. Requirements: the reloption is on and the first key
column is a varlena (`attlen == -1`). The prefix is the first data item's
leading bytes, cut to the longest run any later item shares, capped at
`BTREE_PREFIX_MAX` (126) bytes, and taken only when items share at least 4
bytes on average. Later key columns, INCLUDE columns and posting lists are
moved, not coded. A key that shares nothing still pays the one-byte share
count. Deduplicated (posting) tuples are coded too. Nothing happens for an
int4 first column, or for shared bytes in a second column.
prefix-compression omits the leading `nkeyatts - 1` key columns of a tuple
when they are byte-equal to those of the nearest plain tuple to its left on
the page (the anchor). Requirements: at least two key columns, every leading
column's opclass equalimage, decided once at build time. The last key column
is always stored, so `(url, id)` with all-distinct URLs saves nothing (every
tuple is its own anchor), and `(customer_id, url)` saves only the int4. A
single-column index is never eligible.
Neither design handles shared bytes of a non-first column, or a shared prefix
of distinct values in a leading column. On `(k, id)` with repeated `k` both
apply, but not equally. A follower is 16 bytes against a 64-byte anchor. The
coded tuple is 24 bytes, because the catalog-free coding keeps the trailing
`id` at its original alignment phase and pads to get there; on `(url)` alone a
coded tuple is 16 bytes against 56 plain. That padding, not per-group
adaptivity, is most of the 4863 against 3763 page gap for the insert-grown
`(k, id)` index.
The equalimage gate on prefix-compression is required, not conservative: a
plain tuple inserted inside a group becomes the anchor of the followers after
it. Sort order makes its leading columns opclass-equal to theirs; equalimage
makes them byte-equal, which is what inheritance needs. greg-prefix-
compression has no opclass dependency because each coded tuple records its
own share count against an immutable page prefix and decodes byte for byte.
## 3. On-disk representation
| Aspect | greg-prefix-compression | prefix-compression |
|--------------------------|-----------------------------------------------------------|-------------------------------------------------------------|
| Page flag | `BTP_PREFIX` (1 << 9), per page | `BTP_HAS_PREFIX_COMPRESSION` (1 << 9), every leaf of the index |
| Metapage | unchanged | new `btm_prefix_compression`, carried in `xl_btree_metadata` |
| Extra items | one PREFIX item at `P_HIKEY`/`P_FIRSTKEY`; `P_FIRSTDATAKEY` shifts by one | none |
| Tuple marking | none; first column's payload starts with a share count | `INDEX_ALT_TID_MASK` + `BT_IS_COMPRESSED` (0x4000) in `t_tid` |
| `t_tid` of a data tuple | intact | heap offset packed in the low 12 bits; `BTreeTupleGetHeapTID` returns NULL |
| Per-tuple overhead | 1 byte count (+ varlena header recomputed) | 0 bytes |
| Random access to a tuple | O(tuple) decode from the page prefix | O(group length) walk left to the anchor, then deform/form |
| Pivots, high keys | plain | plain |
| NULL in the coded column | stored plain | NULL leading columns can be shared |
| Posting lists | allowed, coded | forbidden on the whole index |
| Status bits consumed | none | one of the two remaining `t_tid` status bits |
| Version bump | none | none |
The PREFIX item is a non-tuple in the line pointer array. Everything that
walks items must know about it: `P_FIRSTDATAKEY`, the "emptied leaf holds
only its high key" test in page deletion (handled by dropping the item), and
all external tools. The follower representation keeps every item a real
tuple, in the style of posting lists, but at the cost of a status bit and of
a `t_tid` that no longer is a heap TID.
Neither design bumps `BTREE_VERSION`. An older binary cannot detect either
format: it would read the PREFIX item as a data tuple, or classify a follower
as a pivot. Both branches only ever write the new format from the new
binary, so this matters for downgrade only. The greg reloption changes only later
whole-page writes, so `ALTER INDEX ... SET` takes `ShareUpdateExclusiveLock`.
## 4. Readers
greg-prefix-compression routes every read of a leaf data item through
`_bt_getitem()`, which decodes into a caller-supplied `BTItemBuf` (a
`PGAlignedBlock`, 8 KB). On a page without the flag the cost is one
predictable branch. On a coded page every item read is a copy: header,
prefix bytes, suffix, rest of the tuple. `_bt_compare` carries an 8 KB stack
buffer and decodes on every probe of a coded page. Heap TIDs, posting lists
and `BTreeTupleIsPosting` work on the raw item, so `_bt_killitems`, the
VACUUM callback, simple deletion, bottom-up deletion and unique checking
read TIDs without decoding; only key comparisons decode. The array-key
look-ahead decodes the look-ahead tuple.
prefix-compression reconstructs a follower by deforming the anchor (cached per
page in `BTPrefixPageState`), deforming the follower with a trailing-columns
`TupleDesc`, and calling `index_form_tuple`. Costs as written:
* `_bt_compare` on a follower: `palloc` of a scratch tuple, a fresh
`BTPrefixPageState`, a leftward walk to the anchor, a `TupleDesc` built with
`CreateTemplateTupleDesc` and freed again, two `index_deform_tuple` calls and
one `index_form_tuple` (another `palloc`). This runs for every binary-search
probe that lands on a follower, so a descent into a one-group page does this
about `log2(n)` times and walks about `n/2` items per probe.
* `_bt_readpage`: one `TupleDesc` and one scratch buffer per page, one
deform/form per follower.
* `_bt_insertonpg`: builds and frees a `TupleDesc` per compressed insert.
Every consumer of a follower's `t_tid` had to be patched: `_bt_check_unique`,
`_bt_killitems`, `btvacuumpage`, `_bt_simpledel_pass`,
`_bt_bottomupdel_finish_pending`, `_bt_delitems_delete_check`,
`_bt_afternewitemoff`, amcheck and pageinspect. A missed consumer reads a
heap offset with bit 0x4000 set. In the other design a missed decode site
reads a wrong key, which comparisons catch, never a wrong TID.
Index-only scans: greg-prefix-compression sizes `currTuples`/`markTuples` with
`BTTupleStorageSize`, 73,728 bytes each, for every btree index regardless of
the reloption, because a `BTP_PREFIX` page can outlive `ALTER INDEX ... SET
(prefix_compression = off)`. That is 144 KB per index-only scan against 16 KB
on master. prefix-compression starts at 8 KB per buffer and grows with
`repalloc` on demand.
The array-key look-ahead optimization is kept (with decode) on one branch and
disabled on compressed pages on the other.
## 5. Writers: build, insert, split
CREATE INDEX. greg-prefix-compression fills each leaf with plain tuples, then
`_bt_prefix_compress_page()` rewrites the finished page in place. The
fillfactor test is relaxed by the predicted saving, but the hard fit test is
not, so a built leaf never holds more than one plain page of tuples. This is
why built indexes come out at 34-42% leaf density and only 9% fewer pages.
The savings appear only after retail inserts fill the free space, or after a
split re-lays the halves. prefix-compression emits followers inside
`_bt_buildadd`, so a built leaf is 90% full of compressed tuples (2796 pages
against 9462 for `(k, id)`).
Retail insert. greg-prefix-compression encodes the new tuple against the
page's fixed prefix (one `palloc`), sizes the fit test by the coded size, and
logs the coded bytes. A posting-list split on a coded page replaces the
posting tuple in place and forces a full-page image. prefix-compression
finds the anchor by walking left from the insert position, compresses the
tuple if its leading columns match, and logs the follower bytes verbatim. A
tuple that does not match is inserted plain and starts a new group; the old
anchor to its right stays plain.
Page split. greg-prefix-compression rebuilds both leaf halves as whole
pages: `_bt_split_leaf_collect()` decodes the original page into plain item
arrays, `_bt_split_leaf_half()` chooses a prefix for each half and lays it out
through `_bt_split_leaf_layout()`, trying the half's own prefix, then none,
then the original page's prefix, and raising ERROR if none fits. The split
point chooser works in on-page (coded) bytes. prefix-compression keeps
`_bt_split()` nearly unchanged: if the split lands inside a group it
reconstructs `firstright` into plain form so the right page starts with an
anchor, and reconstructs `lastleft` for suffix truncation. The split point
chooser reconstructs candidate boundary tuples for `_bt_keep_natts_fast()`,
and the uniform-tuple-size gate in `_bt_afternewitemoff()` is disabled with
`#if 0`.
Both branches have an unproven "the split layout fits" argument, and both
fail with ERROR before the critical section (the new right page is leaked,
nothing is corrupted):
* prefix-compression says so: `nbtsplitloc.c` does not reserve the growth of
promoting `firstright`, which is the size of the omitted leading columns.
* greg-prefix-compression claims the original prefix "fits by construction",
but (reading) `_bt_recsplitloc()` charges the left page's new high key at
`firstrightsz`, the coded on-page size, while the high key is formed from
the decoded tuple. Suffix truncation never cuts inside a column, so for a
single-column index the plain high key can exceed that estimate by up to
the shared byte count, at most `BTREE_PREFIX_MAX` plus alignment. The
default split strategy leaves far more slack than that; the fillfactor
strategies leave less. Several thousand splits in the measurements raised
no error.
## 6. VACUUM and deletion
greg-prefix-compression needs nothing on delete: items are independent. The
only rewrite is dropping the PREFIX item when a leaf empties, so page
deletion still sees a page that holds only its high key.
prefix-compression must promote the first surviving follower when its anchor
is deleted. `_bt_prefix_vacuum_plan()` reconstructs the survivors outside the
critical section, `_bt_prefix_vacuum_apply()` overwrites them in place after
`PageIndexMultiDelete()` inside it, and PANICs if the overwrite fails. The
space argument (the deleted anchor is at least as large as the growth) reads
correctly. The page is then logged as a forced full-page image, in both
`_bt_delitems_vacuum()` and `_bt_delitems_delete()`, so simple deletion during
inserts pays it too.
## 7. Deduplication and bottom-up deletion
greg-prefix-compression composes with both. A build deduplicates and then
codes. A runtime `_bt_dedup_pass()` on a coded page decodes every item, lays
the deduplicated result out plain, and re-codes it; because a coded page can
hold more logical data than fits plain, the pass may overflow, in which case
it aborts cleanly and the page splits instead. Bottom-up deletion decodes
for key comparisons and removes items without rewriting survivors.
prefix-compression makes the two mutually exclusive per index
(`btm_prefix_compression` implies `!btm_allequalimage`), asserts no
compressed page ever reaches `_bt_dedup_pass()`, and skips bottom-up deletion
on compressed pages because `_bt_keep_natts_fast()` cannot take followers.
Because the opt-in is automatic, every multi-column index whose leading
columns are equalimage loses both mechanisms with no way back. The
`select_distinct` expected output changed for that reason (a parallel worker
count flipped because `distinct_tbl`'s index is no longer deduplicated). For
`(a, b)` int4 with ten rows per key the index is 2749 pages against 1157 on
master, and two-int4-column followers save nothing at all after MAXALIGN
(8 + 4 bytes rounds to 16, the same as 8 + 8).
## 8. WAL and redo
greg-prefix-compression:
* Insert: the coded tuple, replayed verbatim by the existing insert redo.
* Split: `XLH_SPLIT_LEAFPREFIX`. The record carries each half's chosen
prefix, the left high key and new item, and the right half's plain items.
Redo re-derives the left half from the pre-split page with the same
collect/layout routines the primary used, so the coded pages come out byte
for byte without an image. When the right half's plain payload exceeds
`BLCKSZ`, `XLH_SPLIT_RFPI` carries the coded right page as a forced image.
That threshold is crossed whenever the coded page holds more than two plain
pages of items, i.e. whenever the compression ratio exceeds 2x. In the
URL workload 1540 of 1564 splits took the image path.
* Deduplication of a coded page: the chosen prefix plus the deduplicated
plain items, a page's worth, with the same `BLCKSZ` image fallback. Master
logs only the intervals.
* VACUUM: unchanged, plus dropping the PREFIX item on an emptied leaf.
* Adds about 300 lines to `nbtxlog.c`, including a second split-redo path
that duplicates the sibling-link fix-up.
prefix-compression:
* Insert and split: follower bytes travel verbatim; `xl_btree_split` gains
`origflags` so replay carries the page flag to both halves; `_bt_restore_page`
needs no change.
* New root: replay reads the metadata block to set the flag.
* VACUUM and simple deletion with a promotion: forced full-page image.
* `xl_btree_metadata` gains the opt-in flag.
Neither branch updates `nbtdesc.c`, so `pg_waldump` shows none of the new
fields.
## 9. Measurements
Setup: both branch tips built with `-Dcassert=true -Dinjection_points=true`,
one cluster each (`shared_buffers=512MB`, `fsync=off`,
`max_parallel_maintenance_workers=0`). The master column is the greg branch
with the reloption off. Every index passed `bt_index_parent_check(...,
true, true)` afterwards; index-scan, index-only-scan, backward, array-key and
merge-join (mark/restore) probes matched sequential-scan digests on both
branches. Scripts and raw output:
`/mnt/nvme/postgresql/patch/claude_work/pcmp-rig/`.
Index size in pages, 1M rows, 45-byte URL keys where text is involved:
| Index | master | greg on | pc |
|-------------------------------------------------|-------:|--------------:|---------------:|
| `(url)` distinct, CREATE INDEX | 8286 | 7544 (34.4%) | 8286 |
| `(url, id)` distinct, CREATE INDEX | 9449 | 8567 (42.1%) | 9449 (90.0%) |
| `(k)` 1000 keys x 1000, CREATE INDEX | 902 | 902 (86.8%) | 902 |
| `(k, id)` 1000 keys x 1000, CREATE INDEX | 9462 | 8580 (42.1%) | 2796 (90.5%) |
| `(a, b, c)` int4, CREATE INDEX | 3853 | 3853 | 2865 (90.0%) |
| `(a, b)` int4, 10 per key, CREATE INDEX | 1157 | 1157 | 2749 (90.1%) |
| `(url)` distinct, random-order inserts | 10769 (69.8%) | 3889 (65.3%) | 10769 |
| `(url)` distinct, key-order inserts | 8354 (89.8%) | 2764 (91.2%) | 8354 |
| `(k, id)` 1000 x 1000, random-order inserts | 12398 (69.1%) | 4863 (73.0%) | 3763 (69.2%) |
| `(a, b, c)` int4, random-order inserts | 4786 (72.3%) | 4786 | 4117 (67.2%) |
Percentages are `pgstatindex` leaf density. A `pc` entry equal to master
means the index was ineligible or gained nothing.
btree WAL for 300k random-order inserts into `(k, id)` (1000 keys), with
`full_page_writes=off` so only each design's own records count, from
`pg_waldump --stats` restricted to the btree rmgr:
| Record | master | greg on | pc |
|---------------|--------------------:|------------------------------------------:|-------------------:|
| INSERT_LEAF | 33.2 MB / 296,292 | 21.5 MB / 298,456 | 19.4 MB / 298,886 |
| SPLIT_L + R | 15.3 MB / 3,751 | 6.8 MB / 1,564, of which 6.4 MB images | 4.1 MB / 1,125 |
| Total | 48.9 MB | 28.5 MB | 23.6 MB |
btree WAL for DELETE of every seventh row (anchors and followers alike) plus
VACUUM, on those indexes:
| master | greg on | pc |
|-----------------------:|----------------------:|------------------------------------------:|
| 246 KB / 3,373 records | 148 KB / 1,419 records | 3.07 MB / 1,082 records, 2.98 MB images |
A delete that removed whole key runs (every tenth key) never deleted an
anchor with survivors, and cost 138 KB / 92 KB / 75 KB respectively.
btree WAL for 300k random-order inserts of 100 distinct keys into a
single-column index (deduplication active; prefix-compression is ineligible):
| Record | master | greg on |
|-------------|--------------------:|--------------------:|
| INSERT_LEAF | 31.2 MB | 19.2 MB |
| DEDUP | 0.6 MB / 10,355 | 14.8 MB / 2,276 |
| Total | 32.3 MB | 34.7 MB |
Both end at 305 pages. On a duplicate-heavy key the option adds WAL and CPU
and saves nothing, because deduplication already removed the key redundancy.
## 10. Gaps and risks found while reading
greg-prefix-compression:
* Built indexes are sparse (section 5). The fix is to code during the build,
as the other branch does, or to re-run the page finish against the coded
layout.
* The no-image split redo is the uncommon path for the data the feature
targets (section 8). The bound is still `BLCKSZ`, so volume is fine, but
the elaborate redo path mostly does not run, and the commit message's claim
that realistic data never reaches the fallback is wrong.
* 144 KB per index-only scan on every btree (section 4). A smaller bound, or
growth on demand, would remove the cost from indexes that never use the
option.
* The left high key is under-charged in the split point chooser (section 5).
* A posting-list split on a coded page forces an image on every such insert.
Posting-list splits are not rare on tables whose heap TIDs are reused after
VACUUM.
* `pageinspect` is not taught the format: `bt_page_items` shows the PREFIX
item as a tuple with ctid `(0,0)` and the coded bytes as the key.
* The file comment says the design is modeled on
`src/backend/access/bark/barkutils.c`, which is not in the tree.
* No change to `nbtdesc.c`, so the new split and dedup fields are invisible
in `pg_waldump`.
prefix-compression:
* Automatic opt-in with no reloption and no opt-out, and deduplication plus
bottom-up deletion gone for every eligible index (section 7). This is a
regression against master for any multi-column index with duplicates or
with non-HOT update churn, and it is undocumented.
* The split-time promotion fit is open; the commit message says so.
* `t_tid` bit-stealing: nine consumers patched by hand; `BTreeTupleGetHeapTID`
returns NULL for a follower and `BTreeTupleGetMaxHeapTID` returns the masked
pointer, so any new caller must know.
* Per-probe `TupleDesc` construction and `palloc` in `_bt_compare`, and an
O(group) anchor walk per probe (section 4). A relcache-cached trailing
`TupleDesc` and a direct byte-splice reconstruction would remove most of
it.
* Full-page image per promotion (section 6), on VACUUM and on simple
deletion.
* The uniform-tuple-size gate in `_bt_afternewitemoff()` is disabled for all
indexes with `#if 0`, not only for compressed pages.
* Tests: one regression file, no amcheck or pageinspect test, no WAL
consistency run mentioned. No user documentation.
* No change to `nbtdesc.c`.
## 11. What a combined design could take from each
* From prefix-compression: coding during CREATE INDEX, so built pages are
dense; zero-overhead followers; per-group adaptivity within a page.
* From greg-prefix-compression: a self-contained coded tuple (no dependence
on a neighbor item, no rewrite on delete, `t_tid` intact, catalog-free
decode usable by redo and amcheck); a reloption that is off by default and
takes a weak lock; coexistence with posting lists.
* Both branches should settle the split-fit argument in `nbtsplitloc.c`
rather than in the layout fallback, and both need `nbtdesc.c` and
`pageinspect` support.
* A byte-prefix scheme that covers the concatenated leading key columns
(type-aware, so that an int4 first column and a shared varlena second
column both benefit) would cover the union of the two target workloads
with the per-page prefix mechanics of the greg branch.
0003-pageinspect-decode-compressed-follower-TIDs-in-bt_pa.patch (application/octet-stream, 2.6K) download
From 4cebb6bdf44f9c32f46640a29f2ef21094c4ebe0 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Tue, 21 Apr 2026 12:48:16 -0400
Subject: [PATCH 3/3] pageinspect: decode compressed follower TIDs in
bt_page_items.
A prefix-compressed follower's t_tid carries BT_IS_COMPRESSED in the
upper nibble of its offset field. Without decoding, the ctid column
showed values like (blk, 16399), and the htid column was NULL, since
BTreeTupleGetHeapTID() deliberately returns NULL for followers.
Decode via BTreeCompressedTupleGetHeapTID() so that both columns show
the follower's real heap TID, matching what is shown for plain leaf
tuples.
---
contrib/pageinspect/btreefuncs.c | 27 ++++++++++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/contrib/pageinspect/btreefuncs.c b/contrib/pageinspect/btreefuncs.c
index 991766359..17adddabb 100644
--- a/contrib/pageinspect/btreefuncs.c
+++ b/contrib/pageinspect/btreefuncs.c
@@ -497,6 +497,7 @@ bt_page_print_tuples(ua_page_items *uargs)
*datacstring;
char *ptr;
ItemPointer htid;
+ ItemPointerData decoded_htid;
id = PageGetItemId(page, offset);
@@ -508,7 +509,19 @@ bt_page_print_tuples(ua_page_items *uargs)
j = 0;
memset(nulls, 0, sizeof(nulls));
values[j++] = Int16GetDatum(offset);
- values[j++] = ItemPointerGetDatum(&itup->t_tid);
+ /*
+ * For a prefix-compressed follower, t_tid carries BT_IS_COMPRESSED in
+ * its offset's upper nibble and the real heap-tuple offset in the low
+ * 12 bits. Decode so the ctid column shows a clean heap TID rather
+ * than the raw status-bit-bearing bytes.
+ */
+ if (BTreeTupleIsCompressed(itup))
+ {
+ BTreeCompressedTupleGetHeapTID(itup, &decoded_htid);
+ values[j++] = ItemPointerGetDatum(&decoded_htid);
+ }
+ else
+ values[j++] = ItemPointerGetDatum(&itup->t_tid);
values[j++] = Int16GetDatum(IndexTupleSize(itup));
values[j++] = BoolGetDatum(IndexTupleHasNulls(itup));
values[j++] = BoolGetDatum(IndexTupleHasVarwidths(itup));
@@ -582,6 +595,18 @@ bt_page_print_tuples(ua_page_items *uargs)
/* Don't show bogus heap TID in !heapkeyspace pivot tuple */
htid = NULL;
}
+ else if (BTreeTupleIsCompressed(itup))
+ {
+ /*
+ * BTreeTupleGetHeapTID() returns NULL for compressed followers
+ * because t_tid carries encoded status bits and cannot be returned
+ * verbatim. Produce a decoded heap TID so the htid column is
+ * populated (matches the ctid column, unlike pivots/postings
+ * where ctid is synthetic).
+ */
+ BTreeCompressedTupleGetHeapTID(itup, &decoded_htid);
+ htid = &decoded_htid;
+ }
if (htid)
values[j++] = ItemPointerGetDatum(htid);
--
2.55.0
0002-amcheck-verify-prefix-compressed-nbtree-leaf-pages.patch (application/octet-stream, 7.0K) download
From 74ea54df1cf522933349b67aaf10613966b40335 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Mon, 20 Apr 2026 22:05:16 -0400
Subject: [PATCH 2/3] amcheck: verify prefix-compressed nbtree leaf pages.
bt_target_page_check now routes every item on a prefix-compressed leaf
page through _bt_prefix_reconstruct before any of the existing
per-tuple checks run, so the ordering, high key, and heapallindexed
fingerprint checks all operate on the same plain tuples that an index
scan would see. Plain tuples refresh the current anchor as they pass
through, which keeps followers from the next group from being
reconstructed against the previous group's anchor.
BTreeTupleGetHeapTIDCareful decodes a follower's real heap TID into
scratch space, so bt_index_parent_check's cross-page invariant checks
get a usable TID.
Report corruption when the first data item on a prefix-compressed leaf
page is not a plain anchor, when a compressed tuple appears on a leaf
page without BTP_HAS_PREFIX_COMPRESSION, and when the metapage has
both deduplication and prefix compression enabled.
---
contrib/amcheck/verify_nbtree.c | 92 +++++++++++++++++++++++++++++++++
1 file changed, 92 insertions(+)
diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c
index 96a4d1f02..264bc62ef 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -115,6 +115,14 @@ typedef struct BtreeCheckState
*/
IndexTuple lowkey;
+ /*
+ * Scratch ItemPointerData for decoding a prefix-compressed follower's
+ * heap TID on demand (BTreeTupleGetHeapTID returns NULL for such tuples;
+ * callers that want a usable pointer use BTreeCompressedTupleGetHeapTID
+ * into this buffer). Only the last caller's decode is valid.
+ */
+ ItemPointerData compressed_htid_scratch;
+
/*
* The rightlink and incomplete split flag of block one level down to the
* target page, which was visited last time via downlink from target page.
@@ -328,6 +336,11 @@ bt_index_check_callback(Relation indrel, Relation heaprel, void *state, bool rea
(errcode(ERRCODE_INDEX_CORRUPTED),
errmsg("index \"%s\" metapage has equalimage field set on unsupported nbtree version",
RelationGetRelationName(indrel))));
+ if (allequalimage && prefix_compression)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("index \"%s\" metapage has both deduplication and prefix compression enabled",
+ RelationGetRelationName(indrel))));
if (allequalimage && !_bt_allequalimage(indrel, false))
ereport(ERROR,
(errcode(ERRCODE_INDEX_CORRUPTED),
@@ -1229,6 +1242,9 @@ bt_target_page_check(BtreeCheckState *state)
OffsetNumber offset;
OffsetNumber max;
BTPageOpaque topaque;
+ bool prefix_page;
+ BTPrefixPageState prefix_state;
+ char *rbuf = NULL;
/* Last visible entry info for checking indexes with unique constraint */
BtreeLastVisibleEntry lVis = {InvalidBlockNumber, InvalidOffsetNumber, -1, NULL};
@@ -1236,6 +1252,19 @@ bt_target_page_check(BtreeCheckState *state)
topaque = BTPageGetOpaque(state->target);
max = PageGetMaxOffsetNumber(state->target);
+ /*
+ * For prefix-compressed leaf pages, maintain a reconstruction state so
+ * compressed followers are materialized before any downstream checks
+ * look at their attrs, heap TID, or byte image.
+ */
+ prefix_page = P_ISLEAF(topaque) && P_HAS_PREFIX_COMPRESSION(topaque);
+ if (prefix_page)
+ {
+ memset(&prefix_state, 0, sizeof(prefix_state));
+ _bt_prefix_state_init(&prefix_state, state->target);
+ rbuf = palloc(BTMaxItemSize);
+ }
+
elog(DEBUG2, "verifying %u items on %s block %u", max,
P_ISLEAF(topaque) ? "leaf" : "internal", state->targetblock);
@@ -1297,6 +1326,51 @@ bt_target_page_check(BtreeCheckState *state)
itup = (IndexTuple) PageGetItem(state->target, itemid);
tupsize = IndexTupleSize(itup);
+ /*
+ * Prefix-compressed leaf invariants + reconstruction.
+ *
+ * The leftmost data item on a BTP_HAS_PREFIX_COMPRESSION leaf must
+ * be a plain anchor. A compressed tuple anywhere else is fine, but
+ * a compressed tuple at P_FIRSTDATAKEY would leave every follower
+ * below it without a governing anchor. Raise a corruption error.
+ *
+ * After the invariant check, materialize any compressed tuple into
+ * scratch storage so all downstream amcheck probes (natts check,
+ * scankey building, root-descend search, heap-TID fetch, posting
+ * checks) operate on a plain IndexTuple that index_getattr and
+ * friends can deform correctly.
+ */
+ if (prefix_page && offset == P_FIRSTDATAKEY(topaque) &&
+ BTreeTupleIsCompressed(itup))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("leftmost data item on prefix-compressed leaf page is not a plain anchor in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u offset=%u page lsn=%X/%08X.",
+ state->targetblock, offset,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ if (!prefix_page && BTreeTupleIsCompressed(itup))
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg("prefix-compressed tuple found on leaf page without BTP_HAS_PREFIX_COMPRESSION in index \"%s\"",
+ RelationGetRelationName(state->rel)),
+ errdetail_internal("Index block=%u offset=%u page lsn=%X/%08X.",
+ state->targetblock, offset,
+ LSN_FORMAT_ARGS(state->targetlsn))));
+
+ /*
+ * Always route leaf items through _bt_prefix_reconstruct on prefix-
+ * compressed pages. For plain tuples (anchors) it returns the raw
+ * pointer and refreshes the state's current anchor, so subsequent
+ * compressed followers reconstruct from the right group -- previously
+ * we only updated state on compressed tuples and would feed stale
+ * anchor attrs into the bloom filter for the next group's followers.
+ */
+ if (prefix_page)
+ itup = _bt_prefix_reconstruct(state->rel, &prefix_state,
+ offset, rbuf);
+
/*
* lp_len should match the IndexTuple reported length exactly, since
* lp_len is completely redundant in indexes, and both sources of
@@ -1829,6 +1903,12 @@ bt_target_page_check(BtreeCheckState *state)
bt_child_highkey_check(state, InvalidOffsetNumber,
NULL, topaque->btpo_level);
}
+
+ if (prefix_page)
+ {
+ _bt_prefix_state_cleanup(&prefix_state);
+ pfree(rbuf);
+ }
}
/*
@@ -3542,6 +3622,18 @@ BTreeTupleGetHeapTIDCareful(BtreeCheckState *state, IndexTuple itup,
state->targetblock,
RelationGetRelationName(state->rel))));
+ /*
+ * Compressed followers carry their heap TID packed into t_tid's upper
+ * nibble (status) + low 12 bits (offset). Decode into the state's
+ * scratch buffer so the caller gets a usable ItemPointer that agrees
+ * with what regular scans would return.
+ */
+ if (BTreeTupleIsCompressed(itup))
+ {
+ BTreeCompressedTupleGetHeapTID(itup, &state->compressed_htid_scratch);
+ return &state->compressed_htid_scratch;
+ }
+
htid = BTreeTupleGetHeapTID(itup);
if (!ItemPointerIsValid(htid) && nonpivot)
ereport(ERROR,
--
2.55.0
0001-Add-prefix-compression-to-nbtree-leaf-pages.patch (application/octet-stream, 136.9K) download
From 65a29ccabd664246c9612c729aa83f3d636e0524 Mon Sep 17 00:00:00 2001
From: Peter Geoghegan <pg@bowt.ie>
Date: Mon, 20 Apr 2026 20:43:42 -0400
Subject: [PATCH 1/3] Add prefix compression to nbtree leaf pages.
Teach nbtree to store the leading key attributes of a multi-column
index only once per run of adjacent leaf tuples that share them. An
index opts in at build time when it has at least two key columns and
every leading key column (all but the last) uses an equalimage
opclass, which is what makes byte-for-byte reuse of the leading
attributes sound. The metapage records the opt-in in a new
btm_prefix_compression field, and every leaf page that the index
produces sets BTP_HAS_PREFIX_COMPRESSION.
A prefix-compressed leaf page consists of "drifting" prefix groups.
Each group starts with an anchor, a plain non-pivot tuple, followed by
zero or more compressed followers. A follower omits the leading
(nkeyatts - 1) key attributes, storing only its final key attribute
plus any INCLUDE attributes; it inherits the omitted attributes from
the nearest anchor to its left on the page. Followers are marked by
INDEX_ALT_TID_MASK plus a new BT_IS_COMPRESSED status bit in t_tid's
offset field, with the real heap tuple offset packed into the low 12
bits (MaxHeapTuplesPerPage always fits there). There is no
per-follower metadata: a follower is smaller than its plain form by
exactly the size of the omitted attributes. The first data item on a
prefix-compressed page is always an anchor, and pivot tuples are never
compressed.
Prefix compression and posting list deduplication are mutually
exclusive on any given index: btm_prefix_compression implies
!btm_allequalimage, so a prefix-compressed page never contains a
posting list tuple. xl_btree_metadata carries the new field so that
the opt-in survives crash recovery and replication.
Read paths materialize followers on demand. _bt_readpage,
_bt_compare, the split point chooser, and _bt_set_startikey
reconstruct a follower into a scratch buffer by merging the governing
anchor's leading attributes with the follower's own; a per-page
BTPrefixPageState caches the anchor, so consecutive followers from the
same group are cheap. Code above nbtree (scan key evaluation,
index-only scan tuple return, amcheck's heap verification) never sees
a compressed tuple. Sites that consume a leaf tuple's t_tid directly
(unique checking, _bt_killitems, simple and bottom-up deletion, and
the VACUUM page callback) decode the follower's real heap TID first.
Index-only scan tuple workspaces now grow on demand, since a page's
worth of reconstructed tuples can exceed BLCKSZ.
CREATE INDEX emits followers from _bt_buildadd whenever the next
sorted tuple shares the current anchor's leading attributes. Retail
inserts do the same on pages that already carry
BTP_HAS_PREFIX_COMPRESSION, and the leaf root that _bt_getroot creates
for an empty index inherits the flag from the metapage. A page split
that lands inside a group promotes the right page's first item to
plain form (and reconstructs lastleft) before suffix truncation, so
both halves keep the first-item-is-an-anchor invariant. Both halves
also keep BTP_HAS_PREFIX_COMPRESSION, which is why xl_btree_split now
carries origpage's flags. VACUUM and simple deletion promote the
first surviving follower of a deleted anchor to plain form; this
always fits, because the deleted anchor was at least as large as the
growth, and the affected page is logged as a full page image.
Suffix truncation still works on prefix-compressed pages: the split
point chooser reconstructs candidate lastleft/firstright tuples before
calling _bt_keep_natts_fast, which now asserts that it is only ever
passed plain tuples. Look-ahead during array key scans and bottom-up
deletion are both disabled on prefix-compressed pages. The uniform
tuple size gating condition in _bt_afternewitemoff is disabled for
now, since prefix compression means that TPC-C style indexes no longer
have uniform tuple sizes; the trade-offs there need to be revisited.
Known limitation: nbtsplitloc.c does not account for the growth from
promoting a follower at a mid-group split point, so the "promotion
always fits" guarantee does not yet hold on the split path. The new
README section describes the design and the space accounting argument
in full.
Add a regression test covering build, scans in both directions,
inserts that extend and start groups, page splits, VACUUM promotion,
ineligible index shapes, NULLs, and INCLUDE columns. The
select_distinct expected output changes because distinct_tbl's index
is no longer deduplicated, which makes it a few pages larger and flips
the planned parallel worker count.
---
src/include/access/nbtree.h | 171 ++++-
src/include/access/nbtxlog.h | 18 +-
src/backend/access/nbtree/Makefile | 1 +
src/backend/access/nbtree/README | 150 +++++
src/backend/access/nbtree/meson.build | 1 +
src/backend/access/nbtree/nbtdedup.c | 26 +-
src/backend/access/nbtree/nbtinsert.c | 238 ++++++-
src/backend/access/nbtree/nbtpage.c | 124 +++-
src/backend/access/nbtree/nbtprefix.c | 605 ++++++++++++++++++
src/backend/access/nbtree/nbtreadpage.c | 114 +++-
src/backend/access/nbtree/nbtree.c | 59 +-
src/backend/access/nbtree/nbtsearch.c | 72 ++-
src/backend/access/nbtree/nbtsort.c | 176 ++++-
src/backend/access/nbtree/nbtsplitloc.c | 189 +++++-
src/backend/access/nbtree/nbtutils.c | 61 +-
src/backend/access/nbtree/nbtxlog.c | 41 +-
contrib/amcheck/verify_nbtree.c | 5 +-
.../expected/btree_prefix_compression.out | 250 ++++++++
src/test/regress/expected/select_distinct.out | 2 +-
src/test/regress/parallel_schedule | 1 +
.../regress/sql/btree_prefix_compression.sql | 128 ++++
21 files changed, 2346 insertions(+), 86 deletions(-)
create mode 100644 src/backend/access/nbtree/nbtprefix.c
create mode 100644 src/test/regress/expected/btree_prefix_compression.out
create mode 100644 src/test/regress/sql/btree_prefix_compression.sql
diff --git a/src/include/access/nbtree.h b/src/include/access/nbtree.h
index 3097e9bb1..451b55cc5 100644
--- a/src/include/access/nbtree.h
+++ b/src/include/access/nbtree.h
@@ -83,6 +83,7 @@ typedef BTPageOpaqueData *BTPageOpaque;
#define BTP_HAS_GARBAGE (1 << 6) /* page has LP_DEAD tuples (deprecated) */
#define BTP_INCOMPLETE_SPLIT (1 << 7) /* right sibling's downlink is missing */
#define BTP_HAS_FULLXID (1 << 8) /* contains BTDeletedPageData */
+#define BTP_HAS_PREFIX_COMPRESSION (1 << 9) /* leaf page uses prefix compression */
/*
* The max allowed value of a cycle ID is a bit less than 64K. This is
@@ -117,6 +118,7 @@ typedef struct BTMetaPageData
float8 btm_last_cleanup_num_heap_tuples;
bool btm_allequalimage; /* are all columns "equalimage"? */
+ bool btm_prefix_compression; /* leaf pages use prefix compression? */
} BTMetaPageData;
#define BTPageGetMeta(p) \
@@ -227,6 +229,8 @@ typedef struct BTMetaPageData
#define P_HAS_GARBAGE(opaque) (((opaque)->btpo_flags & BTP_HAS_GARBAGE) != 0)
#define P_INCOMPLETE_SPLIT(opaque) (((opaque)->btpo_flags & BTP_INCOMPLETE_SPLIT) != 0)
#define P_HAS_FULLXID(opaque) (((opaque)->btpo_flags & BTP_HAS_FULLXID) != 0)
+#define P_HAS_PREFIX_COMPRESSION(opaque) \
+ (((opaque)->btpo_flags & BTP_HAS_PREFIX_COMPRESSION) != 0)
/*
* BTDeletedPageData is the page contents of a deleted page
@@ -456,6 +460,28 @@ typedef struct BTVacState
* number of columns stored is always implicitly the total number in the
* index (in practice there can never be non-key columns stored, since
* deduplication is not supported with INCLUDE indexes).
+ *
+ * A second alternative non-pivot tuple representation is used for prefix
+ * compression on multi-column leaf pages that set BTP_HAS_PREFIX_COMPRESSION
+ * in btpo_flags. On such pages:
+ *
+ * - "Anchor" tuples are byte-identical to plain non-pivot tuples: no
+ * INDEX_ALT_TID_MASK, t_tid stores the real heap TID, and the tuple body
+ * carries all key attributes plus any INCLUDE attributes.
+ *
+ * - "Compressed" followers omit the leading (nkeyatts - 1) key attributes,
+ * which are inherited from the most recent preceding anchor on the page.
+ * They set INDEX_ALT_TID_MASK in t_info and BT_IS_COMPRESSED in t_tid's
+ * offset status nibble. The real heap-tuple offset fits in the low 12
+ * bits of t_tid's offset (BT_OFFSET_MASK), since MaxHeapTuplesPerPage is
+ * always well under 4096 for every supported BLCKSZ. t_tid's block
+ * number is the unchanged heap block. The tuple body carries only the
+ * trailing suffix key attribute plus any INCLUDE attributes -- no
+ * trailing ItemPointerData, zero bytes of per-follower metadata.
+ *
+ * Compressed followers require dedup to be disabled on the whole index
+ * (btm_prefix_compression implies !btm_allequalimage), so BT_IS_POSTING and
+ * BT_IS_COMPRESSED are mutually exclusive on any given leaf page.
*/
#define INDEX_ALT_TID_MASK INDEX_AM_RESERVED_BIT
@@ -465,6 +491,7 @@ typedef struct BTVacState
/* BT_STATUS_OFFSET_MASK status bits */
#define BT_PIVOT_HEAP_TID_ATTR 0x1000
#define BT_IS_POSTING 0x2000
+#define BT_IS_COMPRESSED 0x4000
/*
* Mask allocated for number of keys in index tuple must be able to fit
@@ -482,8 +509,9 @@ BTreeTupleIsPivot(IndexTuple itup)
{
if ((itup->t_info & INDEX_ALT_TID_MASK) == 0)
return false;
- /* absence of BT_IS_POSTING in offset number indicates pivot tuple */
- if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_POSTING) != 0)
+ /* absence of BT_IS_POSTING and BT_IS_COMPRESSED indicates pivot tuple */
+ if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) &
+ (BT_IS_POSTING | BT_IS_COMPRESSED)) != 0)
return false;
return true;
@@ -501,6 +529,18 @@ BTreeTupleIsPosting(IndexTuple itup)
return true;
}
+static inline bool
+BTreeTupleIsCompressed(IndexTuple itup)
+{
+ if ((itup->t_info & INDEX_ALT_TID_MASK) == 0)
+ return false;
+ /* presence of BT_IS_COMPRESSED in offset indicates prefix-compressed follower */
+ if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_COMPRESSED) == 0)
+ return false;
+
+ return true;
+}
+
static inline void
BTreeTupleSetPosting(IndexTuple itup, uint16 nhtids, int postingoffset)
{
@@ -509,6 +549,7 @@ BTreeTupleSetPosting(IndexTuple itup, uint16 nhtids, int postingoffset)
Assert((size_t) postingoffset == MAXALIGN(postingoffset));
Assert(postingoffset < INDEX_SIZE_MASK);
Assert(!BTreeTupleIsPivot(itup));
+ Assert(!BTreeTupleIsCompressed(itup));
itup->t_info |= INDEX_ALT_TID_MASK;
ItemPointerSetOffsetNumber(&itup->t_tid, (nhtids | BT_IS_POSTING));
@@ -651,10 +692,56 @@ BTreeTupleGetHeapTID(IndexTuple itup)
}
else if (BTreeTupleIsPosting(itup))
return BTreeTupleGetPosting(itup);
+ else if (BTreeTupleIsCompressed(itup))
+ {
+ /*
+ * Compressed followers store their real heap TID offset in the low
+ * 12 bits of t_tid's offset field (BT_OFFSET_MASK), with the
+ * BT_IS_COMPRESSED status bit occupying one of the upper 4 bits.
+ * Because the offset field isn't a clean ItemPointerData, callers
+ * that need a usable ItemPointer must use
+ * BTreeCompressedTupleGetHeapTID() with a caller-provided buffer.
+ */
+ return NULL;
+ }
return &itup->t_tid;
}
+/*
+ * Write the real heap TID of a prefix-compressed follower into the
+ * caller-provided output buffer. The follower's t_tid directly carries the
+ * heap block number, and the low 12 bits of its offset field carry the real
+ * heap-tuple offset; the upper nibble is masked off.
+ */
+static inline void
+BTreeCompressedTupleGetHeapTID(IndexTuple itup, ItemPointer out)
+{
+ OffsetNumber raw;
+
+ Assert(BTreeTupleIsCompressed(itup));
+
+ raw = ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_OFFSET_MASK;
+ out->ip_blkid = itup->t_tid.ip_blkid;
+ ItemPointerSetOffsetNumber(out, raw);
+}
+
+/*
+ * Set t_tid on a to-be-written compressed follower from a real heap TID,
+ * packing the BT_IS_COMPRESSED status bit into the offset field.
+ */
+static inline void
+BTreeCompressedTupleSetHeapTID(IndexTuple itup, ItemPointer heap_tid)
+{
+ OffsetNumber heap_off = ItemPointerGetOffsetNumberNoCheck(heap_tid);
+
+ Assert((heap_off & ~BT_OFFSET_MASK) == 0);
+ itup->t_info |= INDEX_ALT_TID_MASK;
+ itup->t_tid.ip_blkid = heap_tid->ip_blkid;
+ ItemPointerSetOffsetNumber(&itup->t_tid, heap_off | BT_IS_COMPRESSED);
+ Assert(BTreeTupleIsCompressed(itup));
+}
+
/*
* Get maximum heap TID attribute, which could be the only TID in the case of
* a non-pivot tuple that does not have a posting list.
@@ -796,6 +883,7 @@ typedef struct BTScanInsertData
{
bool heapkeyspace;
bool allequalimage;
+ bool prefix_compression; /* index uses prefix-compressed leaves? */
bool anynullkeys;
bool nextkey;
bool backward; /* backward index scan? */
@@ -924,6 +1012,38 @@ typedef struct BTVacuumPostingData
typedef BTVacuumPostingData *BTVacuumPosting;
+/*
+ * BTPrefixPageState -- per-page working state for prefix-compressed leaf
+ * pages. Callers seed the state by passing a Page pointer to
+ * _bt_prefix_state_init(), then advance it across items on that page. The
+ * state caches the most recently seen anchor tuple (and its deformed
+ * Datums/isnulls) so that reconstructing consecutive compressed followers in
+ * the same group is cheap: each follower only needs its own suffix deformed
+ * and merged with the cached anchor values.
+ *
+ * The state is inexpensive to initialize (no allocations until first use)
+ * and must be passed to _bt_prefix_state_cleanup() before the owning frame
+ * disappears, so that any cached trailing TupleDesc is freed.
+ */
+typedef struct BTPrefixPageState
+{
+ Page page; /* the leaf page being walked */
+ OffsetNumber anchor_off; /* offset of current anchor, or Invalid */
+ IndexTuple anchor; /* pointer into the page for current anchor */
+ int natts_anchor; /* number of leading key attrs anchor supplies */
+ Datum anchor_values[INDEX_MAX_KEYS];
+ bool anchor_isnull[INDEX_MAX_KEYS];
+ bool anchor_deformed; /* are anchor_values/isnull filled in? */
+ OffsetNumber next_anchor_off; /* offset of next anchor, or Invalid if unknown */
+
+ /*
+ * Trailing TupleDesc covering attrs (natts_anchor+1..natts) -- the on-disk
+ * layout of a compressed follower. Built lazily on first reconstruction
+ * and freed by _bt_prefix_state_cleanup().
+ */
+ struct TupleDescData *follower_tupdesc;
+} BTPrefixPageState;
+
/*
* BTScanOpaqueData is the btree-private state needed for an indexscan.
* This consists of preprocessed scan keys (see _bt_preprocess_keys() for
@@ -1074,11 +1194,15 @@ typedef struct BTScanOpaqueData
/*
* If we are doing an index-only scan, these are the tuple storage
- * workspaces for the currPos and markPos respectively. Each is of size
- * BLCKSZ, so it can hold as much as a full page's worth of tuples.
+ * workspaces for the currPos and markPos respectively. Sized to hold a
+ * full page's worth of tuples; on a prefix-compressed leaf the buffers
+ * may grow beyond the initial size to accommodate reconstructed tuples
+ * that are larger than their on-disk form.
*/
char *currTuples; /* tuple storage for currPos */
char *markTuples; /* tuple storage for markPos */
+ Size currTuplesSize; /* bytes allocated at currTuples */
+ Size markTuplesSize; /* bytes allocated at markTuples */
/*
* If the marked position is on the same page as current position, we
@@ -1192,6 +1316,41 @@ extern void _bt_parallel_done(IndexScanDesc scan);
extern void _bt_parallel_primscan_schedule(IndexScanDesc scan,
BlockNumber curr_page);
+/*
+ * prototypes for functions in nbtprefix.c
+ */
+extern void _bt_prefix_state_init(BTPrefixPageState *st, Page page);
+extern void _bt_prefix_state_cleanup(BTPrefixPageState *st);
+extern void _bt_prefix_state_set_anchor(BTPrefixPageState *st,
+ OffsetNumber anchor_off);
+extern OffsetNumber _bt_prefix_find_anchor(Page page, OffsetNumber offnum);
+extern IndexTuple _bt_prefix_reconstruct(Relation rel, BTPrefixPageState *st,
+ OffsetNumber offnum, char *dstbuf);
+extern IndexTuple _bt_prefix_form_tuple(Relation rel, BTPrefixPageState *st,
+ IndexTuple compressed, char *dstbuf);
+extern bool _bt_prefix_leading_equalimage(Relation rel);
+extern TupleDesc _bt_prefix_build_follower_tupdesc(Relation rel,
+ int natts_anchor);
+extern IndexTuple _bt_prefix_compress_new_tuple(TupleDesc full_tupdesc,
+ TupleDesc follower_tupdesc,
+ int natts_anchor,
+ IndexTuple new_plain,
+ char *dstbuf);
+typedef struct BTPrefixPromotion
+{
+ OffsetNumber off;
+ IndexTuple plain; /* palloc'd plain form */
+ Size plainsz; /* MAXALIGNed */
+} BTPrefixPromotion;
+
+extern int _bt_prefix_vacuum_plan(Relation rel, Page page,
+ const OffsetNumber *deletable,
+ int ndeletable,
+ BTPrefixPromotion *promotions);
+extern void _bt_prefix_vacuum_apply(Page page,
+ BTPrefixPromotion *promotions,
+ int nprom);
+
/*
* prototypes for functions in nbtdedup.c
*/
@@ -1231,7 +1390,7 @@ extern OffsetNumber _bt_findsplitloc(Relation rel, Page origpage,
* prototypes for functions in nbtpage.c
*/
extern void _bt_initmetapage(Page page, BlockNumber rootbknum, uint32 level,
- bool allequalimage);
+ bool allequalimage, bool prefix_compression);
extern bool _bt_vacuum_needs_cleanup(Relation rel);
extern void _bt_set_cleanup_info(Relation rel, BlockNumber num_delpages);
extern void _bt_upgrademetapage(Page page);
@@ -1239,7 +1398,7 @@ extern Buffer _bt_getroot(Relation rel, Relation heaprel, int access);
extern Buffer _bt_gettrueroot(Relation rel);
extern int _bt_getrootheight(Relation rel);
extern void _bt_metaversion(Relation rel, bool *heapkeyspace,
- bool *allequalimage);
+ bool *allequalimage, bool *prefix_compression);
extern void _bt_checkpage(Relation rel, Buffer buf);
extern Buffer _bt_getbuf(Relation rel, BlockNumber blkno, int access);
extern Buffer _bt_allocbuf(Relation rel, Relation heaprel);
diff --git a/src/include/access/nbtxlog.h b/src/include/access/nbtxlog.h
index 3a78ec27f..15cefc8e2 100644
--- a/src/include/access/nbtxlog.h
+++ b/src/include/access/nbtxlog.h
@@ -55,6 +55,7 @@ typedef struct xl_btree_metadata
uint32 fastlevel;
uint32 last_cleanup_num_delpages;
bool allequalimage;
+ bool prefix_compression;
} xl_btree_metadata;
/*
@@ -156,9 +157,24 @@ typedef struct xl_btree_split
OffsetNumber firstrightoff; /* first origpage item on rightpage */
OffsetNumber newitemoff; /* new item's offset */
uint16 postingoff; /* offset inside orig posting tuple */
+ /*
+ * origpage's btpo_flags at split time. Replay uses this to carry
+ * forward BTP_HAS_PREFIX_COMPRESSION (and any future per-page flags)
+ * to the reinitialized right page. Filtered by
+ * BTP_SPLIT_PRESERVED_FLAGS in replay so that flags that only belong
+ * on origpage (BTP_INCOMPLETE_SPLIT, BTP_ROOT, BTP_HAS_GARBAGE,
+ * BTP_SPLIT_END) never cross over.
+ */
+ uint16 origflags;
} xl_btree_split;
-#define SizeOfBtreeSplit (offsetof(xl_btree_split, postingoff) + sizeof(uint16))
+#define SizeOfBtreeSplit (offsetof(xl_btree_split, origflags) + sizeof(uint16))
+
+/*
+ * Flags that xl_btree_split replay preserves from origpage on the right
+ * page. Anything outside this mask is cleared.
+ */
+#define BTP_SPLIT_PRESERVED_FLAGS (BTP_LEAF | BTP_HAS_PREFIX_COMPRESSION)
/*
* When page is deduplicated, consecutive groups of tuples with equal keys are
diff --git a/src/backend/access/nbtree/Makefile b/src/backend/access/nbtree/Makefile
index 0daf640af..0818e2387 100644
--- a/src/backend/access/nbtree/Makefile
+++ b/src/backend/access/nbtree/Makefile
@@ -17,6 +17,7 @@ OBJS = \
nbtdedup.o \
nbtinsert.o \
nbtpage.o \
+ nbtprefix.o \
nbtpreprocesskeys.o \
nbtreadpage.o \
nbtree.o \
diff --git a/src/backend/access/nbtree/README b/src/backend/access/nbtree/README
index f38675476..0817a272e 100644
--- a/src/backend/access/nbtree/README
+++ b/src/backend/access/nbtree/README
@@ -1041,6 +1041,156 @@ that need a page split anyway. Besides, supporting variable "split points"
while splitting posting lists won't actually improve overall space
utilization.
+Notes about prefix compression
+------------------------------
+
+Prefix compression is an orthogonal leaf-page encoding that targets
+multi-column indexes whose leading key columns repeat across many adjacent
+logical tuples -- for example, an index on (customer_id, order_date) with
+a heavily skewed customer_id distribution. The encoding is modeled on
+Bayer/Unterauer's "Prefix B-trees" as described by Graefe. It is opt-in
+per index at build time, gated on the index having at least 2 key
+attributes and the leading (nkeyatts - 1) columns being equalimage (which
+makes byte-verbatim reuse of the anchor's leading attrs sound).
+
+An index opts in during bulk build by recording btm_prefix_compression on
+the metapage; BTP_HAS_PREFIX_COMPRESSION is set on every leaf page the
+index produces. Prefix compression and posting-list deduplication are
+strictly mutually exclusive on any given index: btm_prefix_compression
+implies !btm_allequalimage, and dedup only runs when allequalimage is set.
+A prefix-compressed leaf page therefore never contains posting tuples, and
+a dedup-eligible leaf page never contains compressed followers.
+
+On-disk structure. A prefix-compressed leaf page is organized as one or
+more "drifting" prefix groups. Each group consists of:
+
+ * An anchor tuple. Byte-identical to a plain non-pivot leaf tuple:
+ INDEX_ALT_TID_MASK is clear, t_tid carries the real heap TID, and the
+ body contains the full set of key + INCLUDE attrs. All regular leaf
+ tuple invariants (e.g. BTreeTupleGetHeapTID(itup) == &itup->t_tid)
+ hold unchanged. A page's first data item is always an anchor.
+
+ * Zero or more compressed followers that physically follow the anchor in
+ offset order. A follower sets INDEX_ALT_TID_MASK in t_info and
+ encodes BT_IS_COMPRESSED (0x4000) in the upper nibble of t_tid's
+ offset field. The low 12 bits of the offset field (BT_OFFSET_MASK)
+ hold the real heap-tuple offset; the block number in t_tid.ip_blkid
+ is stored in the clear. Because MaxHeapTuplesPerPage fits in 12 bits
+ for every supported BLCKSZ, the encoding has zero per-follower
+ overhead: a follower is shorter than its plain form by exactly the
+ space of the omitted leading attrs. The body stores only the
+ trailing key attrs (natts_anchor+1 .. nkeyatts) plus INCLUDE attrs.
+
+A group ends at the next anchor (the next plain non-pivot tuple) or at
+the end of the page's data area. A single page can host many groups,
+each with a different prefix -- hence "drifting". Internal pages are
+unaffected; only leaf pages carry BTP_HAS_PREFIX_COMPRESSION, and pivot
+tuples (internal-page downlinks and leaf high keys) are always plain.
+
+Scan-time reconstruction. _bt_readpage, _bt_compare, _bt_killitems, and
+the VACUUM page-walker materialize a follower into a plain IndexTuple on
+demand: they combine the governing anchor's leading attrs (discovered via
+a short leftward walk in _bt_prefix_find_anchor, cached in a
+BTPrefixPageState across items of the same group) with the follower's
+trailing attrs, and demask t_tid via BTreeCompressedTupleGetHeapTID.
+Callers above nbtree (including _bt_checkkeys, amcheck, and index-only
+scan tuple return) never see a compressed follower.
+
+Build path. _bt_buildadd emits the first tuple on each page as a plain
+anchor and compares subsequent sorted input tuples to the current anchor
+using _bt_keep_natts_fast. If the leading (nkeyatts - 1) attrs match, the
+new tuple is emitted as a compressed follower via
+_bt_prefix_compress_new_tuple. Otherwise the tuple is emitted plain and
+becomes the anchor of a new group. Parallel build produces the same
+plain sorted tuples as always; compression happens only inside
+_bt_buildadd on the leader.
+
+Insert path. _bt_insertonpg compresses a newly inserted tuple when the
+target leaf is BTP_HAS_PREFIX_COMPRESSION and the incoming tuple shares
+the governing anchor's leading attrs (resolved via _bt_prefix_find_anchor
+walking left from newitemoff). The incoming tuple's heap TID is passed
+through as scantid for search, so the insert is placed at the correct
+(key, TID) position before compression. A tuple that doesn't share the
+anchor's leading attrs is inserted plain and starts a new group.
+
+Split path. Leaf page splits may land at a group boundary or inside a
+group. If firstright (the first item of the right page) is a compressed
+follower, _bt_split reconstructs it into plain form and uses that for
+both the right page's leftmost item (satisfying "first data item is an
+anchor") and for _bt_truncate's firstright input. If lastleft is a
+compressed follower, _bt_split reconstructs it before passing to
+_bt_truncate. The high key that gets produced is plain; internal pages
+never carry compressed tuples or the BTP_HAS_PREFIX_COMPRESSION flag.
+
+Space management and the "splits never fail" invariant. Two places
+perform a follower-to-plain-anchor promotion: (a) VACUUM's anchor-
+promotion in _bt_delitems_vacuum/_bt_delitems_delete via
+_bt_prefix_vacuum_plan/_apply, when an anchor is being deleted with at
+least one surviving follower; and (b) _bt_split when firstright is a
+compressed follower. Both rely on the following invariant:
+
+ promoted_plain_size = header + prefix_keys + suffix_keys + include
+ follower_on_disk = header + suffix_keys + include
+
+so promotion costs exactly prefix_keys bytes of extra space per
+promoted follower. For VACUUM we get those bytes for free because the
+anchor (whose size is >= header + prefix_keys + suffix_keys + include,
+i.e. >= prefix_keys by far) is being deleted in the same operation.
+PageIndexMultiDelete runs first and frees the anchor's bytes; the
+subsequent PageIndexTupleOverwrite for the promoted follower always fits
+by construction, summed across all promotions on the same page.
+
+For _bt_split the story is worse: nbtsplitloc.c does not account for
+promotion at all. The split chooser is unaware of prefix compression
+entirely -- it scores candidate splits using on-disk sizes, ignoring
+that a mid-group split forces _bt_split to reconstruct firstright into
+a plain anchor that's prefix_keys bytes larger than the compressed
+follower. There is no code in nbtsplitloc.c that reserves space for
+this growth or preferentially picks group-boundary split points. The
+"promotion always fits" guarantee does NOT hold on the split path.
+Left as follow-up work.
+
+Mutual exclusion with posting-list dedup is the second leg of the "never
+fail" argument. Dedup produces and rewrites posting tuples, and posting
+splits expand an existing posting slot in place (same allocated size, TIDs
+shifted). If dedup could run on a prefix-compressed page, its posting
+tuples could turn into compressed followers (via a later bulk-style merge
+in a future hybrid design), and the space accounting would get tangled
+with promotion. The mutex keeps the two encodings strictly separated per
+index: _bt_dedup_pass asserts !P_HAS_PREFIX_COMPRESSION(page) at entry,
+and _bt_initmetapage refuses to set both flags.
+
+Orphan anchors. After VACUUM removes every follower of a group, the
+anchor remains in place as a singleton group; no cleanup is necessary
+because an anchor is byte-identical to a plain leaf tuple. The reverse
+case (anchor deleted with followers surviving) is handled by the
+promotion path above.
+
+On-the-fly scan-time interlocks. _bt_readpage's LP_DEAD-skip branch
+still has to update the current prefix state when it skips a dead plain
+tuple, because such a dead plain tuple is the governing anchor for any
+compressed follower that physically follows it on the page. Silently
+skipping would leave st->anchor_off pointing at an earlier anchor and
+cause every subsequent follower to reconstruct under the wrong leading
+attrs.
+
+WAL. Compressed-follower bytes travel verbatim through existing
+xl_btree_insert and xl_btree_split records; replay uses the raw bytes
+without reconstructing. The per-page BTP_HAS_PREFIX_COMPRESSION flag is
+included in BTP_SPLIT_PRESERVED_FLAGS so split replay carries it forward
+on both halves, and new-root replay (XLOG_BTREE_NEWROOT) sets the flag on
+a fresh leaf root when btm_prefix_compression is set on the metapage.
+xl_btree_metadata carries btm_prefix_compression alongside
+btm_allequalimage so the mutex invariant survives recovery.
+
+Downgrade to a version that doesn't understand BT_IS_COMPRESSED is not
+supported for indexes that opted in. An older binary reading a
+compressed follower would misclassify it as a pivot (ALT-TID set,
+BT_IS_POSTING clear), which is catastrophic on a leaf page. Affected
+indexes need a REINDEX to downgrade. Anchors, being byte-identical to
+plain leaf tuples, would read fine. This mirrors the upgrade story for
+v4's introduction of posting lists.
+
Notes About Data Representation
-------------------------------
diff --git a/src/backend/access/nbtree/meson.build b/src/backend/access/nbtree/meson.build
index 812f067e7..dfdd4fde8 100644
--- a/src/backend/access/nbtree/meson.build
+++ b/src/backend/access/nbtree/meson.build
@@ -5,6 +5,7 @@ backend_sources += files(
'nbtdedup.c',
'nbtinsert.c',
'nbtpage.c',
+ 'nbtprefix.c',
'nbtpreprocesskeys.c',
'nbtreadpage.c',
'nbtree.c',
diff --git a/src/backend/access/nbtree/nbtdedup.c b/src/backend/access/nbtree/nbtdedup.c
index af7affdf4..2b377e71f 100644
--- a/src/backend/access/nbtree/nbtdedup.c
+++ b/src/backend/access/nbtree/nbtdedup.c
@@ -71,6 +71,17 @@ _bt_dedup_pass(Relation rel, Buffer buf, IndexTuple newitem, Size newitemsz,
int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
XLogRecPtr recptr;
+ /*
+ * Dedup is mutually exclusive with prefix compression at the index level
+ * (btm_prefix_compression ⇒ !btm_allequalimage, enforced at index build).
+ * Callers only invoke dedup when itup_key->allequalimage is set, so a
+ * prefix-compressed leaf page must never reach here. Compressed
+ * followers carry BT_IS_COMPRESSED in their t_tid and would be
+ * misinterpreted by _bt_dedup_save_htid / _bt_form_posting as regular
+ * heap TIDs.
+ */
+ Assert(!P_HAS_PREFIX_COMPRESSION(opaque));
+
/* Passed-in newitemsz is MAXALIGNED but does not include line pointer */
newitemsz += sizeof(ItemIdData);
@@ -665,7 +676,20 @@ _bt_bottomupdel_finish_pending(Page page, BTDedupState state,
if (!BTreeTupleIsPosting(itup))
{
/* Simple case: A plain non-pivot tuple */
- ideltid->tid = itup->t_tid;
+ if (BTreeTupleIsCompressed(itup))
+ {
+ /*
+ * Compressed follower: t_tid packs BT_IS_COMPRESSED into its
+ * offset nibble. Feeding the raw bytes to the heap-side
+ * table_index_delete_tuples would point at a bogus heap
+ * offset (> MaxHeapTuplesPerPage) and trip an internal
+ * qsort-comparator assertion. Decode the real heap TID
+ * into the deltid slot instead.
+ */
+ BTreeCompressedTupleGetHeapTID(itup, &ideltid->tid);
+ }
+ else
+ ideltid->tid = itup->t_tid;
ideltid->id = delstate->ndeltids;
istatus->idxoffnum = offnum;
istatus->knowndeletable = false; /* for now */
diff --git a/src/backend/access/nbtree/nbtinsert.c b/src/backend/access/nbtree/nbtinsert.c
index d5d964301..c7aa0037d 100644
--- a/src/backend/access/nbtree/nbtinsert.c
+++ b/src/backend/access/nbtree/nbtinsert.c
@@ -526,7 +526,18 @@ _bt_check_unique(Relation rel, BTInsertState insertstate, Relation heapRel,
{
/* ... htid is from simple non-pivot tuple */
Assert(!inposting);
- htid = curitup->t_tid;
+
+ /*
+ * A prefix-compressed follower packs BT_IS_COMPRESSED in
+ * t_tid's upper nibble; decode to a clean heap TID before
+ * any tableam fetch or TID comparison below. Plain
+ * (non-compressed) tuples, including anchors, fall through
+ * with their t_tid used verbatim.
+ */
+ if (P_HAS_PREFIX_COMPRESSION(opaque) && BTreeTupleIsCompressed(curitup))
+ BTreeCompressedTupleGetHeapTID(curitup, &htid);
+ else
+ htid = curitup->t_tid;
}
else if (!inposting)
{
@@ -910,7 +921,12 @@ _bt_findinsertloc(Relation rel,
/*
* If the target page cannot fit newitem, try to avoid splitting the
- * page on insert by performing deletion or deduplication now
+ * page on insert by performing deletion or deduplication now. On
+ * prefix-compressed indexes deduplication is disabled index-wide
+ * (btm_allequalimage is false), but we still run the combined
+ * delete-or-dedup helper: the deletion pass (LP_DEAD and
+ * bottom-up) is still valuable, and the dedup pass is a no-op
+ * because allequalimage is false.
*/
if (PageGetFreeSpace(page) < insertstate->itemsz)
_bt_delete_or_dedup_one_page(rel, heapRel, insertstate, false,
@@ -1135,6 +1151,9 @@ _bt_insertonpg(Relation rel,
IndexTuple oposting = NULL;
IndexTuple origitup = NULL;
IndexTuple nposting = NULL;
+ IndexTuple emit_itup; /* possibly-compressed form actually written */
+ Size emit_itemsz;
+ char *emit_scratch = NULL;
XLogRecPtr recptr;
page = BufferGetPage(buf);
@@ -1154,10 +1173,78 @@ _bt_insertonpg(Relation rel,
BTreeTupleGetNAtts(itup, rel) <=
IndexRelationGetNumberOfKeyAttributes(rel));
Assert(!BTreeTupleIsPosting(itup));
+ Assert(!BTreeTupleIsCompressed(itup));
Assert(MAXALIGN(IndexTupleSize(itup)) == itemsz);
/* Caller must always finish incomplete split for us */
Assert(!P_INCOMPLETE_SPLIT(opaque));
+ /*
+ * Default: emit itup as-is with its plain size. When the target leaf page
+ * is prefix-compressed we may replace emit_itup with a compressed follower
+ * below.
+ */
+ emit_itup = itup;
+ emit_itemsz = itemsz;
+
+ /*
+ * Prefix compression: if this is a leaf page that already carries
+ * BTP_HAS_PREFIX_COMPRESSION and the incoming tuple shares the governing
+ * anchor's leading (nkeyatts-1) key attrs, emit the compressed form.
+ *
+ * The invariant that the leftmost data item on a prefix-compressed leaf
+ * page must be a plain anchor is preserved: we never compress when
+ * newitemoff == P_FIRSTDATAKEY (the to-be-leftmost position). Posting
+ * splits never happen on prefix-compressed pages because such indexes
+ * have btm_allequalimage=false, so dedup is disabled index-wide.
+ */
+ if (isleaf && itup_key->prefix_compression &&
+ P_HAS_PREFIX_COMPRESSION(opaque) && postingoff == 0 &&
+ newitemoff > P_FIRSTDATAKEY(opaque))
+ {
+ OffsetNumber aoff;
+
+ aoff = _bt_prefix_find_anchor(page, OffsetNumberPrev(newitemoff));
+ if (aoff == InvalidOffsetNumber)
+ ereport(ERROR,
+ (errcode(ERRCODE_INDEX_CORRUPTED),
+ errmsg_internal("prefix-compressed leaf page has no anchor before offset %u in index \"%s\"",
+ newitemoff,
+ RelationGetRelationName(rel))));
+ else
+ {
+ IndexTuple anchor = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, aoff));
+ int natts_anchor =
+ IndexRelationGetNumberOfKeyAttributes(rel) - 1;
+
+ if (_bt_keep_natts_fast(rel, anchor, itup) > natts_anchor)
+ {
+ TupleDesc follower_tupdesc;
+
+ /*
+ * By btree sort-order invariant, any incoming tuple that lies
+ * inside the governing anchor's group (by offset and sort
+ * position) must share all natts_anchor leading attrs -- the
+ * check above turns that invariant into a precondition for
+ * compression, not a guarantee. A tuple landing between two
+ * groups, or at the start of a new group, will fall through
+ * and be emitted plain, becoming a new anchor.
+ */
+ follower_tupdesc =
+ _bt_prefix_build_follower_tupdesc(rel, natts_anchor);
+ emit_scratch = palloc(BTMaxItemSize);
+ emit_itup =
+ _bt_prefix_compress_new_tuple(RelationGetDescr(rel),
+ follower_tupdesc,
+ natts_anchor,
+ itup,
+ emit_scratch);
+ emit_itemsz = MAXALIGN(IndexTupleSize(emit_itup));
+ FreeTupleDesc(follower_tupdesc);
+ }
+ }
+ }
+
/*
* Every internal page should have exactly one negative infinity item at
* all times. Only _bt_split() and _bt_newlevel() should add items that
@@ -1209,6 +1296,19 @@ _bt_insertonpg(Relation rel,
nposting = _bt_swap_posting(itup, oposting, postingoff);
/* itup now contains rightmost/max TID from oposting */
+ /*
+ * The tuple physically inserted on the page after the posting split
+ * is 'itup' (with the TID swap applied by _bt_swap_posting), not
+ * 'emit_itup' (which still points at origitup). Master's code uses
+ * 'itup' directly. Our prefix-compression branch introduced
+ * emit_itup as the thing PageAddItem writes out, so we must update
+ * it to match 'itup' in the posting-split path or we'd write origitup
+ * (with its original unswapped TID) and corrupt the page's
+ * (key, TID) sort order.
+ */
+ emit_itup = itup;
+ emit_itemsz = itemsz;
+
/* Alter offset so that newitem goes after posting list */
newitemoff = OffsetNumberNext(newitemoff);
}
@@ -1219,14 +1319,27 @@ _bt_insertonpg(Relation rel,
* Note: PageGetFreeSpace() subtracts sizeof(ItemIdData) from its result,
* so this comparison is correct even though we appear to be accounting
* only for the item and not for its line pointer.
+ *
+ * Use emit_itemsz (the possibly-compressed size) here so that a tuple
+ * whose uncompressed size would not fit but whose compressed size does
+ * can avoid a page split.
*/
- if (PageGetFreeSpace(page) < itemsz)
+ if (PageGetFreeSpace(page) < emit_itemsz)
{
Buffer rbuf;
Assert(!split_only_page);
- /* split the buffer into left and right halves */
+ /*
+ * _bt_split has not yet been taught about prefix compression; hand it
+ * the plain tuple and plain size. The split logic will produce pages
+ * that the build/insert paths can subsequently compress to.
+ */
+ if (emit_scratch != NULL)
+ {
+ pfree(emit_scratch);
+ emit_scratch = NULL;
+ }
rbuf = _bt_split(rel, heaprel, itup_key, buf, cbuf, newitemoff, itemsz,
itup, origitup, nposting, postingoff);
PredicateLockPageSplit(rel,
@@ -1297,7 +1410,7 @@ _bt_insertonpg(Relation rel,
if (postingoff != 0)
memcpy(oposting, nposting, MAXALIGN(IndexTupleSize(nposting)));
- if (PageAddItem(page, itup, itemsz, newitemoff, false, false) == InvalidOffsetNumber)
+ if (PageAddItem(page, emit_itup, emit_itemsz, newitemoff, false, false) == InvalidOffsetNumber)
elog(PANIC, "failed to add new item to block %u in index \"%s\"",
BufferGetBlockNumber(buf), RelationGetRelationName(rel));
@@ -1373,6 +1486,7 @@ _bt_insertonpg(Relation rel,
xlmeta.fastlevel = metad->btm_fastlevel;
xlmeta.last_cleanup_num_delpages = metad->btm_last_cleanup_num_delpages;
xlmeta.allequalimage = metad->btm_allequalimage;
+ xlmeta.prefix_compression = metad->btm_prefix_compression;
XLogRegisterBuffer(2, metabuf,
REGBUF_WILL_INIT | REGBUF_STANDARD);
@@ -1384,8 +1498,11 @@ _bt_insertonpg(Relation rel,
XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
if (postingoff == 0)
{
- /* Just log itup from caller */
- XLogRegisterBufData(0, itup, IndexTupleSize(itup));
+ /*
+ * Log the bytes as they appear on the page -- compressed when
+ * prefix compression turned itup into a follower, else plain.
+ */
+ XLogRegisterBufData(0, emit_itup, IndexTupleSize(emit_itup));
}
else
{
@@ -1445,6 +1562,9 @@ _bt_insertonpg(Relation rel,
if (BlockNumberIsValid(blockcache) &&
_bt_getrootheight(rel) >= BTREE_FASTPATH_MIN_LEVEL)
RelationSetTargetBlock(rel, blockcache);
+
+ if (emit_scratch != NULL)
+ pfree(emit_scratch);
}
/* be tidy */
@@ -1517,6 +1637,12 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
isleaf,
isrightmost;
XLogRecPtr recptr;
+ /*
+ * Scratch buffers used when prefix compression forces us to reconstruct
+ * firstright and/or lastleft to their plain form.
+ */
+ char *firstright_scratch = NULL;
+ char *lastleft_scratch = NULL;
/*
* origpage is the original page to be split. leftpage is a temporary
@@ -1638,6 +1764,19 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
* lastleft. A tuple with all the keys from firstright but the max heap
* TID from lastleft is used, to avoid introducing a special case.)
*/
+ /*
+ * Prefix-compression bookkeeping:
+ * - firstright is the candidate first item on the new right page. On a
+ * BTP_HAS_PREFIX_COMPRESSION page, the leftmost data item on the right
+ * page must be a plain anchor (invariant #1). If the tuple at
+ * firstrightoff is a compressed follower (the split landed mid-group),
+ * we reconstruct its plain form and use that for both _bt_truncate's
+ * firstright input and the right page's P_FIRSTKEY.
+ * - lastleft must also be a plain tuple for _bt_truncate, so we
+ * reconstruct it on the fly if it was a compressed follower.
+ * Scratch buffers are declared above and freed near the end of the
+ * function.
+ */
if (!newitemonleft && newitemoff == firstrightoff)
{
/* incoming tuple becomes firstright */
@@ -1652,6 +1791,22 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
firstright = (IndexTuple) PageGetItem(origpage, itemid);
if (firstrightoff == origpagepostingoff)
firstright = nposting;
+
+ /* Mid-group split: promote compressed follower to plain anchor */
+ if (isleaf && itup_key->prefix_compression &&
+ BTreeTupleIsCompressed(firstright))
+ {
+ BTPrefixPageState rst;
+
+ memset(&rst, 0, sizeof(rst));
+ _bt_prefix_state_init(&rst, origpage);
+ firstright_scratch = palloc(BTMaxItemSize);
+ firstright = _bt_prefix_reconstruct(rel, &rst,
+ firstrightoff,
+ firstright_scratch);
+ _bt_prefix_state_cleanup(&rst);
+ itemsz = MAXALIGN(IndexTupleSize(firstright));
+ }
}
if (isleaf)
@@ -1675,6 +1830,21 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
lastleft = (IndexTuple) PageGetItem(origpage, itemid);
if (lastleftoff == origpagepostingoff)
lastleft = nposting;
+
+ /* Reconstruct if lastleft is a compressed follower */
+ if (itup_key->prefix_compression &&
+ BTreeTupleIsCompressed(lastleft))
+ {
+ BTPrefixPageState rst;
+
+ memset(&rst, 0, sizeof(rst));
+ _bt_prefix_state_init(&rst, origpage);
+ lastleft_scratch = palloc(BTMaxItemSize);
+ lastleft = _bt_prefix_reconstruct(rel, &rst,
+ lastleftoff,
+ lastleft_scratch);
+ _bt_prefix_state_cleanup(&rst);
+ }
}
lefthighkey = _bt_truncate(rel, lastleft, firstright, itup_key);
@@ -1872,6 +2042,20 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
}
else
{
+ /*
+ * On a prefix-compressed page, if the split landed mid-group, we
+ * promote the first right-page data item to its plain form so
+ * the right page's leftmost data item is a plain anchor.
+ * Subsequent followers on the right page inherit from this new
+ * anchor (their leading attrs matched the original anchor, and
+ * the promotion preserves those attrs).
+ */
+ if (i == firstrightoff && firstright_scratch != NULL)
+ {
+ dataitem = firstright;
+ itemsz = MAXALIGN(IndexTupleSize(dataitem));
+ }
+
if (!_bt_pgaddtup(rightpage, itemsz, dataitem, afterrightoff,
afterrightoff == minusinfoff))
{
@@ -2004,6 +2188,8 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
xlrec.postingoff = 0;
if (postingoff != 0 && origpagepostingoff < firstrightoff)
xlrec.postingoff = postingoff;
+ /* Preserve origpage flags (BTP_HAS_PREFIX_COMPRESSION etc.) */
+ xlrec.origflags = oopaque->btpo_flags;
XLogBeginInsert();
XLogRegisterData(&xlrec, SizeOfBtreeSplit);
@@ -2103,6 +2289,10 @@ _bt_split(Relation rel, Relation heaprel, BTScanInsert itup_key, Buffer buf,
/* be tidy */
if (isleaf)
pfree(lefthighkey);
+ if (firstright_scratch != NULL)
+ pfree(firstright_scratch);
+ if (lastleft_scratch != NULL)
+ pfree(lastleft_scratch);
/* split's done */
return rbuf;
@@ -2622,6 +2812,7 @@ _bt_newlevel(Relation rel, Relation heaprel, Buffer lbuf, Buffer rbuf)
md.fastlevel = metad->btm_level;
md.last_cleanup_num_delpages = metad->btm_last_cleanup_num_delpages;
md.allequalimage = metad->btm_allequalimage;
+ md.prefix_compression = metad->btm_prefix_compression;
XLogRegisterBufData(2, &md, sizeof(xl_btree_metadata));
@@ -2723,6 +2914,18 @@ _bt_pgaddtup(Page page,
* all this because !heapkeyspace indexes must still do a "getting tired"
* linear search, and so are likely to get some benefit from using it as a
* gating condition.)
+ *
+ * XXX prefix compression interaction: bottom-up deletion and deduplication
+ * both ultimately call _bt_keep_natts_fast on pairs of on-page tuples, which
+ * cannot yet consume compressed followers directly (see the comment over
+ * _bt_keep_natts_fast itself). Dedup is already disabled at the index level
+ * (btm_prefix_compression ⇒ !btm_allequalimage); bottom-up delete is
+ * suppressed per-call below on prefix-compressed pages only. Simple
+ * deletion stays enabled for prefix-compressed pages because its per-tuple
+ * handling has been taught to decode compressed heap TIDs (see
+ * _bt_simpledel_pass), so there's no asymmetry of "delete nothing" --
+ * simple deletion still runs, bottom-up does not. Remove that asymmetry
+ * once nbtsplitloc.c/dedup learn to reconstruct.
*/
static void
_bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
@@ -2816,7 +3019,17 @@ _bt_delete_or_dedup_one_page(Relation rel, Relation heapRel,
* together index tuples, so the same correctness considerations do not
* apply. We deliberately omit an index-is-allequalimage test here.
*/
+ /*
+ * Bottom-up deletion compares on-page tuples via _bt_keep_natts_fast,
+ * which expects plain tuples with the full tupdesc. Compressed
+ * followers on a prefix-compressed leaf don't satisfy that contract, so
+ * skip the optimization on such pages -- this is purely advisory and
+ * simply gives up some free-space reclamation. The subsequent dedup
+ * pass is also guarded (and is always disabled for prefix-compressed
+ * indexes anyway), so insertion falls through to split as usual.
+ */
if ((indexUnchanged || uniquedup) &&
+ !P_HAS_PREFIX_COMPRESSION(BTPageGetOpaque(BufferGetPage(buffer))) &&
_bt_bottomupdel_pass(rel, buffer, heapRel, insertstate->itemsz))
return;
@@ -2863,6 +3076,7 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel,
int ndeadblocks;
TM_IndexDeleteOp delstate;
OffsetNumber offnum;
+ BTPageOpaque opaque = BTPageGetOpaque(page);
/* Get array of table blocks pointed to by LP_DEAD-set tuples */
deadblocks = _bt_deadblocks(page, deletable, ndeletable, newitem,
@@ -2902,9 +3116,15 @@ _bt_simpledel_pass(Relation rel, Buffer buffer, Relation heapRel,
/*
* TID's table block is among those pointed to by the TIDs from
- * LP_DEAD-bit set tuples on page -- add TID to deltids
+ * LP_DEAD-bit set tuples on page -- add TID to deltids. For
+ * compressed followers we must demask the heap offset, because
+ * t_tid.ip_posid carries BT_IS_COMPRESSED in its upper nibble
+ * and the tableam below expects a clean ItemPointerData.
*/
- odeltid->tid = itup->t_tid;
+ if (P_HAS_PREFIX_COMPRESSION(opaque) && BTreeTupleIsCompressed(itup))
+ BTreeCompressedTupleGetHeapTID(itup, &odeltid->tid);
+ else
+ odeltid->tid = itup->t_tid;
odeltid->id = delstate.ndeltids;
ostatus->idxoffnum = offnum;
ostatus->knowndeletable = ItemIdIsDead(itemid);
diff --git a/src/backend/access/nbtree/nbtpage.c b/src/backend/access/nbtree/nbtpage.c
index 83d6d367f..409a2613f 100644
--- a/src/backend/access/nbtree/nbtpage.c
+++ b/src/backend/access/nbtree/nbtpage.c
@@ -63,14 +63,20 @@ static void _bt_pendingfsm_add(BTVacState *vstate, BlockNumber target,
/*
* _bt_initmetapage() -- Fill a page buffer with a correct metapage image
+ *
+ * btm_allequalimage and btm_prefix_compression are mutually exclusive (the
+ * two on-disk encodings are incompatible). Callers must honor that; we
+ * assert it here.
*/
void
_bt_initmetapage(Page page, BlockNumber rootbknum, uint32 level,
- bool allequalimage)
+ bool allequalimage, bool prefix_compression)
{
BTMetaPageData *metad;
BTPageOpaque metaopaque;
+ Assert(!(allequalimage && prefix_compression));
+
_bt_pageinit(page, BLCKSZ);
metad = BTPageGetMeta(page);
@@ -83,6 +89,7 @@ _bt_initmetapage(Page page, BlockNumber rootbknum, uint32 level,
metad->btm_last_cleanup_num_delpages = 0;
metad->btm_last_cleanup_num_heap_tuples = -1.0;
metad->btm_allequalimage = allequalimage;
+ metad->btm_prefix_compression = prefix_compression;
metaopaque = BTPageGetOpaque(page);
metaopaque->btpo_flags = BTP_META;
@@ -122,9 +129,10 @@ _bt_upgrademetapage(Page page)
metad->btm_version = BTREE_NOVAC_VERSION;
metad->btm_last_cleanup_num_delpages = 0;
metad->btm_last_cleanup_num_heap_tuples = -1.0;
- /* Only a REINDEX can set this field */
+ /* Only a REINDEX can set these fields */
Assert(!metad->btm_allequalimage);
metad->btm_allequalimage = false;
+ metad->btm_prefix_compression = false;
/* Adjust pd_lower (see _bt_initmetapage() for details) */
((PageHeader) page)->pd_lower =
@@ -299,6 +307,7 @@ _bt_set_cleanup_info(Relation rel, BlockNumber num_delpages)
md.fastlevel = metad->btm_fastlevel;
md.last_cleanup_num_delpages = num_delpages;
md.allequalimage = metad->btm_allequalimage;
+ md.prefix_compression = metad->btm_prefix_compression;
XLogRegisterBufData(0, &md, sizeof(xl_btree_metadata));
@@ -450,6 +459,16 @@ _bt_getroot(Relation rel, Relation heaprel, int access)
rootopaque = BTPageGetOpaque(rootpage);
rootopaque->btpo_prev = rootopaque->btpo_next = P_NONE;
rootopaque->btpo_flags = (BTP_LEAF | BTP_ROOT);
+ /*
+ * The new leaf root inherits the index-wide prefix-compression
+ * opt-in. Without this, an initially-empty CREATE INDEX followed
+ * by retail INSERTs produces plain-only leaves forever (the insert
+ * path gates compression on BTP_HAS_PREFIX_COMPRESSION, and
+ * _bt_split propagates the flag from origpage to its new right
+ * sibling, so an unflagged first leaf poisons every descendant).
+ */
+ if (metad->btm_prefix_compression)
+ rootopaque->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION;
rootopaque->btpo_level = 0;
rootopaque->btpo_cycleid = 0;
/* Get raw page pointer for metapage */
@@ -490,6 +509,7 @@ _bt_getroot(Relation rel, Relation heaprel, int access)
md.fastlevel = 0;
md.last_cleanup_num_delpages = 0;
md.allequalimage = metad->btm_allequalimage;
+ md.prefix_compression = metad->btm_prefix_compression;
XLogRegisterBufData(2, &md, sizeof(xl_btree_metadata));
@@ -721,7 +741,8 @@ _bt_getrootheight(Relation rel)
* pg_upgrade'd from Postgres 12.
*/
void
-_bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage)
+_bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage,
+ bool *prefix_compression)
{
BTMetaPageData *metad;
@@ -741,6 +762,7 @@ _bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage)
{
*heapkeyspace = metad->btm_version > BTREE_NOVAC_VERSION;
*allequalimage = metad->btm_allequalimage;
+ *prefix_compression = metad->btm_prefix_compression;
_bt_relbuf(rel, metabuf);
return;
@@ -769,10 +791,15 @@ _bt_metaversion(Relation rel, bool *heapkeyspace, bool *allequalimage)
Assert(metad->btm_version <= BTREE_VERSION);
Assert(!metad->btm_allequalimage ||
metad->btm_version > BTREE_NOVAC_VERSION);
+ Assert(!metad->btm_prefix_compression ||
+ metad->btm_version > BTREE_NOVAC_VERSION);
+ /* Mutually exclusive with deduplication */
+ Assert(!(metad->btm_allequalimage && metad->btm_prefix_compression));
Assert(metad->btm_fastroot != P_NONE);
*heapkeyspace = metad->btm_version > BTREE_NOVAC_VERSION;
*allequalimage = metad->btm_allequalimage;
+ *prefix_compression = metad->btm_prefix_compression;
}
/*
@@ -1170,6 +1197,8 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
Size updatedbuflen = 0;
OffsetNumber updatedoffsets[MaxIndexTuplesPerPage];
XLogRecPtr recptr;
+ BTPrefixPromotion promotions[MaxIndexTuplesPerPage];
+ int nprom = 0;
/* Shouldn't be called unless there's something to do */
Assert(ndeletable > 0 || nupdatable > 0);
@@ -1180,6 +1209,28 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
updatedoffsets, &updatedbuflen,
needswal);
+ /*
+ * Plan survivor-follower promotions outside the critical section, so
+ * _bt_prefix_reconstruct's palloc()s are safe. The resulting
+ * 'promotions' array carries palloc'd plain tuples that we'll overwrite
+ * in place inside the critical section below. The plan records
+ * post-delete offsets: the apply step runs *after* PageIndexMultiDelete.
+ */
+ if (ndeletable > 0)
+ nprom = _bt_prefix_vacuum_plan(rel, page, deletable, ndeletable,
+ promotions);
+
+ /*
+ * Prefix compression and posting-list dedup are mutually exclusive at the
+ * index level (btm_prefix_compression ⇒ !btm_allequalimage), so a page
+ * that produced any promotions cannot also carry posting-tuple updates.
+ * That lets us safely reorder: promotions must run *after*
+ * PageIndexMultiDelete (so the deleted anchors' bytes are available to
+ * grow each survivor into its plain form), while posting updates keep
+ * their historical pre-delete slot for pages without promotions.
+ */
+ Assert(nprom == 0 || nupdatable == 0);
+
/* No ereport(ERROR) until changes are logged */
START_CRIT_SECTION();
@@ -1212,6 +1263,15 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ /*
+ * Promote survivor followers to plain anchors now that the original
+ * anchors' bytes have been freed by PageIndexMultiDelete. The plan's
+ * recorded offsets are already adjusted for the post-delete line pointer
+ * shift. This ordering is what makes the PageIndexTupleOverwrite
+ * always-fit guarantee hold (see comment in _bt_prefix_vacuum_apply).
+ */
+ _bt_prefix_vacuum_apply(page, promotions, nprom);
+
/*
* We can clear the vacuum cycle ID since this page has certainly been
* processed by the current vacuum scan.
@@ -1235,12 +1295,20 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
if (needswal)
{
xl_btree_vacuum xlrec_vacuum;
+ uint8 xlflags = REGBUF_STANDARD;
xlrec_vacuum.ndeleted = ndeletable;
xlrec_vacuum.nupdated = nupdatable;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ /*
+ * When anchor promotion happened, the page's new layout is not
+ * recoverable from the ndeletable/nupdatable deltas alone; force a
+ * full page image so replay sees the post-promotion bytes.
+ */
+ if (nprom > 0)
+ xlflags |= REGBUF_FORCE_IMAGE;
+ XLogRegisterBuffer(0, buf, xlflags);
XLogRegisterData(&xlrec_vacuum, SizeOfBtreeVacuum);
if (ndeletable > 0)
@@ -1269,6 +1337,9 @@ _bt_delitems_vacuum(Relation rel, Buffer buf,
/* free tuples allocated within _bt_delitems_update() */
for (int i = 0; i < nupdatable; i++)
pfree(updatable[i]->itup);
+ /* free promoted tuples allocated within _bt_prefix_vacuum_plan() */
+ for (int i = 0; i < nprom; i++)
+ pfree(promotions[i].plain);
}
/*
@@ -1302,6 +1373,8 @@ _bt_delitems_delete(Relation rel, Buffer buf,
Size updatedbuflen = 0;
OffsetNumber updatedoffsets[MaxIndexTuplesPerPage];
XLogRecPtr recptr;
+ BTPrefixPromotion promotions[MaxIndexTuplesPerPage];
+ int nprom = 0;
/* Shouldn't be called unless there's something to do */
Assert(ndeletable > 0 || nupdatable > 0);
@@ -1312,6 +1385,18 @@ _bt_delitems_delete(Relation rel, Buffer buf,
updatedoffsets, &updatedbuflen,
needswal);
+ /*
+ * Plan survivor-follower promotions outside the critical section; the
+ * plan records post-delete offsets and the apply step runs *after*
+ * PageIndexMultiDelete.
+ */
+ if (ndeletable > 0)
+ nprom = _bt_prefix_vacuum_plan(rel, page, deletable, ndeletable,
+ promotions);
+
+ /* Mutually exclusive -- see comment in _bt_delitems_vacuum. */
+ Assert(nprom == 0 || nupdatable == 0);
+
/* No ereport(ERROR) until changes are logged */
START_CRIT_SECTION();
@@ -1332,6 +1417,9 @@ _bt_delitems_delete(Relation rel, Buffer buf,
if (ndeletable > 0)
PageIndexMultiDelete(page, deletable, ndeletable);
+ /* Delete-first ordering frees anchor bytes; promotions always fit now. */
+ _bt_prefix_vacuum_apply(page, promotions, nprom);
+
/*
* Unlike _bt_delitems_vacuum, we *must not* clear the vacuum cycle ID at
* this point. The VACUUM command alone controls vacuum cycle IDs.
@@ -1353,6 +1441,7 @@ _bt_delitems_delete(Relation rel, Buffer buf,
if (needswal)
{
xl_btree_delete xlrec_delete;
+ uint8 xlflags = REGBUF_STANDARD;
xlrec_delete.snapshotConflictHorizon = snapshotConflictHorizon;
xlrec_delete.ndeleted = ndeletable;
@@ -1360,7 +1449,9 @@ _bt_delitems_delete(Relation rel, Buffer buf,
xlrec_delete.isCatalogRel = isCatalogRel;
XLogBeginInsert();
- XLogRegisterBuffer(0, buf, REGBUF_STANDARD);
+ if (nprom > 0)
+ xlflags |= REGBUF_FORCE_IMAGE;
+ XLogRegisterBuffer(0, buf, xlflags);
XLogRegisterData(&xlrec_delete, SizeOfBtreeDelete);
if (ndeletable > 0)
@@ -1389,6 +1480,9 @@ _bt_delitems_delete(Relation rel, Buffer buf,
/* free tuples allocated within _bt_delitems_update() */
for (int i = 0; i < nupdatable; i++)
pfree(updatable[i]->itup);
+ /* free promoted tuples allocated within _bt_prefix_vacuum_plan() */
+ for (int i = 0; i < nprom; i++)
+ pfree(promotions[i].plain);
}
/*
@@ -1588,8 +1682,23 @@ _bt_delitems_delete_check(Relation rel, Buffer buf, Relation heapRel,
if (!BTreeTupleIsPosting(itup))
{
- /* Plain non-pivot tuple */
- Assert(ItemPointerEquals(&itup->t_tid, &delstate->deltids[i].tid));
+ /*
+ * Plain non-pivot tuple (or a prefix-compressed follower). Deltids
+ * entries were populated by the collector paths with decoded heap
+ * TIDs, so compare against the follower's decoded TID rather than
+ * the raw t_tid bytes (whose upper nibble carries BT_IS_COMPRESSED).
+ */
+#ifdef USE_ASSERT_CHECKING
+ {
+ ItemPointerData htid;
+
+ if (BTreeTupleIsCompressed(itup))
+ BTreeCompressedTupleGetHeapTID(itup, &htid);
+ else
+ htid = itup->t_tid;
+ Assert(ItemPointerEquals(&htid, &delstate->deltids[i].tid));
+ }
+#endif
if (dstatus->knowndeletable)
deletable[ndeletable++] = idxoffnum;
continue;
@@ -2732,6 +2841,7 @@ _bt_unlink_halfdead_page(Relation rel, Buffer leafbuf, BlockNumber scanblkno,
xlmeta.fastlevel = metad->btm_fastlevel;
xlmeta.last_cleanup_num_delpages = metad->btm_last_cleanup_num_delpages;
xlmeta.allequalimage = metad->btm_allequalimage;
+ xlmeta.prefix_compression = metad->btm_prefix_compression;
XLogRegisterBufData(4, &xlmeta, sizeof(xl_btree_metadata));
xlinfo = XLOG_BTREE_UNLINK_PAGE_META;
diff --git a/src/backend/access/nbtree/nbtprefix.c b/src/backend/access/nbtree/nbtprefix.c
new file mode 100644
index 000000000..a480aa6cf
--- /dev/null
+++ b/src/backend/access/nbtree/nbtprefix.c
@@ -0,0 +1,605 @@
+/*-------------------------------------------------------------------------
+ *
+ * nbtprefix.c
+ * Prefix-compressed leaf-page support for btrees.
+ *
+ * Portions Copyright (c) 1996-2026, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ * src/backend/access/nbtree/nbtprefix.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include "access/htup_details.h"
+#include "access/nbtree.h"
+#include "utils/lsyscache.h"
+#include "utils/rel.h"
+
+/*
+ * MaxHeapTuplesPerPage must fit in BT_OFFSET_MASK so that a compressed
+ * follower's real heap-tuple offset can be packed into the low 12 bits of
+ * t_tid's offset field, leaving BT_IS_COMPRESSED in the upper nibble.
+ */
+StaticAssertDecl(MaxHeapTuplesPerPage <= BT_OFFSET_MASK,
+ "MaxHeapTuplesPerPage must fit in BT_OFFSET_MASK");
+
+/* Internal helper also exposed via _bt_prefix_build_follower_tupdesc below */
+static TupleDesc build_follower_tupdesc(Relation rel, int natts_anchor);
+
+
+/*
+ * _bt_prefix_state_init() -- begin walking a prefix-compressed leaf page.
+ *
+ * Must be paired with _bt_prefix_state_cleanup() once walking is finished
+ * (or before the frame owning 'st' is destroyed). It is legal to call
+ * _bt_prefix_state_init() again on the same state -- the previous state is
+ * cleaned up implicitly.
+ */
+void
+_bt_prefix_state_init(BTPrefixPageState *st, Page page)
+{
+ _bt_prefix_state_cleanup(st);
+ st->page = page;
+ st->anchor_off = InvalidOffsetNumber;
+ st->anchor = NULL;
+ st->natts_anchor = 0;
+ st->anchor_deformed = false;
+ st->next_anchor_off = InvalidOffsetNumber;
+ st->follower_tupdesc = NULL;
+}
+
+/*
+ * _bt_prefix_state_cleanup() -- release any per-page caches held by state.
+ */
+void
+_bt_prefix_state_cleanup(BTPrefixPageState *st)
+{
+ if (st->follower_tupdesc != NULL)
+ {
+ FreeTupleDesc(st->follower_tupdesc);
+ st->follower_tupdesc = NULL;
+ }
+ st->page = NULL;
+ st->anchor_off = InvalidOffsetNumber;
+ st->anchor = NULL;
+ st->anchor_deformed = false;
+ st->next_anchor_off = InvalidOffsetNumber;
+}
+
+/*
+ * _bt_prefix_state_set_anchor() -- mark anchor_off as the current anchor.
+ *
+ * The tuple at anchor_off must be a plain (non-compressed, non-pivot,
+ * non-posting) leaf tuple. The anchor's Datums/isnull arrays are NOT
+ * deformed here; deformation is deferred to first use in _bt_prefix_form_tuple
+ * so that anchors without any followers pay no reconstruction cost.
+ */
+void
+_bt_prefix_state_set_anchor(BTPrefixPageState *st, OffsetNumber anchor_off)
+{
+ IndexTuple itup;
+
+ Assert(st->page != NULL);
+ Assert(anchor_off >= FirstOffsetNumber);
+ Assert(anchor_off <= PageGetMaxOffsetNumber(st->page));
+
+ itup = (IndexTuple) PageGetItem(st->page, PageGetItemId(st->page, anchor_off));
+ Assert(!BTreeTupleIsCompressed(itup));
+ Assert(!BTreeTupleIsPivot(itup) || anchor_off == P_HIKEY);
+ Assert(!BTreeTupleIsPosting(itup));
+
+ st->anchor_off = anchor_off;
+ st->anchor = itup;
+ st->anchor_deformed = false;
+ st->next_anchor_off = InvalidOffsetNumber;
+}
+
+/*
+ * _bt_prefix_find_anchor() -- walk left from offnum to find the governing
+ * anchor tuple on the same page.
+ *
+ * Returns the offset of the anchor, or InvalidOffsetNumber if no anchor is
+ * found before the start of the data area (which would indicate corruption).
+ * The item at offnum itself is examined first, so callers can use this to
+ * resolve any item to its anchor.
+ */
+OffsetNumber
+_bt_prefix_find_anchor(Page page, OffsetNumber offnum)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber firstdata = P_FIRSTDATAKEY(opaque);
+ OffsetNumber off;
+
+ Assert(P_ISLEAF(opaque));
+ Assert(P_HAS_PREFIX_COMPRESSION(opaque));
+ Assert(offnum >= firstdata);
+
+ for (off = offnum; off >= firstdata; off--)
+ {
+ IndexTuple itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, off));
+
+ if (!BTreeTupleIsCompressed(itup))
+ return off;
+ }
+
+ return InvalidOffsetNumber;
+}
+
+/*
+ * build_follower_tupdesc() -- construct a trailing-attrs TupleDesc.
+ *
+ * The returned TupleDesc covers attrs (natts_anchor+1 .. natts) of the
+ * relation's tupdesc, renumbered to start at 1. This matches the on-disk
+ * layout of a compressed follower. Caller owns the returned TupleDesc and
+ * must free it with FreeTupleDesc().
+ */
+static TupleDesc
+build_follower_tupdesc(Relation rel, int natts_anchor)
+{
+ TupleDesc src = RelationGetDescr(rel);
+ int natts = src->natts;
+ int follower_natts = natts - natts_anchor;
+ TupleDesc desc;
+
+ Assert(natts_anchor > 0);
+ Assert(follower_natts > 0);
+
+ desc = CreateTemplateTupleDesc(follower_natts);
+
+ memcpy(TupleDescAttr(desc, 0),
+ TupleDescAttr(src, natts_anchor),
+ follower_natts * sizeof(FormData_pg_attribute));
+
+ /*
+ * Renumber attnums so they run 1..follower_natts. Attribute metadata
+ * otherwise matches the source (including typlen, typalign, attoptions).
+ */
+ for (int i = 0; i < follower_natts; i++)
+ {
+ Form_pg_attribute att = TupleDescAttr(desc, i);
+
+ att->attnum = i + 1;
+ /* Constraint metadata is not copied and not needed for deform/form */
+ att->attnotnull = false;
+ att->atthasdef = false;
+ att->atthasmissing = false;
+ att->attidentity = '\0';
+ att->attgenerated = '\0';
+
+ populate_compact_attribute(desc, i);
+ }
+
+ desc->tdtypeid = src->tdtypeid;
+ desc->tdtypmod = src->tdtypmod;
+
+ TupleDescFinalize(desc);
+
+ return desc;
+}
+
+/*
+ * _bt_prefix_form_tuple() -- materialize a compressed follower.
+ *
+ * Combines the leading attrs from the state's current anchor with the
+ * trailing attrs from 'compressed' to produce a plain-looking, fully formed
+ * IndexTuple. The follower's real heap TID (extracted from the low 12 bits
+ * of its t_tid offset field) is written into the result's t_tid.
+ *
+ * If 'dstbuf' is non-NULL it is used as the output buffer (caller must
+ * ensure it is at least BTMaxItemSize bytes). Otherwise the returned tuple
+ * is palloc()'d in CurrentMemoryContext and caller is responsible for
+ * pfree()ing it.
+ *
+ * Requires that _bt_prefix_state_set_anchor() has been called for the
+ * governing anchor of 'compressed'.
+ */
+IndexTuple
+_bt_prefix_form_tuple(Relation rel, BTPrefixPageState *st,
+ IndexTuple compressed, char *dstbuf)
+{
+ TupleDesc tupdesc = RelationGetDescr(rel);
+ int natts = tupdesc->natts;
+ int natts_anchor = st->natts_anchor;
+ int follower_natts = natts - natts_anchor;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ Datum trail_values[INDEX_MAX_KEYS];
+ bool trail_isnull[INDEX_MAX_KEYS];
+ IndexTuple result;
+ ItemPointerData heap_tid;
+
+ Assert(BTreeTupleIsCompressed(compressed));
+ Assert(st->anchor != NULL);
+ Assert(natts_anchor > 0 && natts_anchor < natts);
+
+ /* Deform the anchor once per anchor-group */
+ if (!st->anchor_deformed)
+ {
+ index_deform_tuple(st->anchor, tupdesc,
+ st->anchor_values, st->anchor_isnull);
+ st->anchor_deformed = true;
+ }
+
+ /*
+ * Build (or reuse) the trailing-attrs TupleDesc; the on-disk layout of a
+ * follower matches this shape exactly.
+ */
+ if (st->follower_tupdesc == NULL)
+ st->follower_tupdesc = build_follower_tupdesc(rel, natts_anchor);
+
+ index_deform_tuple(compressed, st->follower_tupdesc,
+ trail_values, trail_isnull);
+
+ /* Merge leading (anchor) + trailing (follower) into one Datum array */
+ memcpy(values, st->anchor_values, natts_anchor * sizeof(Datum));
+ memcpy(isnull, st->anchor_isnull, natts_anchor * sizeof(bool));
+ memcpy(values + natts_anchor, trail_values, follower_natts * sizeof(Datum));
+ memcpy(isnull + natts_anchor, trail_isnull, follower_natts * sizeof(bool));
+
+ result = index_form_tuple(tupdesc, values, isnull);
+
+ /* Patch heap TID: compressed tuple's real heap TID lives in t_tid */
+ BTreeCompressedTupleGetHeapTID(compressed, &heap_tid);
+ result->t_tid = heap_tid;
+
+ if (dstbuf != NULL)
+ {
+ Size sz = IndexTupleSize(result);
+
+ memcpy(dstbuf, result, sz);
+ pfree(result);
+ return (IndexTuple) dstbuf;
+ }
+
+ return result;
+}
+
+/*
+ * _bt_prefix_leading_equalimage() -- is the index eligible for prefix
+ * compression?
+ *
+ * True when the index has >= 2 key attrs and each of the leading
+ * (nkeyatts - 1) key columns is "equalimage" -- i.e. byte-equality of a
+ * stored attr is a sound proxy for opclass equality. That is what lets us
+ * inherit the anchor's leading attrs verbatim for every compressed follower
+ * in the group without collating or round-tripping the values.
+ *
+ * INCLUDE (non-key) attributes do not participate in the prefix and are
+ * never checked here. The trailing key attribute also isn't checked: it is
+ * always stored in full on every follower.
+ */
+bool
+_bt_prefix_leading_equalimage(Relation rel)
+{
+ int nkeyatts = IndexRelationGetNumberOfKeyAttributes(rel);
+
+ if (nkeyatts < 2)
+ return false;
+
+ for (int i = 0; i < nkeyatts - 1; i++)
+ {
+ Oid opfamily = rel->rd_opfamily[i];
+ Oid opcintype = rel->rd_opcintype[i];
+ Oid collation = rel->rd_indcollation[i];
+ Oid equalimageproc;
+
+ equalimageproc = get_opfamily_proc(opfamily, opcintype, opcintype,
+ BTEQUALIMAGE_PROC);
+ if (!OidIsValid(equalimageproc) ||
+ !DatumGetBool(OidFunctionCall1Coll(equalimageproc, collation,
+ ObjectIdGetDatum(opcintype))))
+ return false;
+ }
+
+ return true;
+}
+
+/*
+ * _bt_prefix_build_follower_tupdesc() -- public wrapper for the internal
+ * follower-tupdesc builder. See build_follower_tupdesc() for details.
+ *
+ * Returned TupleDesc covers attrs (natts_anchor+1 .. natts), renumbered to
+ * start at 1. Caller owns the TupleDesc and must free it with
+ * FreeTupleDesc().
+ */
+TupleDesc
+_bt_prefix_build_follower_tupdesc(Relation rel, int natts_anchor)
+{
+ return build_follower_tupdesc(rel, natts_anchor);
+}
+
+/*
+ * _bt_prefix_compress_new_tuple() -- build a compressed follower IndexTuple
+ * from a sorted/insert plain tuple that is known to share the anchor's
+ * leading natts_anchor key attrs.
+ *
+ * full_tupdesc is RelationGetDescr(rel), follower_tupdesc covers just the
+ * trailing attrs (built once by the caller via
+ * _bt_prefix_build_follower_tupdesc). The returned IndexTuple is palloc'd
+ * (or written to dstbuf when non-NULL). Its t_tid carries BT_IS_COMPRESSED
+ * in the upper nibble and the real heap-tuple offset in the low 12 bits.
+ */
+IndexTuple
+_bt_prefix_compress_new_tuple(TupleDesc full_tupdesc,
+ TupleDesc follower_tupdesc,
+ int natts_anchor,
+ IndexTuple new_plain,
+ char *dstbuf)
+{
+ int natts = full_tupdesc->natts;
+ int follower_natts = natts - natts_anchor;
+ Datum values[INDEX_MAX_KEYS];
+ bool isnull[INDEX_MAX_KEYS];
+ Datum trail_values[INDEX_MAX_KEYS];
+ bool trail_isnull[INDEX_MAX_KEYS];
+ IndexTuple result;
+
+ Assert(natts_anchor > 0 && natts_anchor < natts);
+ Assert(!BTreeTupleIsPivot(new_plain));
+ Assert(!BTreeTupleIsPosting(new_plain));
+ Assert(!BTreeTupleIsCompressed(new_plain));
+ Assert(follower_tupdesc->natts == follower_natts);
+
+ /*
+ * Deform new_plain once with the full tupdesc, then repack only the
+ * trailing attrs through the follower tupdesc. This is a tiny bit of
+ * extra work vs. walking just the trailing attrs, but it keeps us honest
+ * about NULL bitmap semantics and handles toasting/compression through
+ * index_form_tuple uniformly.
+ */
+ index_deform_tuple(new_plain, full_tupdesc, values, isnull);
+
+ memcpy(trail_values, values + natts_anchor, follower_natts * sizeof(Datum));
+ memcpy(trail_isnull, isnull + natts_anchor, follower_natts * sizeof(bool));
+
+ result = index_form_tuple(follower_tupdesc, trail_values, trail_isnull);
+
+ /*
+ * Patch t_tid to carry the real heap TID plus BT_IS_COMPRESSED. Caller
+ * passes new_plain's heap TID through new_plain->t_tid (non-ALT form),
+ * which we copy verbatim before setting the status bit.
+ */
+ BTreeCompressedTupleSetHeapTID(result, &new_plain->t_tid);
+
+ if (dstbuf != NULL)
+ {
+ Size sz = IndexTupleSize(result);
+
+ memcpy(dstbuf, result, sz);
+ pfree(result);
+ return (IndexTuple) dstbuf;
+ }
+
+ return result;
+}
+
+/*
+ * _bt_prefix_vacuum_plan() -- plan survivor-follower promotions before VACUUM
+ * applies 'deletable' to a page.
+ *
+ * Walks deletable[] and, for each plain (anchor) tuple that still has at
+ * least one surviving compressed follower (one not also in deletable),
+ * reconstructs that follower's plain form into a freshly-palloc'd
+ * IndexTuple. The returned state describes which offsets must be
+ * PageIndexTupleOverwrite()-written before the main PageIndexMultiDelete
+ * runs.
+ *
+ * This runs OUTSIDE the vacuum path's critical section: the palloc()s inside
+ * _bt_prefix_reconstruct() are disallowed inside a critsec. The subsequent
+ * _bt_prefix_vacuum_apply() performs only MAXALIGN-safe memcpy-sized work
+ * and is safe to call inside the critsec.
+ *
+ * Caller-provided 'promotions' must have room for at least ndeletable
+ * entries. Returns the number of planned promotions. The palloc'd
+ * IndexTuple pointers inside 'promotions' must be pfreed by the caller after
+ * the critsec ends.
+ *
+ * 'deletable' must be sorted ascending (PostgreSQL's vacuum code already
+ * guarantees this).
+ */
+int
+_bt_prefix_vacuum_plan(Relation rel, Page page,
+ const OffsetNumber *deletable, int ndeletable,
+ BTPrefixPromotion *promotions)
+{
+ BTPageOpaque opaque = BTPageGetOpaque(page);
+ OffsetNumber maxoff = PageGetMaxOffsetNumber(page);
+ int nprom = 0;
+
+ if (!P_ISLEAF(opaque) || !P_HAS_PREFIX_COMPRESSION(opaque))
+ return 0;
+
+ for (int i = 0; i < ndeletable; i++)
+ {
+ OffsetNumber anchor_off = deletable[i];
+ IndexTuple anchor;
+ OffsetNumber f;
+ OffsetNumber survivor = InvalidOffsetNumber;
+ BTPrefixPageState rst;
+ IndexTuple plain;
+ int precedingdeletes = 0;
+
+ if (anchor_off < P_FIRSTDATAKEY(opaque) || anchor_off > maxoff)
+ continue;
+
+ anchor = (IndexTuple) PageGetItem(page, PageGetItemId(page, anchor_off));
+ /*
+ * Only plain (non-compressed, non-pivot, non-posting) tuples on a
+ * prefix-compressed leaf page can be anchors.
+ */
+ if (BTreeTupleIsCompressed(anchor) || BTreeTupleIsPivot(anchor) ||
+ BTreeTupleIsPosting(anchor))
+ continue;
+
+ /*
+ * Scan forward from the anchor looking for the first compressed
+ * follower that is NOT itself scheduled for deletion. Stop at the
+ * next plain tuple (group boundary).
+ */
+ for (f = OffsetNumberNext(anchor_off); f <= maxoff;
+ f = OffsetNumberNext(f))
+ {
+ IndexTuple item = (IndexTuple) PageGetItem(page,
+ PageGetItemId(page, f));
+ bool in_deletable = false;
+
+ if (!BTreeTupleIsCompressed(item))
+ break; /* group boundary */
+
+ for (int j = i + 1; j < ndeletable && deletable[j] <= f; j++)
+ {
+ if (deletable[j] == f)
+ {
+ in_deletable = true;
+ break;
+ }
+ }
+ if (!in_deletable)
+ {
+ survivor = f;
+ break;
+ }
+ }
+
+ if (survivor == InvalidOffsetNumber)
+ continue;
+
+ /* Reconstruct into its own palloc'd IndexTuple */
+ memset(&rst, 0, sizeof(rst));
+ _bt_prefix_state_init(&rst, page);
+ plain = _bt_prefix_reconstruct(rel, &rst, survivor, NULL);
+ _bt_prefix_state_cleanup(&rst);
+
+ /*
+ * Record how many deletable[] offsets are strictly less than the
+ * survivor's offset. After PageIndexMultiDelete() runs, the
+ * survivor's offset shifts down by exactly that count -- we need
+ * the post-delete offset to do the promotion PageIndexTupleOverwrite.
+ *
+ * Apply-time ordering (delete-first) is what makes promotion
+ * always fit on the page: deleting the anchor releases at least
+ * plain_sz - follower_sz bytes (the plain form is the full schema
+ * the anchor had; the follower only stored the trailing attr), so
+ * the subsequent overwrite can never run out of space.
+ */
+ for (int j = 0; j < ndeletable; j++)
+ {
+ if (deletable[j] < survivor)
+ precedingdeletes++;
+ else
+ break;
+ }
+
+ promotions[nprom].off = survivor - precedingdeletes;
+ promotions[nprom].plain = plain;
+ promotions[nprom].plainsz = MAXALIGN(IndexTupleSize(plain));
+ nprom++;
+ }
+
+ return nprom;
+}
+
+/*
+ * _bt_prefix_vacuum_apply() -- apply the promotions planned by
+ * _bt_prefix_vacuum_plan().
+ *
+ * Must be called *after* PageIndexMultiDelete() has applied the deletable[]
+ * list, so that the offsets recorded in promotions[] already reflect the
+ * post-delete line-pointer shift. Delete-first ordering is what makes this
+ * routine's space use always-fit by construction, as follows.
+ *
+ * Each promotion grows one follower from size F to plain size P, where
+ * P = header + prefix_keys + suffix_keys + follower_include, and
+ * F = header + suffix_keys + follower_include.
+ * So the growth is (P - F) = prefix_keys_size (within alignment slop). The
+ * anchor whose deletion motivated this promotion had size
+ * A = header + prefix_keys + suffix_keys + anchor_include >= prefix_keys,
+ * so PageIndexMultiDelete() has already freed at least (P - F) bytes per
+ * promotion. Across multiple promotions on the same page the accounting
+ * sums linearly (each promotion has its own deleted anchor contributing
+ * enough bytes), so PageIndexTupleOverwrite() cannot run out of space.
+ *
+ * Hence failure here would indicate either (a) an invariant break in the
+ * planner (e.g. a promotion scheduled without its anchor actually being
+ * deleted), or (b) a broken page. We PANIC because we are inside a critical
+ * section and past the point of no return.
+ *
+ * Safe to call inside a critical section: the palloc()s that build
+ * promotions[i].plain happen in the planner, outside the critsec.
+ */
+void
+_bt_prefix_vacuum_apply(Page page, BTPrefixPromotion *promotions, int nprom)
+{
+ for (int i = 0; i < nprom; i++)
+ {
+ if (!PageIndexTupleOverwrite(page, promotions[i].off,
+ promotions[i].plain,
+ promotions[i].plainsz))
+ elog(PANIC,
+ "failed to promote compressed follower to anchor at offset %u",
+ promotions[i].off);
+ }
+}
+
+/*
+ * _bt_prefix_reconstruct() -- return a plain-looking IndexTuple for offnum.
+ *
+ * If the item at offnum is a plain (non-compressed) tuple, the raw pointer
+ * from PageGetItem() is returned unchanged. If it is a compressed follower,
+ * this function resolves its governing anchor (updating the state as needed)
+ * and materializes a full tuple via _bt_prefix_form_tuple().
+ *
+ * 'dstbuf' behaves as described for _bt_prefix_form_tuple().
+ */
+IndexTuple
+_bt_prefix_reconstruct(Relation rel, BTPrefixPageState *st,
+ OffsetNumber offnum, char *dstbuf)
+{
+ IndexTuple raw;
+
+ Assert(st->page != NULL);
+ Assert(offnum >= FirstOffsetNumber);
+ Assert(offnum <= PageGetMaxOffsetNumber(st->page));
+
+ raw = (IndexTuple) PageGetItem(st->page, PageGetItemId(st->page, offnum));
+
+ if (!BTreeTupleIsCompressed(raw))
+ {
+ /*
+ * Plain anchor (or high key, or internal pivot if caller misused the
+ * helper). Opportunistically refresh the state so subsequent
+ * followers in the same group can skip the find-anchor walk.
+ */
+ if (!BTreeTupleIsPivot(raw) && !BTreeTupleIsPosting(raw) &&
+ offnum != P_HIKEY)
+ _bt_prefix_state_set_anchor(st, offnum);
+ return raw;
+ }
+
+ /* Compressed follower: ensure we have the governing anchor loaded */
+ if (st->anchor_off == InvalidOffsetNumber ||
+ offnum < st->anchor_off ||
+ (st->next_anchor_off != InvalidOffsetNumber &&
+ offnum >= st->next_anchor_off))
+ {
+ OffsetNumber aoff = _bt_prefix_find_anchor(st->page, offnum);
+
+ if (aoff == InvalidOffsetNumber)
+ elog(ERROR, "no prefix anchor for compressed tuple at offset %u",
+ offnum);
+ _bt_prefix_state_set_anchor(st, aoff);
+ }
+
+ /*
+ * Pick up natts_anchor from the relation's schema. For v1, the value is
+ * a per-index constant (= nkeyatts - 1) and the state's natts_anchor is
+ * set here; a future version that records natts_anchor per-anchor can
+ * read it from the anchor itself.
+ */
+ st->natts_anchor = IndexRelationGetNumberOfKeyAttributes(rel) - 1;
+
+ return _bt_prefix_form_tuple(rel, st, raw, dstbuf);
+}
diff --git a/src/backend/access/nbtree/nbtreadpage.c b/src/backend/access/nbtree/nbtreadpage.c
index 55722fd87..052572f05 100644
--- a/src/backend/access/nbtree/nbtreadpage.c
+++ b/src/backend/access/nbtree/nbtreadpage.c
@@ -145,6 +145,9 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
ignore_killed_tuples = scan->ignore_killed_tuples;
int itemIndex,
indnatts;
+ bool prefix_page;
+ BTPrefixPageState prefix_state;
+ char *rbuf = NULL;
/* save the page/buffer block number, along with its sibling links */
page = BufferGetPage(so->currPos.buf);
@@ -169,6 +172,20 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
minoff = P_FIRSTDATAKEY(opaque);
maxoff = PageGetMaxOffsetNumber(page);
+ /*
+ * If the leaf uses prefix compression we must materialize compressed
+ * followers into full IndexTuples before passing them to _bt_checkkeys
+ * and _bt_saveitem. The state and scratch buffer live for the duration
+ * of this _bt_readpage call.
+ */
+ prefix_page = P_HAS_PREFIX_COMPRESSION(opaque);
+ if (prefix_page)
+ {
+ memset(&prefix_state, 0, sizeof(prefix_state));
+ _bt_prefix_state_init(&prefix_state, page);
+ rbuf = palloc(BTMaxItemSize);
+ }
+
/* initialize page-level state that we'll pass to _bt_checkkeys */
pstate.minoff = minoff;
pstate.maxoff = maxoff;
@@ -244,16 +261,36 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
/*
* If the scan specifies not to return killed tuples, then we
- * treat a killed tuple as not passing the qual
+ * treat a killed tuple as not passing the qual.
+ *
+ * On a prefix-compressed leaf we must still track prefix-group
+ * state across LP_DEAD plain tuples: a dead plain tuple is an
+ * anchor for the group that physically follows it on the page,
+ * so surviving compressed followers below must continue to
+ * reconstruct under *it*, not under some earlier anchor whose
+ * keys are different. Silently skipping the anchor would leave
+ * st->anchor_off pointing at a stale (earlier) anchor and give
+ * every subsequent follower the wrong leading attrs.
*/
if (ignore_killed_tuples && ItemIdIsDead(iid))
{
+ if (prefix_page)
+ {
+ IndexTuple deaditup = (IndexTuple) PageGetItem(page, iid);
+
+ if (!BTreeTupleIsCompressed(deaditup) &&
+ !BTreeTupleIsPivot(deaditup) &&
+ !BTreeTupleIsPosting(deaditup))
+ _bt_prefix_state_set_anchor(&prefix_state, offnum);
+ }
offnum = OffsetNumberNext(offnum);
continue;
}
itup = (IndexTuple) PageGetItem(page, iid);
Assert(!BTreeTupleIsPivot(itup));
+ if (prefix_page)
+ itup = _bt_prefix_reconstruct(rel, &prefix_state, offnum, rbuf);
pstate.offnum = offnum;
passes_quals = _bt_checkkeys(scan, &pstate, arrayKeys,
@@ -417,6 +454,8 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
itup = (IndexTuple) PageGetItem(page, iid);
Assert(!BTreeTupleIsPivot(itup));
+ if (prefix_page)
+ itup = _bt_prefix_reconstruct(rel, &prefix_state, offnum, rbuf);
pstate.offnum = offnum;
if (arrayKeys && offnum == minoff && pstate.forcenonrequired)
@@ -524,6 +563,12 @@ _bt_readpage(IndexScanDesc scan, ScanDirection dir, OffsetNumber offnum,
*/
Assert(!pstate.forcenonrequired);
+ if (prefix_page)
+ {
+ _bt_prefix_state_cleanup(&prefix_state);
+ pfree(rbuf);
+ }
+
return (so->currPos.firstItem <= so->currPos.lastItem);
}
@@ -623,8 +668,45 @@ _bt_set_startikey(IndexScanDesc scan, BTReadPageState *pstate)
iid = PageGetItemId(pstate->page, pstate->maxoff);
lasttup = (IndexTuple) PageGetItem(pstate->page, iid);
- /* Determine the first attribute whose values change on caller's page */
- firstchangingattnum = _bt_keep_natts_fast(rel, firsttup, lasttup);
+ /*
+ * On a prefix-compressed leaf page either tuple may be a compressed
+ * follower that _bt_keep_natts_fast cannot consume. Reconstruct into
+ * scratch buffers so the comparison sees plain IndexTuples. The first
+ * data item on a prefix-compressed page is always an anchor, so firsttup
+ * in practice only needs reconstruction for defensiveness.
+ */
+ if (P_HAS_PREFIX_COMPRESSION(BTPageGetOpaque(pstate->page)))
+ {
+ BTPrefixPageState prefix_st;
+ char *fbuf = NULL;
+ char *lbuf = NULL;
+
+ memset(&prefix_st, 0, sizeof(prefix_st));
+ _bt_prefix_state_init(&prefix_st, pstate->page);
+ if (BTreeTupleIsCompressed(firsttup))
+ {
+ fbuf = palloc(BTMaxItemSize);
+ firsttup = _bt_prefix_reconstruct(rel, &prefix_st,
+ pstate->minoff, fbuf);
+ }
+ if (BTreeTupleIsCompressed(lasttup))
+ {
+ lbuf = palloc(BTMaxItemSize);
+ lasttup = _bt_prefix_reconstruct(rel, &prefix_st,
+ pstate->maxoff, lbuf);
+ }
+ firstchangingattnum = _bt_keep_natts_fast(rel, firsttup, lasttup);
+ _bt_prefix_state_cleanup(&prefix_st);
+ if (fbuf)
+ pfree(fbuf);
+ if (lbuf)
+ pfree(lbuf);
+ }
+ else
+ {
+ /* Determine the first attribute whose values change on caller's page */
+ firstchangingattnum = _bt_keep_natts_fast(rel, firsttup, lasttup);
+ }
for (; startikey < so->numberOfKeys; startikey++)
{
@@ -1042,6 +1124,20 @@ _bt_saveitem(BTScanOpaque so, int itemIndex,
if (so->currTuples)
{
Size itupsz = IndexTupleSize(itup);
+ Size needed = so->currPos.nextTupleOffset + MAXALIGN(itupsz);
+
+ if (needed > so->currTuplesSize)
+ {
+ /*
+ * Prefix-compressed pages can reconstruct to more bytes than the
+ * on-disk page holds, so the initial BLCKSZ-sized workspace can
+ * overflow. Grow geometrically to amortize future growth.
+ */
+ Size newsize = Max(so->currTuplesSize * 2, needed);
+
+ so->currTuples = (char *) repalloc(so->currTuples, newsize);
+ so->currTuplesSize = newsize;
+ }
currItem->tupleOffset = so->currPos.nextTupleOffset;
memcpy(so->currTuples + so->currPos.nextTupleOffset, itup, itupsz);
@@ -2088,6 +2184,18 @@ _bt_checkkeys_look_ahead(IndexScanDesc scan, BTReadPageState *pstate,
aheadoffnum = Max((int) pstate->minoff,
(int) pstate->offnum - pstate->targetdistance);
+ /*
+ * Disable the look-ahead optimization on prefix-compressed leaves. A
+ * follower at aheadoffnum has its leading-attr bytes on another item on
+ * the page (its governing anchor), and the array-key comparison below
+ * needs those leading attrs to decide correctness. Reconstructing the
+ * look-ahead tuple is possible but tricky to get right in all edge
+ * cases; simply skipping the optimization here is safe and only costs
+ * us the speculative skip opportunity.
+ */
+ if (P_HAS_PREFIX_COMPRESSION(BTPageGetOpaque(pstate->page)))
+ return;
+
ahead = (IndexTuple) PageGetItem(pstate->page,
PageGetItemId(pstate->page, aheadoffnum));
if (_bt_tuple_before_array_skeys(scan, dir, ahead, tupdesc, tupnatts,
diff --git a/src/backend/access/nbtree/nbtree.c b/src/backend/access/nbtree/nbtree.c
index 0abdd7b49..7afe5f909 100644
--- a/src/backend/access/nbtree/nbtree.c
+++ b/src/backend/access/nbtree/nbtree.c
@@ -183,14 +183,21 @@ void
btbuildempty(Relation index)
{
bool allequalimage = _bt_allequalimage(index, false);
+ bool prefix_compression = _bt_prefix_leading_equalimage(index);
BulkWriteState *bulkstate;
BulkWriteBuffer metabuf;
bulkstate = smgr_bulk_start_rel(index, INIT_FORKNUM);
- /* Construct metapage. */
+ /*
+ * Construct metapage. allequalimage and prefix_compression are mutually
+ * exclusive; when prefix_compression is chosen, clear allequalimage
+ * (dedup is disabled index-wide for prefix-compressed indexes).
+ */
metabuf = smgr_bulk_get_buf(bulkstate);
- _bt_initmetapage((Page) metabuf, P_NONE, 0, allequalimage);
+ _bt_initmetapage((Page) metabuf, P_NONE, 0,
+ allequalimage && !prefix_compression,
+ prefix_compression);
smgr_bulk_write(bulkstate, BTREE_METAPAGE, metabuf, true);
smgr_bulk_finish(bulkstate);
@@ -373,6 +380,7 @@ btbeginscan(Relation rel, int nkeys, int norderbys)
* scan->xs_itupdesc whether we'll need it or not, since that's so cheap.
*/
so->currTuples = so->markTuples = NULL;
+ so->currTuplesSize = so->markTuplesSize = 0;
scan->xs_itupdesc = RelationGetDescr(rel);
@@ -429,14 +437,17 @@ btrescan(IndexScanDesc scan, ScanKey scankey, int nscankeys,
/*
* Allocate tuple workspace arrays, if needed for an index-only scan and
- * not already done in a previous rescan call. To save on palloc
- * overhead, both workspaces are allocated as one palloc block; only this
- * function and btendscan know that.
+ * not already done in a previous rescan call. Each workspace must hold
+ * a full page's worth of tuples; BLCKSZ is the natural starting size, but
+ * prefix compression can produce reconstructed pages larger than BLCKSZ,
+ * so we allow _bt_ensure_tuple_workspace() to grow the buffers on demand.
*/
if (scan->xs_want_itup && so->currTuples == NULL)
{
- so->currTuples = (char *) palloc(BLCKSZ * 2);
- so->markTuples = so->currTuples + BLCKSZ;
+ so->currTuples = (char *) palloc(BLCKSZ);
+ so->markTuples = (char *) palloc(BLCKSZ);
+ so->currTuplesSize = BLCKSZ;
+ so->markTuplesSize = BLCKSZ;
}
/*
@@ -480,7 +491,8 @@ btendscan(IndexScanDesc scan)
pfree(so->killedItems);
if (so->currTuples != NULL)
pfree(so->currTuples);
- /* so->markTuples should not be pfree'd, see btrescan */
+ if (so->markTuples != NULL)
+ pfree(so->markTuples);
pfree(so);
}
@@ -554,8 +566,16 @@ btrestrpos(IndexScanDesc scan)
offsetof(BTScanPosData, items[1]) +
so->markPos.lastItem * sizeof(BTScanPosItem));
if (so->currTuples)
+ {
+ if (so->currTuplesSize < so->markPos.nextTupleOffset)
+ {
+ so->currTuples = (char *) repalloc(so->currTuples,
+ so->markPos.nextTupleOffset);
+ so->currTuplesSize = so->markPos.nextTupleOffset;
+ }
memcpy(so->currTuples, so->markTuples,
so->markPos.nextTupleOffset);
+ }
/* Reset the scan's array keys (see _bt_steppage for why) */
if (so->numArrayKeys)
{
@@ -1575,8 +1595,27 @@ backtrack:
Assert(!BTreeTupleIsPivot(itup));
if (!BTreeTupleIsPosting(itup))
{
- /* Regular tuple, standard table TID representation */
- if (callback(&itup->t_tid, callback_state))
+ ItemPointerData htidbuf;
+ ItemPointer htid;
+
+ /*
+ * Regular tuple, standard table TID representation -- but
+ * if the tuple is a prefix-compressed follower, t_tid
+ * packs BT_IS_COMPRESSED in its upper nibble and the real
+ * heap offset in the low 12 bits. Feeding the raw bytes
+ * to the VACUUM callback would never match any dead heap
+ * TID and we'd silently leak the index entry; decode to
+ * a scratch ItemPointerData first.
+ */
+ if (P_HAS_PREFIX_COMPRESSION(opaque) && BTreeTupleIsCompressed(itup))
+ {
+ BTreeCompressedTupleGetHeapTID(itup, &htidbuf);
+ htid = &htidbuf;
+ }
+ else
+ htid = &itup->t_tid;
+
+ if (callback(htid, callback_state))
{
deletable[ndeletable++] = offnum;
nhtidsdead++;
diff --git a/src/backend/access/nbtree/nbtsearch.c b/src/backend/access/nbtree/nbtsearch.c
index 61f63251c..306385a5e 100644
--- a/src/backend/access/nbtree/nbtsearch.c
+++ b/src/backend/access/nbtree/nbtsearch.c
@@ -701,6 +701,9 @@ _bt_compare(Relation rel,
int ncmpkey;
int ntupatts;
int32 result;
+ BTPrefixPageState prefix_st;
+ bool prefix_st_init = false;
+ char *scratch = NULL;
Assert(_bt_check_natts(rel, key->heapkeyspace, page, offnum));
Assert(key->keysz <= IndexRelationGetNumberOfKeyAttributes(rel));
@@ -714,6 +717,27 @@ _bt_compare(Relation rel,
return 1;
itup = (IndexTuple) PageGetItem(page, PageGetItemId(page, offnum));
+
+ /*
+ * On a prefix-compressed leaf page a follower's leading attrs live on the
+ * governing anchor. Reconstruct the tuple into a scratch buffer so the
+ * rest of this function can treat it as an ordinary IndexTuple. For
+ * compressed tuples the caller-visible heap TID also requires decoding;
+ * we do that below via BTreeCompressedTupleGetHeapTID().
+ */
+ if (P_ISLEAF(opaque) && BTreeTupleIsCompressed(itup))
+ {
+ memset(&prefix_st, 0, sizeof(prefix_st));
+ _bt_prefix_state_init(&prefix_st, page);
+ prefix_st_init = true;
+ scratch = palloc(BTMaxItemSize);
+ /*
+ * _bt_prefix_reconstruct returns a plain-looking IndexTuple whose
+ * t_tid is already demasked into a real heap TID.
+ */
+ itup = _bt_prefix_reconstruct(rel, &prefix_st, offnum, scratch);
+ }
+
ntupatts = BTreeTupleGetNAtts(itup, rel);
/*
@@ -776,7 +800,7 @@ _bt_compare(Relation rel,
/* if the keys are unequal, return the difference */
if (result != 0)
- return result;
+ goto done;
scankey++;
}
@@ -791,7 +815,10 @@ _bt_compare(Relation rel,
* necessary.
*/
if (key->keysz > ntupatts)
- return 1;
+ {
+ result = 1;
+ goto done;
+ }
/*
* Use the heap TID attribute and scantid to try to break the tie. The
@@ -826,10 +853,14 @@ _bt_compare(Relation rel,
*/
if (!key->backward && key->keysz == ntupatts && heapTid == NULL &&
key->heapkeyspace)
- return 1;
+ {
+ result = 1;
+ goto done;
+ }
/* All provided scankey arguments found to be equal */
- return 0;
+ result = 0;
+ goto done;
}
/*
@@ -838,7 +869,10 @@ _bt_compare(Relation rel,
*/
Assert(key->keysz == IndexRelationGetNumberOfKeyAttributes(rel));
if (heapTid == NULL)
- return 1;
+ {
+ result = 1;
+ goto done;
+ }
/*
* Scankey must be treated as equal to a posting list tuple if its scantid
@@ -849,16 +883,27 @@ _bt_compare(Relation rel,
Assert(ntupatts >= IndexRelationGetNumberOfKeyAttributes(rel));
result = ItemPointerCompare(key->scantid, heapTid);
if (result <= 0 || !BTreeTupleIsPosting(itup))
- return result;
+ goto done;
else
{
result = ItemPointerCompare(key->scantid,
BTreeTupleGetMaxHeapTID(itup));
if (result > 0)
- return 1;
+ {
+ result = 1;
+ goto done;
+ }
}
- return 0;
+ result = 0;
+
+done:
+ if (prefix_st_init)
+ {
+ _bt_prefix_state_cleanup(&prefix_st);
+ pfree(scratch);
+ }
+ return result;
}
/*
@@ -1441,7 +1486,8 @@ _bt_first(IndexScanDesc scan, ScanDirection dir)
* page _bt_search returns).
*----------
*/
- _bt_metaversion(rel, &inskey.heapkeyspace, &inskey.allequalimage);
+ _bt_metaversion(rel, &inskey.heapkeyspace, &inskey.allequalimage,
+ &inskey.prefix_compression);
inskey.anynullkeys = false; /* unused */
inskey.scantid = NULL;
inskey.keysz = keysz;
@@ -1676,8 +1722,16 @@ _bt_steppage(IndexScanDesc scan, ScanDirection dir)
offsetof(BTScanPosData, items[1]) +
so->currPos.lastItem * sizeof(BTScanPosItem));
if (so->markTuples)
+ {
+ if (so->markTuplesSize < so->currPos.nextTupleOffset)
+ {
+ so->markTuples = (char *) repalloc(so->markTuples,
+ so->currPos.nextTupleOffset);
+ so->markTuplesSize = so->currPos.nextTupleOffset;
+ }
memcpy(so->markTuples, so->currTuples,
so->currPos.nextTupleOffset);
+ }
so->markPos.itemIndex = so->markItemIndex;
so->markItemIndex = -1;
diff --git a/src/backend/access/nbtree/nbtsort.c b/src/backend/access/nbtree/nbtsort.c
index 756dfa3dc..8779bd5ad 100644
--- a/src/backend/access/nbtree/nbtsort.c
+++ b/src/backend/access/nbtree/nbtsort.c
@@ -242,6 +242,10 @@ typedef struct BTPageState
uint32 btps_level; /* tree level (0 = leaf) */
Size btps_full; /* "full" if less than this much free space */
struct BTPageState *btps_next; /* link to parent level, if any */
+ /* Prefix-compression bookkeeping (leaf level only) */
+ bool btps_prefix_compression; /* produce compressed followers? */
+ OffsetNumber btps_anchor_off; /* offset of current anchor on btps_buf */
+ TupleDesc btps_follower_tupdesc; /* trailing-attrs desc, lazily built */
} BTPageState;
/*
@@ -254,6 +258,7 @@ typedef struct BTWriteState
BulkWriteState *bulkstate;
BTScanInsert inskey; /* generic insertion scankey */
BlockNumber btws_pages_alloced; /* # pages allocated */
+ bool prefix_compression; /* emit prefix-compressed leaf pages? */
} BTWriteState;
@@ -568,6 +573,16 @@ _bt_leafbuild(BTSpool *btspool, BTSpool *btspool2)
/* _bt_mkscankey() won't set allequalimage without metapage */
wstate.inskey->allequalimage = _bt_allequalimage(wstate.index, true);
+ /*
+ * Decide whether this build produces prefix-compressed leaf pages. An
+ * index that qualifies is automatically opted in and opts out of posting
+ * list deduplication for the duration of its life (they are mutually
+ * exclusive on disk, so both metapage flags must never be true together).
+ */
+ wstate.prefix_compression = _bt_prefix_leading_equalimage(wstate.index);
+ if (wstate.prefix_compression)
+ wstate.inskey->allequalimage = false;
+
/* reserve the metapage */
wstate.btws_pages_alloced = BTREE_METAPAGE + 1;
@@ -673,6 +688,18 @@ _bt_pagestate(BTWriteState *wstate, uint32 level)
/* no parent level, yet */
state->btps_next = NULL;
+ /* Prefix compression applies only at the leaf level */
+ state->btps_prefix_compression = (level == 0 && wstate->prefix_compression);
+ state->btps_anchor_off = InvalidOffsetNumber;
+ state->btps_follower_tupdesc = NULL;
+ if (state->btps_prefix_compression)
+ {
+ /* Mark the initial leaf page */
+ BTPageOpaque opq = BTPageGetOpaque((Page) state->btps_buf);
+
+ opq->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION;
+ }
+
return state;
}
@@ -797,6 +824,9 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
Size pgspc;
Size itupsz;
bool isleaf;
+ IndexTuple emit_tup; /* tuple as it will be written (possibly compressed) */
+ char *emit_scratch = NULL; /* scratch for compressed form */
+ bool emit_is_compressed = false;
/*
* This is a handy place to check for cancel interrupts during the btree
@@ -811,12 +841,49 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
last_truncextra = state->btps_lastextra;
state->btps_lastextra = truncextra;
- pgspc = PageGetFreeSpace(npage);
- itupsz = IndexTupleSize(itup);
- itupsz = MAXALIGN(itupsz);
/* Leaf case has slightly different rules due to suffix truncation */
isleaf = (state->btps_level == 0);
+ /*
+ * If this leaf-level build is prefix-compressing and the incoming tuple
+ * shares the current anchor's leading (nkeyatts-1) key attrs, emit it as
+ * a compressed follower. Otherwise emit it plain (and record it as a
+ * new anchor after insertion). The first tuple on any page is always
+ * emitted plain, per invariant #1 (leftmost data item is always an
+ * anchor).
+ */
+ emit_tup = itup;
+ if (isleaf && state->btps_prefix_compression &&
+ last_off >= P_FIRSTKEY &&
+ state->btps_anchor_off != InvalidOffsetNumber)
+ {
+ IndexTuple anchor;
+ int natts_anchor =
+ IndexRelationGetNumberOfKeyAttributes(wstate->index) - 1;
+
+ anchor = (IndexTuple) PageGetItem(npage,
+ PageGetItemId(npage,
+ state->btps_anchor_off));
+ if (_bt_keep_natts_fast(wstate->index, anchor, itup) > natts_anchor)
+ {
+ if (state->btps_follower_tupdesc == NULL)
+ state->btps_follower_tupdesc =
+ _bt_prefix_build_follower_tupdesc(wstate->index,
+ natts_anchor);
+ emit_scratch = palloc(BTMaxItemSize);
+ emit_tup = _bt_prefix_compress_new_tuple(RelationGetDescr(wstate->index),
+ state->btps_follower_tupdesc,
+ natts_anchor,
+ itup,
+ emit_scratch);
+ emit_is_compressed = true;
+ }
+ }
+
+ pgspc = PageGetFreeSpace(npage);
+ itupsz = IndexTupleSize(emit_tup);
+ itupsz = MAXALIGN(itupsz);
+
/*
* Check whether the new item can fit on a btree page on current level at
* all.
@@ -867,11 +934,21 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
ItemId ii;
ItemId hii;
IndexTuple oitup;
+ char *oitup_scratch = NULL; /* set when last item was compressed */
+ Size oitup_sz;
/* Create new page of same level */
nbuf = _bt_blnewpage(wstate, state->btps_level);
npage = (Page) nbuf;
+ /* Carry BTP_HAS_PREFIX_COMPRESSION onto the new leaf page */
+ if (isleaf && state->btps_prefix_compression)
+ {
+ BTPageOpaque nopq = BTPageGetOpaque(npage);
+
+ nopq->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION;
+ }
+
/* and assign it a page position */
nblkno = wstate->btws_pages_alloced++;
@@ -885,8 +962,31 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
Assert(last_off > P_FIRSTKEY);
ii = PageGetItemId(opage, last_off);
oitup = (IndexTuple) PageGetItem(opage, ii);
- _bt_sortaddtup(npage, ItemIdGetLength(ii), oitup, P_FIRSTKEY,
- !isleaf);
+ oitup_sz = ItemIdGetLength(ii);
+
+ /*
+ * If the last-left tuple is a compressed follower, reconstruct it to
+ * its plain form for use both as npage's first data item (an anchor)
+ * and as _bt_truncate's firstright input. The opage high key below
+ * will be generated from the plain form too.
+ */
+ if (isleaf && state->btps_prefix_compression &&
+ BTreeTupleIsCompressed(oitup))
+ {
+ BTPrefixPageState rst;
+ IndexTuple reconstructed;
+
+ memset(&rst, 0, sizeof(rst));
+ _bt_prefix_state_init(&rst, opage);
+ oitup_scratch = palloc(BTMaxItemSize);
+ reconstructed = _bt_prefix_reconstruct(wstate->index, &rst,
+ last_off, oitup_scratch);
+ _bt_prefix_state_cleanup(&rst);
+ oitup = reconstructed;
+ oitup_sz = IndexTupleSize(oitup);
+ }
+
+ _bt_sortaddtup(npage, oitup_sz, oitup, P_FIRSTKEY, !isleaf);
/*
* Move 'last' into the high key position on opage. _bt_blnewpage()
@@ -906,6 +1006,7 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
{
IndexTuple lastleft;
IndexTuple truncated;
+ char *lastleft_scratch = NULL;
/*
* Truncate away any unneeded attributes from high key on leaf
@@ -933,18 +1034,44 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
ii = PageGetItemId(opage, OffsetNumberPrev(last_off));
lastleft = (IndexTuple) PageGetItem(opage, ii);
+ /* Reconstruct a compressed lastleft before handing it to _bt_truncate */
+ if (state->btps_prefix_compression &&
+ BTreeTupleIsCompressed(lastleft))
+ {
+ BTPrefixPageState rst;
+
+ memset(&rst, 0, sizeof(rst));
+ _bt_prefix_state_init(&rst, opage);
+ lastleft_scratch = palloc(BTMaxItemSize);
+ lastleft = _bt_prefix_reconstruct(wstate->index, &rst,
+ OffsetNumberPrev(last_off),
+ lastleft_scratch);
+ _bt_prefix_state_cleanup(&rst);
+ }
+
Assert(IndexTupleSize(oitup) > last_truncextra);
truncated = _bt_truncate(wstate->index, lastleft, oitup,
wstate->inskey);
if (!PageIndexTupleOverwrite(opage, P_HIKEY, truncated, IndexTupleSize(truncated)))
elog(ERROR, "failed to add high key to the index page");
pfree(truncated);
+ if (lastleft_scratch != NULL)
+ pfree(lastleft_scratch);
/* oitup should continue to point to the page's high key */
hii = PageGetItemId(opage, P_HIKEY);
oitup = (IndexTuple) PageGetItem(opage, hii);
}
+ /*
+ * oitup_scratch (when set) held the reconstructed plain form used as
+ * npage's new P_FIRSTKEY and as _bt_truncate's firstright. The
+ * bytes have been copied into their on-page homes; the scratch is no
+ * longer needed.
+ */
+ if (oitup_scratch != NULL)
+ pfree(oitup_scratch);
+
/*
* Link the old page into its parent, using its low key. If we don't
* have a parent, we have to create one; this adds a new btree level.
@@ -987,9 +1114,12 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
_bt_blwritepage(wstate, obuf, oblkno);
/*
- * Reset last_off to point to new page
+ * Reset last_off to point to new page. The item we just placed at
+ * P_FIRSTKEY is the new anchor (plain form) for npage.
*/
last_off = P_FIRSTKEY;
+ if (isleaf && state->btps_prefix_compression)
+ state->btps_anchor_off = P_FIRSTKEY;
}
/*
@@ -1011,12 +1141,26 @@ _bt_buildadd(BTWriteState *wstate, BTPageState *state, IndexTuple itup,
}
/*
- * Add the new item into the current page.
+ * Add the new item into the current page. emit_tup may be a compressed
+ * follower derived from itup (on leaf prefix-compressed builds); the
+ * plain path always has emit_tup == itup.
*/
last_off = OffsetNumberNext(last_off);
- _bt_sortaddtup(npage, itupsz, itup, last_off,
+ _bt_sortaddtup(npage, itupsz, emit_tup, last_off,
!isleaf && last_off == P_FIRSTKEY);
+ /*
+ * Anchor bookkeeping: the first tuple on a leaf page is always emitted
+ * plain and becomes that page's first anchor; any subsequent plain
+ * (non-compressed) tuple starts a new prefix group and becomes its
+ * anchor. Compressed followers leave btps_anchor_off unchanged.
+ */
+ if (isleaf && state->btps_prefix_compression && !emit_is_compressed)
+ state->btps_anchor_off = last_off;
+
+ if (emit_scratch != NULL)
+ pfree(emit_scratch);
+
state->btps_buf = nbuf;
state->btps_blkno = nblkno;
state->btps_lastoff = last_off;
@@ -1119,6 +1263,18 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
s->btps_buf = NULL; /* writepage took ownership of the buffer */
}
+ /*
+ * Free any cached prefix-compression state held on each page state.
+ */
+ for (s = state; s != NULL; s = s->btps_next)
+ {
+ if (s->btps_follower_tupdesc != NULL)
+ {
+ FreeTupleDesc(s->btps_follower_tupdesc);
+ s->btps_follower_tupdesc = NULL;
+ }
+ }
+
/*
* As the last step in the process, construct the metapage and make it
* point to the new root (unless we had no data at all, in which case it's
@@ -1127,7 +1283,9 @@ _bt_uppershutdown(BTWriteState *wstate, BTPageState *state)
*/
metabuf = smgr_bulk_get_buf(wstate->bulkstate);
_bt_initmetapage((Page) metabuf, rootblkno, rootlevel,
- wstate->inskey->allequalimage);
+ wstate->inskey->allequalimage &&
+ !wstate->prefix_compression,
+ wstate->prefix_compression);
_bt_blwritepage(wstate, metabuf, BTREE_METAPAGE);
}
diff --git a/src/backend/access/nbtree/nbtsplitloc.c b/src/backend/access/nbtree/nbtsplitloc.c
index de9eca3c8..a9f7478ab 100644
--- a/src/backend/access/nbtree/nbtsplitloc.c
+++ b/src/backend/access/nbtree/nbtsplitloc.c
@@ -58,8 +58,95 @@ typedef struct
int nsplits; /* current number of splits */
SplitPoint *splits; /* all candidate split points for page */
int interval; /* current range of acceptable split points */
+
+ /*
+ * Prefix-compression materialization state. Only initialized (and only
+ * non-NULL scratch buffers) when origpage is BTP_HAS_PREFIX_COMPRESSION.
+ * Lets _bt_split_keep_natts_fast() reconstruct compressed followers into
+ * plain tuples before handing them to _bt_keep_natts_fast(), which assumes
+ * full-tupdesc tuples.
+ */
+ bool prefix_page;
+ BTPrefixPageState prefix_state;
+ char *lbuf; /* scratch for reconstructed lastleft */
+ char *rbuf; /* scratch for reconstructed firstright */
} FindSplitData;
+/*
+ * Materialize a tuple that may be a compressed follower into plain form for
+ * the benefit of _bt_keep_natts_fast(). Non-compressed tuples are returned
+ * as-is. The caller-provided scratch buffer is used so that we don't need to
+ * palloc per call.
+ *
+ * offnum must be the offset of 'tup' on state->origpage when tup came from
+ * the page, or InvalidOffsetNumber if tup is newitem (in which case tup is
+ * guaranteed not to be compressed).
+ */
+static inline IndexTuple
+_bt_split_materialize(FindSplitData *state, IndexTuple tup,
+ OffsetNumber offnum, char *scratch)
+{
+ if (!state->prefix_page)
+ return tup;
+ if (!BTreeTupleIsCompressed(tup))
+ return tup;
+ Assert(offnum >= FirstOffsetNumber);
+ return _bt_prefix_reconstruct(state->rel, &state->prefix_state,
+ offnum, scratch);
+}
+
+/*
+ * Wrapper around _bt_keep_natts_fast() that accepts compressed followers
+ * from state->origpage. Either or both of lastleftoff/firstrightoff can be
+ * InvalidOffsetNumber when the corresponding tuple is newitem (plain).
+ */
+static inline int
+_bt_split_keep_natts_fast(FindSplitData *state,
+ IndexTuple lastleft, OffsetNumber lastleftoff,
+ IndexTuple firstright, OffsetNumber firstrightoff)
+{
+ lastleft = _bt_split_materialize(state, lastleft, lastleftoff, state->lbuf);
+ firstright = _bt_split_materialize(state, firstright, firstrightoff, state->rbuf);
+ return _bt_keep_natts_fast(state->rel, lastleft, firstright);
+}
+
+/*
+ * Like _bt_split_lastleft()/_bt_split_firstright() but also returns the
+ * on-origpage offset via *off (InvalidOffsetNumber for newitem). Used by
+ * _bt_split_keep_natts_fast() to reconstruct a compressed follower.
+ */
+static inline IndexTuple
+_bt_split_lastleft_off(FindSplitData *state, SplitPoint *split,
+ OffsetNumber *off)
+{
+ ItemId itemid;
+
+ if (split->newitemonleft && split->firstrightoff == state->newitemoff)
+ {
+ *off = InvalidOffsetNumber;
+ return state->newitem;
+ }
+ *off = OffsetNumberPrev(split->firstrightoff);
+ itemid = PageGetItemId(state->origpage, *off);
+ return (IndexTuple) PageGetItem(state->origpage, itemid);
+}
+
+static inline IndexTuple
+_bt_split_firstright_off(FindSplitData *state, SplitPoint *split,
+ OffsetNumber *off)
+{
+ ItemId itemid;
+
+ if (!split->newitemonleft && split->firstrightoff == state->newitemoff)
+ {
+ *off = InvalidOffsetNumber;
+ return state->newitem;
+ }
+ *off = split->firstrightoff;
+ itemid = PageGetItemId(state->origpage, split->firstrightoff);
+ return (IndexTuple) PageGetItem(state->origpage, itemid);
+}
+
static void _bt_recsplitloc(FindSplitData *state,
OffsetNumber firstrightoff, bool newitemonleft,
int olddataitemstoleft,
@@ -200,6 +287,24 @@ _bt_findsplitloc(Relation rel,
state.splits = palloc_array(SplitPoint, state.maxsplits);
state.nsplits = 0;
+ /*
+ * Set up prefix-compression reconstruction state if the origpage is
+ * prefix-compressed. Without this, _bt_keep_natts_fast cannot evaluate
+ * same-group prefix matches between candidate lastleft and firstright
+ * tuples and suffix truncation would be degraded (the "no common prefix
+ * identified" fallback kicks in every time).
+ */
+ state.prefix_page = state.is_leaf && P_HAS_PREFIX_COMPRESSION(opaque);
+ state.lbuf = NULL;
+ state.rbuf = NULL;
+ if (state.prefix_page)
+ {
+ memset(&state.prefix_state, 0, sizeof(state.prefix_state));
+ _bt_prefix_state_init(&state.prefix_state, origpage);
+ state.lbuf = palloc(BTMaxItemSize);
+ state.rbuf = palloc(BTMaxItemSize);
+ }
+
/*
* Scan through the data items and calculate space usage for a split at
* each possible position
@@ -314,6 +419,12 @@ _bt_findsplitloc(Relation rel,
newitemoff == split->firstrightoff)
{
pfree(state.splits);
+ if (state.lbuf)
+ pfree(state.lbuf);
+ if (state.rbuf)
+ pfree(state.rbuf);
+ if (state.prefix_page)
+ _bt_prefix_state_cleanup(&state.prefix_state);
*newitemonleft = true;
return newitemoff;
}
@@ -424,6 +535,12 @@ _bt_findsplitloc(Relation rel,
firstrightoff = _bt_bestsplitloc(&state, perfectpenalty, newitemonleft,
strategy);
pfree(state.splits);
+ if (state.lbuf)
+ pfree(state.lbuf);
+ if (state.rbuf)
+ pfree(state.rbuf);
+ if (state.prefix_page)
+ _bt_prefix_state_cleanup(&state.prefix_state);
return firstrightoff;
}
@@ -659,11 +776,17 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
* non-pivot tuples must be the same width as well. (Note that the
* possibly-truncated existing high key isn't counted in
* olddataitemstotal, and must be subtracted from maxoff.)
+ *
+ * XXX Disable this for the benefit of prefix compression, since we cannot
+ * expect TPC-C indexes to have uniform tuple size anymore. Revisit the
+ * trade-offs here.
*/
+#if 0
if (state->newitemsz != state->minfirstrightsz)
return false;
if (state->newitemsz * (maxoff - 1) != state->olddataitemstotal)
return false;
+#endif
/*
* Avoid applying optimization when tuples are wider than a tuple
@@ -688,7 +811,9 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
{
itemid = PageGetItemId(state->origpage, maxoff);
tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ keepnatts = _bt_split_keep_natts_fast(state, tup, maxoff,
+ state->newitem,
+ InvalidOffsetNumber);
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -712,14 +837,28 @@ _bt_afternewitemoff(FindSplitData *state, OffsetNumber maxoff,
* optimization. Besides, all inappropriate cases triggered here will
* still split in the middle of the page on average.
*/
- itemid = PageGetItemId(state->origpage, OffsetNumberPrev(state->newitemoff));
- tup = (IndexTuple) PageGetItem(state->origpage, itemid);
- /* Do cheaper test first */
- if (BTreeTupleIsPosting(tup) ||
- !_bt_adjacenthtid(&tup->t_tid, &state->newitem->t_tid))
- return false;
- /* Check same conditions as rightmost item case, too */
- keepnatts = _bt_keep_natts_fast(state->rel, tup, state->newitem);
+ {
+ OffsetNumber tup_off = OffsetNumberPrev(state->newitemoff);
+ ItemPointerData left_htid;
+
+ itemid = PageGetItemId(state->origpage, tup_off);
+ tup = (IndexTuple) PageGetItem(state->origpage, itemid);
+ /* Do cheaper test first */
+ if (BTreeTupleIsPosting(tup))
+ return false;
+
+ if (BTreeTupleIsCompressed(tup))
+ BTreeCompressedTupleGetHeapTID(tup, &left_htid);
+ else
+ left_htid = tup->t_tid;
+ if (!_bt_adjacenthtid(&left_htid, &state->newitem->t_tid))
+ return false;
+
+ /* Check same conditions as rightmost item case, too */
+ keepnatts = _bt_split_keep_natts_fast(state, tup, tup_off,
+ state->newitem,
+ InvalidOffsetNumber);
+ }
if (keepnatts > 1 && keepnatts <= nkeyatts)
{
@@ -937,6 +1076,8 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage,
{
IndexTuple leftmost,
rightmost;
+ OffsetNumber leftmost_off,
+ rightmost_off;
SplitPoint *leftinterval,
*rightinterval;
int perfectpenalty;
@@ -960,15 +1101,17 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage,
* current split interval
*/
_bt_interval_edges(state, &leftinterval, &rightinterval);
- leftmost = _bt_split_lastleft(state, leftinterval);
- rightmost = _bt_split_firstright(state, rightinterval);
+ leftmost = _bt_split_lastleft_off(state, leftinterval, &leftmost_off);
+ rightmost = _bt_split_firstright_off(state, rightinterval, &rightmost_off);
/*
* If initial split interval can produce a split point that will at least
* avoid appending a heap TID in new high key, we're done. Finish split
* with default strategy and initial split interval.
*/
- perfectpenalty = _bt_keep_natts_fast(state->rel, leftmost, rightmost);
+ perfectpenalty = _bt_split_keep_natts_fast(state,
+ leftmost, leftmost_off,
+ rightmost, rightmost_off);
if (perfectpenalty <= indnkeyatts)
return perfectpenalty;
@@ -980,8 +1123,8 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage,
* Use the leftmost split's lastleft tuple and the rightmost split's
* firstright tuple to assess every possible split.
*/
- leftmost = _bt_split_lastleft(state, leftpage);
- rightmost = _bt_split_firstright(state, rightpage);
+ leftmost = _bt_split_lastleft_off(state, leftpage, &leftmost_off);
+ rightmost = _bt_split_firstright_off(state, rightpage, &rightmost_off);
/*
* If page (including new item) has many duplicates but is not entirely
@@ -989,7 +1132,9 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage,
* If page is entirely full of duplicates, a single value strategy split
* will be performed.
*/
- perfectpenalty = _bt_keep_natts_fast(state->rel, leftmost, rightmost);
+ perfectpenalty = _bt_split_keep_natts_fast(state,
+ leftmost, leftmost_off,
+ rightmost, rightmost_off);
if (perfectpenalty <= indnkeyatts)
{
*strategy = SPLIT_MANY_DUPLICATES;
@@ -1027,6 +1172,7 @@ _bt_strategy(FindSplitData *state, SplitPoint *leftpage,
itemid = PageGetItemId(state->origpage, P_HIKEY);
hikey = (IndexTuple) PageGetItem(state->origpage, itemid);
+ /* hikey is a pivot, never compressed; newitem is plain */
perfectpenalty = _bt_keep_natts_fast(state->rel, hikey,
state->newitem);
if (perfectpenalty <= indnkeyatts)
@@ -1147,10 +1293,17 @@ _bt_split_penalty(FindSplitData *state, SplitPoint *split)
return MAXALIGN(ItemIdGetLength(itemid)) + sizeof(ItemIdData);
}
- lastleft = _bt_split_lastleft(state, split);
- firstright = _bt_split_firstright(state, split);
+ {
+ OffsetNumber lastleft_off;
+ OffsetNumber firstright_off;
- return _bt_keep_natts_fast(state->rel, lastleft, firstright);
+ lastleft = _bt_split_lastleft_off(state, split, &lastleft_off);
+ firstright = _bt_split_firstright_off(state, split, &firstright_off);
+
+ return _bt_split_keep_natts_fast(state,
+ lastleft, lastleft_off,
+ firstright, firstright_off);
+ }
}
/*
diff --git a/src/backend/access/nbtree/nbtutils.c b/src/backend/access/nbtree/nbtutils.c
index 85c1b6325..b74b53b3c 100644
--- a/src/backend/access/nbtree/nbtutils.c
+++ b/src/backend/access/nbtree/nbtutils.c
@@ -83,12 +83,14 @@ _bt_mkscankey(Relation rel, IndexTuple itup)
key = palloc(offsetof(BTScanInsertData, scankeys) +
sizeof(ScanKeyData) * indnkeyatts);
if (itup)
- _bt_metaversion(rel, &key->heapkeyspace, &key->allequalimage);
+ _bt_metaversion(rel, &key->heapkeyspace, &key->allequalimage,
+ &key->prefix_compression);
else
{
/* Utility statement callers can set these fields themselves */
key->heapkeyspace = true;
key->allequalimage = false;
+ key->prefix_compression = false;
}
key->anynullkeys = false; /* initial assumption */
key->nextkey = false; /* usual case, required by btinsert */
@@ -333,8 +335,23 @@ _bt_killitems(IndexScanDesc scan)
if (j == nposting)
killtuple = true;
}
- else if (ItemPointerEquals(&ituple->t_tid, &kitem->heapTid))
- killtuple = true;
+ else
+ {
+ /*
+ * Plain leaf tuple (or a prefix-compressed follower -- whose
+ * t_tid.ip_posid carries BT_IS_COMPRESSED in its upper nibble).
+ * Decode the follower's real heap TID before comparing; the
+ * scan recorded kitem->heapTid in its clean, demasked form.
+ */
+ ItemPointerData htid;
+
+ if (BTreeTupleIsCompressed(ituple))
+ BTreeCompressedTupleGetHeapTID(ituple, &htid);
+ else
+ htid = ituple->t_tid;
+ if (ItemPointerEquals(&htid, &kitem->heapTid))
+ killtuple = true;
+ }
/*
* Mark index item as dead, if it isn't already. Since this
@@ -914,6 +931,17 @@ _bt_keep_natts_fast(Relation rel, IndexTuple lastleft, IndexTuple firstright)
int keysz = IndexRelationGetNumberOfKeyAttributes(rel);
int keepnatts;
+ /*
+ * index_getattr with the full tupdesc would walk off the end of a
+ * compressed follower's body and trip the indextuple.c fast-path
+ * assertion. Callers that work with on-page tuples must reconstruct
+ * compressed followers before calling (see _bt_split_keep_natts_fast()
+ * in nbtsplitloc.c for the split path, and the P_HAS_PREFIX_COMPRESSION
+ * guard in _bt_set_startikey()).
+ */
+ Assert(!BTreeTupleIsCompressed(lastleft));
+ Assert(!BTreeTupleIsCompressed(firstright));
+
keepnatts = 1;
for (int attnum = 1; attnum <= keysz; attnum++)
{
@@ -990,6 +1018,29 @@ _bt_check_natts(Relation rel, bool heapkeyspace, Page page, OffsetNumber offnum)
if (natts != nkeyatts && BTreeTupleIsPosting(itup))
return false;
+ /* Compressed followers only appear on prefix-compressed leaf pages */
+ if (BTreeTupleIsCompressed(itup))
+ {
+ if (!P_ISLEAF(opaque))
+ return false;
+ if (!P_HAS_PREFIX_COMPRESSION(opaque))
+ return false;
+ if (offnum < P_FIRSTDATAKEY(opaque))
+ return false; /* never a high key */
+ /* Mutually exclusive with posting-list status */
+ if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) & BT_IS_POSTING) != 0)
+ return false;
+ /* BT_PIVOT_HEAP_TID_ATTR is a pivot-only bit */
+ if ((ItemPointerGetOffsetNumberNoCheck(&itup->t_tid) &
+ BT_PIVOT_HEAP_TID_ATTR) != 0)
+ return false;
+ }
+
+ /* Posting tuples are forbidden on prefix-compressed leaf pages */
+ if (BTreeTupleIsPosting(itup) && P_ISLEAF(opaque) &&
+ P_HAS_PREFIX_COMPRESSION(opaque))
+ return false;
+
if (P_ISLEAF(opaque))
{
if (offnum >= P_FIRSTDATAKEY(opaque))
@@ -1004,8 +1055,8 @@ _bt_check_natts(Relation rel, bool heapkeyspace, Page page, OffsetNumber offnum)
/*
* Leaf tuples that are not the page high key (non-pivot tuples)
* should never be truncated. (Note that tupnatts must have been
- * inferred, even with a posting list tuple, because only pivot
- * tuples store tupnatts directly.)
+ * inferred, even with a posting list tuple or a prefix-compressed
+ * follower, because only pivot tuples store tupnatts directly.)
*/
return tupnatts == natts;
}
diff --git a/src/backend/access/nbtree/nbtxlog.c b/src/backend/access/nbtree/nbtxlog.c
index dff7d286f..74554c5f7 100644
--- a/src/backend/access/nbtree/nbtxlog.c
+++ b/src/backend/access/nbtree/nbtxlog.c
@@ -110,6 +110,8 @@ _bt_restore_meta(XLogReaderState *record, uint8 block_id)
md->btm_last_cleanup_num_delpages = xlrec->last_cleanup_num_delpages;
md->btm_last_cleanup_num_heap_tuples = -1.0;
md->btm_allequalimage = xlrec->allequalimage;
+ md->btm_prefix_compression = xlrec->prefix_compression;
+ Assert(!(md->btm_allequalimage && md->btm_prefix_compression));
pageop = BTPageGetOpaque(metapg);
pageop->btpo_flags = BTP_META;
@@ -291,7 +293,12 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
ropaque->btpo_prev = origpagenumber;
ropaque->btpo_next = spagenumber;
ropaque->btpo_level = xlrec->level;
- ropaque->btpo_flags = isleaf ? BTP_LEAF : 0;
+ /*
+ * Carry forward BTP_LEAF and BTP_HAS_PREFIX_COMPRESSION (plus any future
+ * flags added to BTP_SPLIT_PRESERVED_FLAGS) from origpage. Non-leaf
+ * origpages have no BTP_LEAF to carry, but the mask handles that too.
+ */
+ ropaque->btpo_flags = xlrec->origflags & BTP_SPLIT_PRESERVED_FLAGS;
ropaque->btpo_cycleid = 0;
_bt_restore_page(rpage, datapos, datalen);
@@ -411,10 +418,17 @@ btree_xlog_split(bool newitemonleft, XLogReaderState *record)
PageRestoreTempPage(leftpage, origpage);
- /* Fix opaque fields */
- oopaque->btpo_flags = BTP_INCOMPLETE_SPLIT;
- if (isleaf)
- oopaque->btpo_flags |= BTP_LEAF;
+ /*
+ * Fix opaque fields. Preserve flags that survive a split on the
+ * primary (BTP_LEAF, BTP_HAS_PREFIX_COMPRESSION -- see
+ * BTP_SPLIT_PRESERVED_FLAGS) and then set BTP_INCOMPLETE_SPLIT on
+ * top. Without the mask, compressed followers on a prefix leaf
+ * would be left sitting on a page whose BTP_HAS_PREFIX_COMPRESSION
+ * flag got cleared by replay, breaking scans that gate
+ * reconstruction on the flag.
+ */
+ oopaque->btpo_flags = (xlrec->origflags & BTP_SPLIT_PRESERVED_FLAGS) |
+ BTP_INCOMPLETE_SPLIT;
oopaque->btpo_next = rightpagenumber;
oopaque->btpo_cycleid = 0;
@@ -944,7 +958,24 @@ btree_xlog_newroot(XLogReaderState *record)
pageop->btpo_prev = pageop->btpo_next = P_NONE;
pageop->btpo_level = xlrec->level;
if (xlrec->level == 0)
+ {
+ xl_btree_metadata *md;
+ Size mdlen;
+
pageop->btpo_flags |= BTP_LEAF;
+
+ /*
+ * On the primary _bt_getroot() marks the new leaf root with
+ * BTP_HAS_PREFIX_COMPRESSION when the index opts in. Replay must
+ * mirror that so standbys keep producing compressed pages. The
+ * relevant bit is on the metapage record regardless of level, so
+ * peek at it here before _bt_restore_meta runs below.
+ */
+ md = (xl_btree_metadata *) XLogRecGetBlockData(record, 2, &mdlen);
+ Assert(mdlen == sizeof(xl_btree_metadata));
+ if (md->prefix_compression)
+ pageop->btpo_flags |= BTP_HAS_PREFIX_COMPRESSION;
+ }
pageop->btpo_cycleid = 0;
if (xlrec->level > 0)
diff --git a/contrib/amcheck/verify_nbtree.c b/contrib/amcheck/verify_nbtree.c
index 97c519b47..96a4d1f02 100644
--- a/contrib/amcheck/verify_nbtree.c
+++ b/contrib/amcheck/verify_nbtree.c
@@ -312,7 +312,8 @@ bt_index_check_callback(Relation indrel, Relation heaprel, void *state, bool rea
{
BTCallbackState *args = (BTCallbackState *) state;
bool heapkeyspace,
- allequalimage;
+ allequalimage,
+ prefix_compression;
if (!smgrexists(RelationGetSmgr(indrel), MAIN_FORKNUM))
ereport(ERROR,
@@ -321,7 +322,7 @@ bt_index_check_callback(Relation indrel, Relation heaprel, void *state, bool rea
RelationGetRelationName(indrel))));
/* Extract metadata from metapage, and sanitize it in passing */
- _bt_metaversion(indrel, &heapkeyspace, &allequalimage);
+ _bt_metaversion(indrel, &heapkeyspace, &allequalimage, &prefix_compression);
if (allequalimage && !heapkeyspace)
ereport(ERROR,
(errcode(ERRCODE_INDEX_CORRUPTED),
diff --git a/src/test/regress/expected/btree_prefix_compression.out b/src/test/regress/expected/btree_prefix_compression.out
new file mode 100644
index 000000000..d18719a22
--- /dev/null
+++ b/src/test/regress/expected/btree_prefix_compression.out
@@ -0,0 +1,250 @@
+--
+-- BTREE_PREFIX_COMPRESSION
+--
+-- Functional tests for leaf-level prefix compression: build, insert, split,
+-- VACUUM's anchor-promotion path, and scans in both directions. These
+-- tests operate at the SQL level -- for structural invariants covered by
+-- amcheck and page-layout checks covered by pageinspect, see contrib/.
+--
+-- Eligible: >= 2 key columns, all leading cols equalimage. We can't
+-- easily see the metapage flag without pageinspect, so we exercise the
+-- feature through its user-visible effects: correct query results plus
+-- substantially smaller index size relative to a single-column variant
+-- built on the same leading keys.
+CREATE TABLE pc_t (a int, b int, c int, d text);
+INSERT INTO pc_t
+ SELECT i / 1000, (i / 10) % 100, i, 'payload_' || (i % 7)
+ FROM generate_series(1, 50000) i;
+-- Multi-column integer index: prefix compression applies.
+CREATE INDEX pc_idx_abc ON pc_t (a, b, c);
+-- Single-column index: ineligible for prefix compression.
+CREATE INDEX pc_idx_a ON pc_t (a);
+-- Forward scan correctness
+SELECT count(*) FROM pc_t WHERE a = 7;
+ count
+-------
+ 1000
+(1 row)
+
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42;
+ count
+-------
+ 10
+(1 row)
+
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7420;
+ row
+-------------
+ (7,42,7420)
+(1 row)
+
+-- Range scan
+SELECT count(*) FROM pc_t WHERE a BETWEEN 3 AND 5;
+ count
+-------
+ 3000
+(1 row)
+
+SELECT count(*) FROM pc_t WHERE (a, b) BETWEEN (10, 50) AND (10, 55);
+ count
+-------
+ 60
+(1 row)
+
+-- Backward scan (ORDER BY DESC)
+SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3;
+ c
+------
+ 7429
+ 7428
+ 7427
+(3 rows)
+
+-- Forced index-only scan
+SET enable_bitmapscan = off;
+SET enable_seqscan = off;
+EXPLAIN (COSTS off) SELECT c FROM pc_t WHERE a = 7 AND b = 42;
+ QUERY PLAN
+------------------------------------------
+ Index Only Scan using pc_idx_abc on pc_t
+ Index Cond: ((a = 7) AND (b = 42))
+(2 rows)
+
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42;
+ count
+-------
+ 10
+(1 row)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+-- Insert new rows that share an existing anchor's leading attrs (in-group
+-- follower path), rows starting new groups (new-anchor path), and rows
+-- landing at the leftmost position (forced plain anchor).
+INSERT INTO pc_t VALUES
+ (7, 42, 7420001, 'insert_in_group'),
+ (7, 42, 7420002, 'insert_in_group'),
+ (7, 999, 7999999, 'insert_new_group'), -- new (a,b) combo in existing a group
+ (99, 0, -1, 'insert_new_a'), -- first row of new a group
+ (-1, -1, -1, 'insert_leftmost'); -- leftmost data item on its page
+-- Verify inserted rows are scannable
+SELECT count(*) FROM pc_t WHERE d LIKE 'insert_%';
+ count
+-------
+ 5
+(1 row)
+
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c BETWEEN 7420001 AND 7420002
+ ORDER BY c;
+ row
+----------------
+ (7,42,7420001)
+ (7,42,7420002)
+(2 rows)
+
+SELECT (a,b,c) FROM pc_t WHERE a = -1;
+ row
+------------
+ (-1,-1,-1)
+(1 row)
+
+SELECT (a,b,c) FROM pc_t WHERE a = 99;
+ row
+-----------
+ (99,0,-1)
+(1 row)
+
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 999;
+ row
+-----------------
+ (7,999,7999999)
+(1 row)
+
+-- Exercise the page-split path: enough inserts to force leaf splits on a
+-- fully-loaded group. Verify no rows are lost.
+BEGIN;
+INSERT INTO pc_t
+ SELECT 7, 42, 7420000 + 1000 + i, 'split_forcer'
+ FROM generate_series(1, 5000) i;
+SELECT count(*) FROM pc_t WHERE d = 'split_forcer';
+ count
+-------
+ 5000
+(1 row)
+
+ROLLBACK;
+-- VACUUM's anchor-promotion path: delete exactly the rows that serve as
+-- anchors in their respective prefix groups, so VACUUM must promote the
+-- first surviving follower in each group to a plain form.
+-- For the (a, b, c) index, the anchor of each (a, b) group is the smallest
+-- c -- i.e. i where (i/10) % 100 * 10 == (i % 1000). Simpler rule: the
+-- smallest i within each range of 10 is the anchor-producing row, so
+-- deleting rows where c % 10 = 0 targets those anchors.
+DELETE FROM pc_t WHERE c % 10 = 0 AND d LIKE 'payload_%';
+VACUUM (INDEX_CLEANUP on) pc_t;
+-- Scans must continue to return correct results after promotion
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42;
+ count
+-------
+ 11
+(1 row)
+
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7421;
+ row
+-------------
+ (7,42,7421)
+(1 row)
+
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42 AND c % 10 = 0;
+ count
+-------
+ 0
+(1 row)
+
+SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3;
+ c
+---------
+ 7420002
+ 7420001
+ 7429
+(3 rows)
+
+-- Ineligible index shapes: non-equalimage leading col, single key col,
+-- index with only NULLs in leading col. All three should still work
+-- correctly; they just do not enable prefix compression.
+CREATE TABLE pc_t2 (a text COLLATE "C", b int, c int);
+INSERT INTO pc_t2
+ SELECT (i / 100)::text, (i / 10) % 10, i
+ FROM generate_series(1, 5000) i;
+CREATE INDEX pc_idx2 ON pc_t2 (a, b, c);
+SELECT count(*) FROM pc_t2 WHERE a = '42';
+ count
+-------
+ 100
+(1 row)
+
+SELECT (a,b,c) FROM pc_t2 WHERE a = '42' AND b = 5 AND c = 4255;
+ row
+-------------
+ (42,5,4255)
+(1 row)
+
+-- NULL handling in compressed followers: a row with NULL leading attr
+-- and the same NULL-prefix neighbours should still be in a coherent
+-- prefix group.
+CREATE TABLE pc_t3 (a int, b int, c int);
+INSERT INTO pc_t3
+ SELECT NULL, (i / 10) % 10, i FROM generate_series(1, 1000) i;
+INSERT INTO pc_t3
+ SELECT 1, (i / 10) % 10, i FROM generate_series(1, 1000) i;
+CREATE INDEX pc_idx3 ON pc_t3 (a, b, c);
+SELECT count(*) FROM pc_t3 WHERE a IS NULL;
+ count
+-------
+ 1000
+(1 row)
+
+SELECT count(*) FROM pc_t3 WHERE a = 1;
+ count
+-------
+ 1000
+(1 row)
+
+SELECT count(*) FROM pc_t3 WHERE a IS NULL AND b = 5;
+ count
+-------
+ 100
+(1 row)
+
+-- INCLUDE columns: non-key attrs are stored per-tuple (never part of the
+-- prefix). Verify correctness end-to-end.
+CREATE TABLE pc_t4 (a int, b int, c int, payload text);
+INSERT INTO pc_t4
+ SELECT i / 100, (i / 10) % 10, i, 'row_' || i
+ FROM generate_series(1, 10000) i;
+CREATE INDEX pc_idx4 ON pc_t4 (a, b, c) INCLUDE (payload);
+SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255;
+ payload
+----------
+ row_4255
+(1 row)
+
+-- Index-only scan using INCLUDE column
+SET enable_bitmapscan = off;
+SET enable_seqscan = off;
+EXPLAIN (COSTS off) SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255;
+ QUERY PLAN
+-----------------------------------------------------
+ Index Only Scan using pc_idx4 on pc_t4
+ Index Cond: ((a = 42) AND (b = 5) AND (c = 4255))
+(2 rows)
+
+SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255;
+ payload
+----------
+ row_4255
+(1 row)
+
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+DROP TABLE pc_t, pc_t2, pc_t3, pc_t4;
diff --git a/src/test/regress/expected/select_distinct.out b/src/test/regress/expected/select_distinct.out
index 741f72387..fbdefd033 100644
--- a/src/test/regress/expected/select_distinct.out
+++ b/src/test/regress/expected/select_distinct.out
@@ -542,7 +542,7 @@ SELECT DISTINCT y, x FROM distinct_tbl limit 10;
Limit
-> Unique
-> Gather Merge
- Workers Planned: 1
+ Workers Planned: 2
-> Unique
-> Parallel Index Only Scan using distinct_tbl_x_y_idx on distinct_tbl
(6 rows)
diff --git a/src/test/regress/parallel_schedule b/src/test/regress/parallel_schedule
index 75063f87a..9b5d04c35 100644
--- a/src/test/regress/parallel_schedule
+++ b/src/test/regress/parallel_schedule
@@ -62,6 +62,7 @@ test: sanity_check
# join depends on create_misc
# ----------
test: select_into select_distinct select_distinct_on select_implicit select_having subselect union case join aggregates transactions random portals arrays btree_index hash_index update delete namespace prepared_xacts
+test: btree_prefix_compression
# ----------
# Another group of parallel tests
diff --git a/src/test/regress/sql/btree_prefix_compression.sql b/src/test/regress/sql/btree_prefix_compression.sql
new file mode 100644
index 000000000..e270d9367
--- /dev/null
+++ b/src/test/regress/sql/btree_prefix_compression.sql
@@ -0,0 +1,128 @@
+--
+-- BTREE_PREFIX_COMPRESSION
+--
+-- Functional tests for leaf-level prefix compression: build, insert, split,
+-- VACUUM's anchor-promotion path, and scans in both directions. These
+-- tests operate at the SQL level -- for structural invariants covered by
+-- amcheck and page-layout checks covered by pageinspect, see contrib/.
+--
+
+-- Eligible: >= 2 key columns, all leading cols equalimage. We can't
+-- easily see the metapage flag without pageinspect, so we exercise the
+-- feature through its user-visible effects: correct query results plus
+-- substantially smaller index size relative to a single-column variant
+-- built on the same leading keys.
+CREATE TABLE pc_t (a int, b int, c int, d text);
+INSERT INTO pc_t
+ SELECT i / 1000, (i / 10) % 100, i, 'payload_' || (i % 7)
+ FROM generate_series(1, 50000) i;
+
+-- Multi-column integer index: prefix compression applies.
+CREATE INDEX pc_idx_abc ON pc_t (a, b, c);
+-- Single-column index: ineligible for prefix compression.
+CREATE INDEX pc_idx_a ON pc_t (a);
+
+-- Forward scan correctness
+SELECT count(*) FROM pc_t WHERE a = 7;
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42;
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7420;
+
+-- Range scan
+SELECT count(*) FROM pc_t WHERE a BETWEEN 3 AND 5;
+SELECT count(*) FROM pc_t WHERE (a, b) BETWEEN (10, 50) AND (10, 55);
+
+-- Backward scan (ORDER BY DESC)
+SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3;
+
+-- Forced index-only scan
+SET enable_bitmapscan = off;
+SET enable_seqscan = off;
+EXPLAIN (COSTS off) SELECT c FROM pc_t WHERE a = 7 AND b = 42;
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42;
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+
+-- Insert new rows that share an existing anchor's leading attrs (in-group
+-- follower path), rows starting new groups (new-anchor path), and rows
+-- landing at the leftmost position (forced plain anchor).
+INSERT INTO pc_t VALUES
+ (7, 42, 7420001, 'insert_in_group'),
+ (7, 42, 7420002, 'insert_in_group'),
+ (7, 999, 7999999, 'insert_new_group'), -- new (a,b) combo in existing a group
+ (99, 0, -1, 'insert_new_a'), -- first row of new a group
+ (-1, -1, -1, 'insert_leftmost'); -- leftmost data item on its page
+
+-- Verify inserted rows are scannable
+SELECT count(*) FROM pc_t WHERE d LIKE 'insert_%';
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c BETWEEN 7420001 AND 7420002
+ ORDER BY c;
+SELECT (a,b,c) FROM pc_t WHERE a = -1;
+SELECT (a,b,c) FROM pc_t WHERE a = 99;
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 999;
+
+-- Exercise the page-split path: enough inserts to force leaf splits on a
+-- fully-loaded group. Verify no rows are lost.
+BEGIN;
+INSERT INTO pc_t
+ SELECT 7, 42, 7420000 + 1000 + i, 'split_forcer'
+ FROM generate_series(1, 5000) i;
+SELECT count(*) FROM pc_t WHERE d = 'split_forcer';
+ROLLBACK;
+
+-- VACUUM's anchor-promotion path: delete exactly the rows that serve as
+-- anchors in their respective prefix groups, so VACUUM must promote the
+-- first surviving follower in each group to a plain form.
+-- For the (a, b, c) index, the anchor of each (a, b) group is the smallest
+-- c -- i.e. i where (i/10) % 100 * 10 == (i % 1000). Simpler rule: the
+-- smallest i within each range of 10 is the anchor-producing row, so
+-- deleting rows where c % 10 = 0 targets those anchors.
+DELETE FROM pc_t WHERE c % 10 = 0 AND d LIKE 'payload_%';
+VACUUM (INDEX_CLEANUP on) pc_t;
+
+-- Scans must continue to return correct results after promotion
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42;
+SELECT (a,b,c) FROM pc_t WHERE a = 7 AND b = 42 AND c = 7421;
+SELECT count(*) FROM pc_t WHERE a = 7 AND b = 42 AND c % 10 = 0;
+SELECT c FROM pc_t WHERE a = 7 AND b = 42 ORDER BY c DESC LIMIT 3;
+
+-- Ineligible index shapes: non-equalimage leading col, single key col,
+-- index with only NULLs in leading col. All three should still work
+-- correctly; they just do not enable prefix compression.
+CREATE TABLE pc_t2 (a text COLLATE "C", b int, c int);
+INSERT INTO pc_t2
+ SELECT (i / 100)::text, (i / 10) % 10, i
+ FROM generate_series(1, 5000) i;
+CREATE INDEX pc_idx2 ON pc_t2 (a, b, c);
+SELECT count(*) FROM pc_t2 WHERE a = '42';
+SELECT (a,b,c) FROM pc_t2 WHERE a = '42' AND b = 5 AND c = 4255;
+
+-- NULL handling in compressed followers: a row with NULL leading attr
+-- and the same NULL-prefix neighbours should still be in a coherent
+-- prefix group.
+CREATE TABLE pc_t3 (a int, b int, c int);
+INSERT INTO pc_t3
+ SELECT NULL, (i / 10) % 10, i FROM generate_series(1, 1000) i;
+INSERT INTO pc_t3
+ SELECT 1, (i / 10) % 10, i FROM generate_series(1, 1000) i;
+CREATE INDEX pc_idx3 ON pc_t3 (a, b, c);
+SELECT count(*) FROM pc_t3 WHERE a IS NULL;
+SELECT count(*) FROM pc_t3 WHERE a = 1;
+SELECT count(*) FROM pc_t3 WHERE a IS NULL AND b = 5;
+
+-- INCLUDE columns: non-key attrs are stored per-tuple (never part of the
+-- prefix). Verify correctness end-to-end.
+CREATE TABLE pc_t4 (a int, b int, c int, payload text);
+INSERT INTO pc_t4
+ SELECT i / 100, (i / 10) % 10, i, 'row_' || i
+ FROM generate_series(1, 10000) i;
+CREATE INDEX pc_idx4 ON pc_t4 (a, b, c) INCLUDE (payload);
+SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255;
+-- Index-only scan using INCLUDE column
+SET enable_bitmapscan = off;
+SET enable_seqscan = off;
+EXPLAIN (COSTS off) SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255;
+SELECT payload FROM pc_t4 WHERE a = 42 AND b = 5 AND c = 4255;
+RESET enable_bitmapscan;
+RESET enable_seqscan;
+
+DROP TABLE pc_t, pc_t2, pc_t3, pc_t4;
--
2.55.0
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-11-03 21:48 ` Re: nbtree: opt-in leaf key prefix compression Peter Geoghegan <pg@bowt.ie>
@ 2026-11-04 14:31 ` Greg Burd <greg@burd.me>
2026-11-04 17:38 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
0 siblings, 1 reply; 12+ messages in thread
From: Greg Burd @ 2026-11-04 14:31 UTC (permalink / raw)
To: Peter Geoghegan <pg@bowt.ie>; +Cc: pgsql-hackers@lists.postgresql.org
On Tuesday, November 3rd, 2026 at 4:48 PM, Peter Geoghegan <pg@bowt.ie> wrote:
> On Mon, Oct 26, 2026 at 4:25 PM Greg Burd <greg@burd.me> wrote:
> > This is a patch that adds leaf key prefix compression to nbtree, behind
> > a new per-index storage parameter, prefix_compression, off by default.
> > It's something I started a long time ago when primarily working on
> > JSONB-like data storage/indexing/etc. in Postgres when a colleague
> > pointed out that it didn't yet exist and his measurements showed that it
> > should. He wasn't wrong. It's a solid win for performance and overhead
> > IMO, but YMMV.
Hey Peter,
> I also have a vibe-coded patch adding prefix compression to nbtree,
> from back in April. I had completely forgotten about it until you
> reminded me. I attach it here now.
Excellent, I think we've talked about this idea a few times in person. The
reason for my interest is related to my "HOT for Expression Indexes" patch
where I am trying to make it possible for JSONB updates in heap to be HOT.
Today that isn't possible because all indexes on JSONB are expressions and
expression indexes spoil HOT.
At that time I noted that it is very common with JSONB data to index using
nbtree far too many fields in a JSONB document. This is an especially easy
trap for people coming from a MongoDB background, which has been a trend
since JSONB was introduced (and likely a big part of the justification for
that type).
These JSONB paths are highly prefix-compressible and I don't imagine that
JSONB will be removed from core anytime soon, so I thought I'd take a
shot at improving that use case.
> In the spirit of the age, I also had Claude do a comparative review of
> both of our patches. I also attach that here. I skimmed it a bit,
> which helped me to remember bits and pieces about the prompt I used
> back in April. Apparently my patch doesn't compose with deduplication,
> while yours does. On the other hand, your patch cannot handle prefixes
> unless the prefix column is of a varlena type. So there are cases
> where one performs much better than the other, and vice-versa. That's
> what I would have guessed.
It is a new "age" and we're all adapting to it. I've been doing software
development for 35 years now and this is, hands down, the biggest shift
I've seen for this profession. Everyone will have to adjust to the new
norm once the dust settles, but we're not even at that stage yet.
Thanks for digging your patch out and dusting it off so that I could take
a look and run a comparison. Two differences jump out.
Composing with deduplication was a hard requirement for my work with
Claude.
The varlena-first-column restriction was an efficiency choice early on
that got lost in the process and should have been dropped a while ago.
> > The patch is one commit against master. I'd appreciate review of the
> > approach and the code, and I'm happy to work through whatever it turns
> > up.
>
> We're now living in a world where the cost of producing a prototype
> like this has plummeted. The community's culture hasn't figured out
> how to deal with this yet. I personally don't have much of a clue what
> to do about it, but I am sure that certain things need to be assessed
> very differently now. Generating a patch that, in some technical and
> superficial sense, converges on being a complete implementation of
> some complicated optimization means very little now.
Absolutely true, Claude can prototype just about anything quickly and
sometimes quite well. Feed it a problem, answer some questions and
wait while it burns a hole in your wallet.
I too am confused, but in this case a valuable feature (key-prefix
compression) can be worked out in a few weeks, not years, and deliver
a feature that many will benefit from.
But on this thread I think I let the LLM do the talking about the patch
instead of presenting this idea/work to the hacker community on its
merits.
I own that, it was the wrong order and your mail is a reasonable
reaction to it. But let's not lose sight of the fact that this idea
provides value to users of nbtree, I hope that you agree on that point.
While I'm owning things... you asked what src/backend/access/bark/
barkutils.c is. It's nothing that should be in this patch. BARK is a
separate index AM I've been working on out of tree; this code started
as a port of its leaf prefix coding, and two stale comments pointing
back at that file rode along. They reference a file that doesn't exist
in the tree, which is just confusing. Apologies.
> Posting a huge AI generated patch that nobody will read could still
> make sense if there were doubts about the general efficacy of a
> particular technique, as a kind of informal proof of its feasibility,
> but I don't think that applies here.
You're right that the vibe-coded prototype patch shouldn't have led the
thread. Focusing on technical merit rather than process, the efficacy
here isn't in doubt. So the question isn't whether prefix compression
earns its place, it's whether this design is the right shape for it.
That's the conversation I want, along with code to demonstrate, test,
and evolve the idea into something we feel can be committed.
> It's fairly obvious that prefix
> compression can cut the storage overhead of indexes by 3x or more in
> sympathetic cases, at the cost of CPU overhead for decompression, and
> lots of complexity.
Yes, agreed. It's an obvious improvement that, like all things, has
trade-offs one of which is additional code complexity. But how is this
any different from other optimizations?
> The best feedback I can offer is that I think you should have started
> with a very high level discussion of the architectural trade-offs.
Fair point, can we rewind time a bit and do that now?
> You said "indexes on keys that share a long leading prefix (URLs,
> reverse-DNS names, a leading JSONB path, a tenant-id prefix) spend
> most of their bytes storing that prefix over and over", which is
> obviously true, but you neglected to mention why you felt that was the
> use case to target. Do users actually use nbtree indexes for JSONB
> paths? I have to admit that I didn't know that.
HEAVILY, they use nbtree nearly exclusively and you have to understand
that many are coming (as I said before) from MongoDB to Postgres where
it was standard operating procedure to have 10s, even 100s of indexed
fields within a particular document shape. Worse yet, sometimes when
mapping MongoDB-shaped use onto Postgres (as the open source documentdb
project does) there is a need for additional indexes beyond what the
user specified to enable a similar experience.
But you're right, I should have led with the problem. Talked over the
trade-offs and goals. Then prototype a solution. I skipped some steps.
Another use case is plain text/bytea keys with a long shared prefixes
such as URLs, reverse-DNS names, file paths, tenant-prefixed natural
keys, and the like.
Those are ordinary btree indexes today and the redundancy is obvious.
Most/all of these cases can easily be tested and I'll bet that the floor
for the compression is about 3x if we measured.
JSONB specifically, what I had in mind is a btree on an expression that
pulls a text path out of a document (btree on (doc->>'...'), or on a
materialized path column), not a GIN index.
> It seems likely that a design such as yours, which omits column-level
> compression, will never incorporate it later on once committed. Why do
> you think that's the best trade-off? I personally tend to doubt that
> it is, but I've given it little thought so who knows?
Column-level compression is a great technique, and I'd like to see that
happen too. The time line for that is much longer primarily due to the
many ways that table/index/executor all implicitly agree on one shape
and one behavior defined by heap. If we can more cleanly separate and
define the boundaries between those three then we could potentially see
that in core at some point, but I'll bet that takes a few years to sort
out. In the mean time, let's compress some key prefixes and save some
bytes.
I think these two are not in competition, they are orthogonal and both
is the answer but the easier of the two in today's code base is the
prefix compression.
> More generally, can you paint a detailed picture of what motivated you
> to work on this? I wouldn't expect that kind of background with an
> optimization that was an unalloyed good, and required no changes to
> the on-disk format. But for something like this, which would commit us
> to supporting its compressed representation forever, a detailed
> picture of how it affects users is completely essential. A really
> solid understanding of the trade-offs and the entire design space is
> hugely important.
>
> There are complex trade-offs around whether and to what extent prefix
> compression works by compressing things in terms of the index key
> space versus on a more ad-hoc basis. As Modern B-Tree Techniques puts
> it: "Code simplicity argues for truncating the same prefix from all
> entries in a B-tree node. Moreover, one can apply prefix truncation
> based on the actual keys currently held in a node or based on the
> possible key range as defined by the separator keys in parent (and
> possibly other ancestor) pages."
>
> Where does this design fit under his taxonomy? It looks like you're
> not relying on the compressed prefix to act like a low key. Why was
> that?
You've read it correctly this is the "actual keys currently held in
the node" variant, not the "key range from separator keys" one. The
per-page PREFIX item is the longest common byte prefix of the data
items that were on the page when it was written as a whole. It is not
derived from, and does not act as, a low key, and the parent/separator
keys are untouched.
Why that side of the taxonomy? The separator-key variant is the more
principled one IMO, it ties the compressible prefix to the page's key
space, so it's stable under inserts and composes with suffix truncation
but it's also the more invasive one. It wants the prefix to be a real
boundary key, which means the split and the downlink and _bt_compare
all have to understand it, and that's a much deeper change to nbtree
code than a per-page item that only the leaf read/write path knows
about.
The "actual keys held" variant buys most of the space win for a change
that stays inside the leaf. That was the trade I made that should have
been presented and discussed first is what I'm hearing from you. It's
the one worth arguing about. I made this decision for containment, not
because the separator-key variant is wrong.
> What's the trade-off around picking a base key from the tuples
> present when the page is "written as a whole"? What happens if, later
> on, more tuples are inserted that are within the page's key space but
> not compressible using its base key? Obviously you just don't compress
> those tuples, but why is that generally okay?
You've got the right idea, a later insert whose first column doesn't
share the page's prefix is stored uncoded, in place, next to coded
neighbors (the coded length byte distinguishes them per tuple, so a page
is a mix). No decode-time ambiguity, no rewrite on insert.
Why is this generally okay? Good question, and there are places where
it might not be.
In my estimation, it is okay (read: the right choice) because the base
key is the common prefix of a sorted run, and btree order means the
tuples that share it are contiguous, so an insert landing in the page's
key space usually *does* share the prefix and so the page stays mostly
coded.
The case where it degrades is a page straddling a prefix boundary. The
http://example.com / http://sample.org example from Yao's branch of the
thread is a case where the base key collapses to the short common run and
the page compresses poorly. That's a genuine limitation of one prefix
per page. It's the same root cause as the multiple-prefixes-per-page
idea, I'm open to a format that could carry more than one base key per
page. This design trade-off was a simplification, not a requirement.
For now the answer is that the page is recompressed to the better base
the next time it's written as a whole (a split or a dedup pass), so a
straddling page is transient rather than permanent, and in the worst
case it's simply a plain page, never worse than today.
> You placed a lot of emphasis on your WAL logging strategy, and how
> prefix compression needs to work during recovery, but I don't think
> that's the place to start. When it comes to lower-level details I'd be
> much more concerned about guarantees that a legal page split will
> always be available, even in extreme pathological cases. But that's
> really a facet of the on-disk representation itself, and the design as
> it relates to that.
Agreed that split-availability is the real low-level question and WAL is
downstream of it. I'm pleased with the guarantee the representation
gives, I think it's one of the strongest parts of the design. I should
have presented this earlier on the thread rather than burying it under
the WAL text, another example of your point which is that a healthy
thread on a feature such as this starts with design discussion, not a
functional vibe-coded patch prototype. So here's that idea written up
for discussion:
A prefix page never holds more logical data than a plain page would: a
coded tuple is never larger than the plain one (coding only kicks in
when at least BTREE_PREFIX_MIN_SHARED bytes are shared, and it replaces
those with a single code byte), and any tuple can always be stored
uncoded. So any split point that is legal for a plain page is legal for
a prefix page, and the split chooser's existing leftfree/rightfree
feasibility still holds. The one thing coding changes is that a prefix
page can hold *more* tuples than a plain page, which is why the split
accounting is in coded bytes but that only ever makes more split points
feasible, never fewer.
The degenerate guard you can see in _bt_recsplitloc() exists for exactly
one coded-size artifact (a point that leaves the left page with zero
data items, which plain-size accounting rejects on its own), and the
forced-full-page-image fallback is the belt-and-suspenders for the one
case redo can't rebuild from logged plain items. There is no page a
prefix index can reach that a plain split can't resolve, because
"store it uncoded" is always available as the floor.
If that guarantee has a hole in it, that's the thing I most want found,
so please push on it.
The short version, I'll re-send with the architecture-first framing, the
string-key motivation instead of the JSONB lead, the bark comments gone,
and the split-availability argument up front where it belongs.
> BTW, what is src/backend/access/bark/barkutils.c?
As stated earlier, the first iteration of this key prefix compression was
in the context of a separate vibe-coded exploration I'm doing. BARK is
the best name I could come up with that had B and "tree" combined into a
short word (CREATE INDEX using BARK). I'll do the clean up, sorry for
the confusion.
> --
> Peter Geoghegan
best.
-greg
^ permalink raw reply [nested|flat] 12+ messages in thread
* Re: nbtree: opt-in leaf key prefix compression
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-11-03 21:48 ` Re: nbtree: opt-in leaf key prefix compression Peter Geoghegan <pg@bowt.ie>
2026-11-04 14:31 ` Re: nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
@ 2026-11-04 17:38 ` Greg Burd <greg@burd.me>
0 siblings, 0 replies; 12+ messages in thread
From: Greg Burd @ 2026-11-04 17:38 UTC (permalink / raw)
To: pg@bowt.ie; +Cc: pgsql-hackers@lists.postgresql.org
On Wed, 04 Nov 2026 14:31:45 +0000, Greg Burd <greg@burd.me> wrote:
>
> On Tuesday, November 3rd, 2026 at 4:48 PM, Peter Geoghegan <pg@bowt.ie> wrote:
>
> > It seems likely that a design such as yours, which omits column-level
> > compression, will never incorporate it later on once committed. Why do
> > you think that's the best trade-off? I personally tend to doubt that
> > it is, but I've given it little thought so who knows?
>
> Column-level compression is a great technique, and I'd like to see that
> happen too. The time line for that is much longer primarily due to the
> many ways that table/index/executor all implicitly agree on one shape
> and one behavior defined by heap. If we can more cleanly separate and
> define the boundaries between those three then we could potentially see
> that in core at some point, but I'll bet that takes a few years to sort
> out. In the mean time, let's compress some key prefixes and save some
> bytes.
>
> I think these two are not in competition, they are orthogonal and both
> is the answer but the easier of the two in today's code base is the
> prefix compression.
Quick follow-up...
I may have misunderstood "column-level" to mean "columnar" and if that's
the case I apologize. I think you are talking more about key prefix
compression across multiple columns that together form the btree key,
and if that's the case then yes I agree that's something required of a
solution. I'd been side-stepping this just for simplicity sake but I'll
think about it a bit and see if I can bring that into the design.
best.
-greg
^ permalink raw reply [nested|flat] 12+ messages in thread
end of thread, other threads:[~2026-11-04 18:08 UTC | newest]
Thread overview: 12+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2026-10-26 20:24 nbtree: opt-in leaf key prefix compression Greg Burd <greg@burd.me>
2026-10-27 16:50 ` Greg Burd <greg@burd.me>
2026-10-27 17:50 ` Greg Burd <greg@burd.me>
2026-10-29 16:42 ` Greg Burd <greg@burd.me>
2026-10-30 11:52 ` Yao Feng <fengyao0087@gmail.com>
2026-10-30 15:28 ` Greg Burd <greg@burd.me>
2026-11-03 17:48 ` Greg Burd <greg@burd.me>
2026-11-03 21:36 ` Greg Burd <greg@burd.me>
2026-11-04 18:08 ` Greg Burd <greg@burd.me>
2026-11-03 21:48 ` Peter Geoghegan <pg@bowt.ie>
2026-11-04 14:31 ` Greg Burd <greg@burd.me>
2026-11-04 17:38 ` Greg Burd <greg@burd.me>
This inbox is served by DDX for PostgreSQL; see mirroring instructions
for how to clone and mirror all data and code used for this inbox