From bac3dcf6f108a953e54f371f001e8d379d784898 Mon Sep 17 00:00:00 2001
From: reshke <reshke@double.cloud>
Date: Mon, 13 Oct 2025 20:14:26 +0000
Subject: [PATCH v15 2/3] GIN pageinspect support for entry tree and posting
 tree internal pages

This patch provides a new version for the pageinspect contrib module, including
two new functions:

* gin_entrypage_items.

* gin_datapage_items.

These two functions can be used to examine the GIN entry tree and posting
tree pages. Namely, gin_entrypage_items can be used for both leaf and
non-leaf entry tree pages. gin_datapage_items is provided in pairs with
already-existing gin_leafpage_items to examine the non-leaf posting tree pages.

We keep the different functions here mainly because of different GIN.
pages layoff.

Note that fast-list pages are out of scope of this patch.

Co-authored-by: Peter Eisentraut <peter@eisentraut.org>
Reviewed-by: Andrey Borodin <x4mmm@yandex-team.ru>
Reviewed-by: Roman Khapov <rkhapov@yandex-team.ru>
Reviewed-by: Chao Li <li.evan.chao@gmail.com>
Reviewed-by: Japin Li <japinli@hotmail.com>

Discussion: https://postgr.es/m/CALdSSPiN13n7feQcY0WCmq8jzxjwqhNrt1E=g=g6aZANyE_OoQ@mail.gmail.com
---
 contrib/pageinspect/Makefile                  |   2 +-
 contrib/pageinspect/expected/gin.out          |  84 ++++-
 contrib/pageinspect/expected/gin_1.out        | 151 ++++++++
 contrib/pageinspect/ginfuncs.c                | 350 ++++++++++++++++++
 contrib/pageinspect/meson.build               |   1 +
 .../pageinspect/pageinspect--1.13--1.14.sql   |  27 ++
 contrib/pageinspect/pageinspect.control       |   2 +-
 contrib/pageinspect/sql/gin.sql               |  34 +-
 doc/src/sgml/pageinspect.sgml                 |  54 +++
 9 files changed, 699 insertions(+), 6 deletions(-)
 create mode 100644 contrib/pageinspect/expected/gin_1.out
 create mode 100644 contrib/pageinspect/pageinspect--1.13--1.14.sql

diff --git a/contrib/pageinspect/Makefile b/contrib/pageinspect/Makefile
index eae989569d0..09774fd340c 100644
--- a/contrib/pageinspect/Makefile
+++ b/contrib/pageinspect/Makefile
@@ -13,7 +13,7 @@ OBJS = \
 	rawpage.o
 
 EXTENSION = pageinspect
-DATA =  pageinspect--1.12--1.13.sql \
+DATA =  pageinspect--1.13--1.14.sql pageinspect--1.12--1.13.sql \
 	pageinspect--1.11--1.12.sql pageinspect--1.10--1.11.sql \
 	pageinspect--1.9--1.10.sql pageinspect--1.8--1.9.sql \
 	pageinspect--1.7--1.8.sql pageinspect--1.6--1.7.sql \
diff --git a/contrib/pageinspect/expected/gin.out b/contrib/pageinspect/expected/gin.out
index ff1da6a5a17..5018ef76aa7 100644
--- a/contrib/pageinspect/expected/gin.out
+++ b/contrib/pageinspect/expected/gin.out
@@ -1,6 +1,8 @@
-CREATE TABLE test1 (x int, y int[]);
-INSERT INTO test1 VALUES (1, ARRAY[11, 111]);
+CREATE TABLE test1 (x int, y int[], z text[]);
+INSERT INTO test1 VALUES (1, ARRAY[11, 111], ARRAY['a', 'b', 'c']);
 CREATE INDEX test1_y_idx ON test1 USING gin (y) WITH (fastupdate = off);
+CREATE INDEX test2_y_z_idx ON test1 USING gin (y, z) WITH (fastupdate = off);
+CREATE INDEX test3_y_z_idx ON test1 USING gin (y, z) WITH (fastupdate = on);
 \x
 SELECT * FROM gin_metapage_info(get_raw_page('test1_y_idx', 0));
 -[ RECORD 1 ]----+-----------
@@ -27,6 +29,45 @@ flags     | {leaf}
 SELECT * FROM gin_leafpage_items(get_raw_page('test1_y_idx', 1));
 ERROR:  input page is not a compressed GIN data leaf page
 DETAIL:  Flags 0002, expected 0083
+SELECT * FROM gin_entrypage_items(get_raw_page('test1_y_idx', 1), 'test1_y_idx'::regclass);
+-[ RECORD 1 ]--------------
+itemoffset | 1
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | y=11
+-[ RECORD 2 ]--------------
+itemoffset | 2
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | y=111
+
+SELECT * FROM gin_entrypage_items(get_raw_page('test2_y_z_idx', 1), 'test2_y_z_idx'::regclass);
+-[ RECORD 1 ]--------------
+itemoffset | 1
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | y=11
+-[ RECORD 2 ]--------------
+itemoffset | 2
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | y=111
+-[ RECORD 3 ]--------------
+itemoffset | 3
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | z=a
+-[ RECORD 4 ]--------------
+itemoffset | 4
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | z=b
+-[ RECORD 5 ]--------------
+itemoffset | 5
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | z=c
+
 INSERT INTO test1 SELECT x, ARRAY[1,10] FROM generate_series(2,10000) x;
 SELECT COUNT(*) > 0
 FROM gin_leafpage_items(get_raw_page('test1_y_idx',
@@ -35,6 +76,23 @@ FROM gin_leafpage_items(get_raw_page('test1_y_idx',
 -[ RECORD 1 ]
 ?column? | t
 
+-- Now test posting tree non-leaf page.
+-- This requires inserting many tuples on a single leaf page to trigger page split.
+CREATE TABLE test_data_page(i INT[]);
+CREATE INDEX test_data_page_i_idx ON test_data_page USING gin(i) WITH (fastupdate = off);
+INSERT INTO test_data_page SELECT ARRAY[1] FROM generate_series(1, 10000);
+-- For this index, block 0 is metapage, block 1 is entry tree, block 2 is
+-- posting tree non-leaf page and block 3 & 4 are compressed data leaf pages.
+SELECT * FROM gin_datapage_items(get_raw_page('test_data_page_i_idx', 2));
+-[ RECORD 1 ]-------
+itemoffset | 1
+downlink   | 4
+item_tid   | (44,83)
+-[ RECORD 2 ]-------
+itemoffset | 2
+downlink   | 3
+item_tid   | (0,0)
+
 -- Failure with various modes.
 -- Suppress the DETAIL message, to allow the tests to work across various
 -- page sizes and architectures.
@@ -54,12 +112,34 @@ ERROR:  input page is not a valid GIN data leaf page
 SELECT * FROM gin_leafpage_items(get_raw_page('test1', 0));
 ERROR:  input page is not a valid GIN data leaf page
 \set VERBOSITY default
+-- Reject unsupported page types in gin_entrypage_items.
+SELECT * FROM gin_entrypage_items(get_raw_page('test2_y_z_idx', 0), 'test2_y_z_idx'::regclass);
+ERROR:  gin_entrypage_items does not support metapages
+-- Check the error message for the internal posting tree page.
+SELECT * FROM gin_entrypage_items(get_raw_page('test_data_page_i_idx', 2), 'test_data_page_i_idx'::regclass);
+ERROR:  gin_entrypage_items does not support posting tree pages
+HINT:  This appears to be a GIN posting tree page. Please use gin_datapage_items.
+-- insert new row to trigger new (fast-list) page allocation.
+INSERT INTO test1 VALUES (1, ARRAY[11, 111], ARRAY['a', 'b', 'c']);
+-- double check that the new page is fast-list.
+SELECT * FROM gin_page_opaque_info(get_raw_page('test3_y_z_idx', 2));
+-[ RECORD 1 ]------------------
+rightlink | 3
+maxoff    | 120
+flags     | {list,list_fullrow}
+
+-- reject fast-list pages.
+SELECT * FROM gin_entrypage_items(get_raw_page('test3_y_z_idx', 3), 'test3_y_z_idx'::regclass);
+ERROR:  gin_entrypage_items does not support fast list pages
 -- Tests with all-zero pages.
 SHOW block_size \gset
 SELECT gin_leafpage_items(decode(repeat('00', :block_size), 'hex'));
 -[ RECORD 1 ]------+-
 gin_leafpage_items | 
 
+SELECT gin_datapage_items(decode(repeat('00', :block_size), 'hex'));
+(0 rows)
+
 SELECT gin_metapage_info(decode(repeat('00', :block_size), 'hex'));
 -[ RECORD 1 ]-----+-
 gin_metapage_info | 
diff --git a/contrib/pageinspect/expected/gin_1.out b/contrib/pageinspect/expected/gin_1.out
new file mode 100644
index 00000000000..afb52500111
--- /dev/null
+++ b/contrib/pageinspect/expected/gin_1.out
@@ -0,0 +1,151 @@
+CREATE TABLE test1 (x int, y int[], z text[]);
+INSERT INTO test1 VALUES (1, ARRAY[11, 111], ARRAY['a', 'b', 'c']);
+CREATE INDEX test1_y_idx ON test1 USING gin (y) WITH (fastupdate = off);
+CREATE INDEX test2_y_z_idx ON test1 USING gin (y, z) WITH (fastupdate = off);
+CREATE INDEX test3_y_z_idx ON test1 USING gin (y, z) WITH (fastupdate = on);
+\x
+SELECT * FROM gin_metapage_info(get_raw_page('test1_y_idx', 0));
+-[ RECORD 1 ]----+-----------
+pending_head     | 4294967295
+pending_tail     | 4294967295
+tail_free_size   | 0
+n_pending_pages  | 0
+n_pending_tuples | 0
+n_total_pages    | 2
+n_entry_pages    | 1
+n_data_pages     | 0
+n_entries        | 2
+version          | 2
+
+SELECT * FROM gin_metapage_info(get_raw_page('test1_y_idx', 1));
+ERROR:  input page is not a GIN metapage
+DETAIL:  Flags 0002, expected 0008
+SELECT * FROM gin_page_opaque_info(get_raw_page('test1_y_idx', 1));
+-[ RECORD 1 ]---------
+rightlink | 4294967295
+maxoff    | 0
+flags     | {leaf}
+
+SELECT * FROM gin_leafpage_items(get_raw_page('test1_y_idx', 1));
+ERROR:  input page is not a compressed GIN data leaf page
+DETAIL:  Flags 0002, expected 0083
+SELECT * FROM gin_entrypage_items(get_raw_page('test1_y_idx', 1), 'test1_y_idx'::regclass);
+-[ RECORD 1 ]--------------
+itemoffset | 1
+downlink   | (2147483660,1)
+tids       | {"(0,1)"}
+keys       | y=11
+-[ RECORD 2 ]--------------
+itemoffset | 2
+downlink   | (2147483660,1)
+tids       | {"(0,1)"}
+keys       | y=111
+
+SELECT * FROM gin_entrypage_items(get_raw_page('test2_y_z_idx', 1), 'test2_y_z_idx'::regclass);
+-[ RECORD 1 ]--------------
+itemoffset | 1
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | y=11
+-[ RECORD 2 ]--------------
+itemoffset | 2
+downlink   | (2147483664,1)
+tids       | {"(0,1)"}
+keys       | y=111
+-[ RECORD 3 ]--------------
+itemoffset | 3
+downlink   | (2147483660,1)
+tids       | {"(0,1)"}
+keys       | z=a
+-[ RECORD 4 ]--------------
+itemoffset | 4
+downlink   | (2147483660,1)
+tids       | {"(0,1)"}
+keys       | z=b
+-[ RECORD 5 ]--------------
+itemoffset | 5
+downlink   | (2147483660,1)
+tids       | {"(0,1)"}
+keys       | z=c
+
+INSERT INTO test1 SELECT x, ARRAY[1,10] FROM generate_series(2,10000) x;
+SELECT COUNT(*) > 0
+FROM gin_leafpage_items(get_raw_page('test1_y_idx',
+                        (pg_relation_size('test1_y_idx') /
+                         current_setting('block_size')::bigint)::int - 1));
+-[ RECORD 1 ]
+?column? | t
+
+-- Now test posting tree non-leaf page.
+-- This requires inserting many tuples on a single leaf page to trigger page split.
+CREATE TABLE test_data_page(i INT[]);
+CREATE INDEX test_data_page_i_idx ON test_data_page USING gin(i) WITH (fastupdate = off);
+INSERT INTO test_data_page SELECT ARRAY[1] FROM generate_series(1, 10000);
+-- For this index, block 0 is metapage, block 1 is entry tree, block 2 is
+-- posting tree non-leaf page and block 3 & 4 are compressed data leaf pages.
+SELECT * FROM gin_datapage_items(get_raw_page('test_data_page_i_idx', 2));
+-[ RECORD 1 ]--------
+itemoffset | 1
+downlink   | 4
+item_tid   | (41,125)
+-[ RECORD 2 ]--------
+itemoffset | 2
+downlink   | 3
+item_tid   | (0,0)
+
+-- Failure with various modes.
+-- Suppress the DETAIL message, to allow the tests to work across various
+-- page sizes and architectures.
+\set VERBOSITY terse
+-- invalid page size
+SELECT gin_leafpage_items('aaa'::bytea);
+ERROR:  invalid page size
+SELECT gin_metapage_info('bbb'::bytea);
+ERROR:  invalid page size
+SELECT gin_page_opaque_info('ccc'::bytea);
+ERROR:  invalid page size
+-- invalid special area size
+SELECT * FROM gin_metapage_info(get_raw_page('test1', 0));
+ERROR:  input page is not a valid GIN metapage
+SELECT * FROM gin_page_opaque_info(get_raw_page('test1', 0));
+ERROR:  input page is not a valid GIN data leaf page
+SELECT * FROM gin_leafpage_items(get_raw_page('test1', 0));
+ERROR:  input page is not a valid GIN data leaf page
+\set VERBOSITY default
+-- Reject unsupported page types in gin_entrypage_items.
+SELECT * FROM gin_entrypage_items(get_raw_page('test2_y_z_idx', 0), 'test2_y_z_idx'::regclass);
+ERROR:  gin_entrypage_items does not support metapages
+-- Check the error message for the internal posting tree page.
+SELECT * FROM gin_entrypage_items(get_raw_page('test_data_page_i_idx', 2), 'test_data_page_i_idx'::regclass);
+ERROR:  gin_entrypage_items does not support posting tree pages
+HINT:  This appears to be a GIN posting tree page. Please use gin_datapage_items.
+-- insert new row to trigger new (fast-list) page allocation.
+INSERT INTO test1 VALUES (1, ARRAY[11, 111], ARRAY['a', 'b', 'c']);
+-- double check that the new page is fast-list.
+SELECT * FROM gin_page_opaque_info(get_raw_page('test3_y_z_idx', 2));
+-[ RECORD 1 ]------------------
+rightlink | 3
+maxoff    | 136
+flags     | {list,list_fullrow}
+
+-- reject fast-list pages.
+SELECT * FROM gin_entrypage_items(get_raw_page('test3_y_z_idx', 3), 'test3_y_z_idx'::regclass);
+ERROR:  gin_entrypage_items does not support fast list pages
+-- Tests with all-zero pages.
+SHOW block_size \gset
+SELECT gin_leafpage_items(decode(repeat('00', :block_size), 'hex'));
+-[ RECORD 1 ]------+-
+gin_leafpage_items | 
+
+SELECT gin_datapage_items(decode(repeat('00', :block_size), 'hex'));
+(0 rows)
+
+SELECT gin_metapage_info(decode(repeat('00', :block_size), 'hex'));
+-[ RECORD 1 ]-----+-
+gin_metapage_info | 
+
+SELECT gin_page_opaque_info(decode(repeat('00', :block_size), 'hex'));
+-[ RECORD 1 ]--------+-
+gin_page_opaque_info | 
+
+DROP TABLE test1;
diff --git a/contrib/pageinspect/ginfuncs.c b/contrib/pageinspect/ginfuncs.c
index b7bd7a3f4cd..c9cf08872ac 100644
--- a/contrib/pageinspect/ginfuncs.c
+++ b/contrib/pageinspect/ginfuncs.c
@@ -11,18 +11,27 @@
 
 #include "access/gin_private.h"
 #include "access/htup_details.h"
+#include "access/relation.h"
+#include "access/tupdesc.h"
 #include "catalog/pg_type.h"
 #include "funcapi.h"
 #include "miscadmin.h"
 #include "pageinspect.h"
 #include "utils/array.h"
 #include "utils/builtins.h"
+#include "utils/lsyscache.h"
+#include "utils/rel.h"
+#include "utils/ruleutils.h"
 
 
 PG_FUNCTION_INFO_V1(gin_metapage_info);
 PG_FUNCTION_INFO_V1(gin_page_opaque_info);
+PG_FUNCTION_INFO_V1(gin_entrypage_items);
 PG_FUNCTION_INFO_V1(gin_leafpage_items);
+PG_FUNCTION_INFO_V1(gin_datapage_items);
 
+#define IS_INDEX(r) ((r)->rd_rel->relkind == RELKIND_INDEX)
+#define IS_GIN(r) ((r)->rd_rel->relam == GIN_AM_OID)
 
 Datum
 gin_metapage_info(PG_FUNCTION_ARGS)
@@ -175,6 +184,347 @@ typedef struct gin_leafpage_items_state
 	GinPostingList *lastseg;
 } gin_leafpage_items_state;
 
+/*
+ * gin_entrypage_items
+ *
+ * Allows inspection of contents of an entry tree page.
+ */
+Datum
+gin_entrypage_items(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	Oid			indexRelid = PG_GETARG_OID(1);
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	Relation	indexRel;
+	OffsetNumber maxoff;
+	TupleDesc	tupdesc;
+	Page		page;
+	GinPageOpaque opaq;
+	StringInfoData buf;
+
+	if (!superuser())
+		ereport(ERROR,
+				errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				errmsg("must be superuser to use raw page functions"));
+
+	InitMaterializedSRF(fcinfo, 0);
+
+	/* Open the index relation */
+	indexRel = index_open(indexRelid, AccessShareLock);
+
+	if (!IS_INDEX(indexRel) || !IS_GIN(indexRel))
+		ereport(ERROR,
+				errcode(ERRCODE_WRONG_OBJECT_TYPE),
+				errmsg("\"%s\" is not a %s index",
+					   RelationGetRelationName(indexRel), "GIN"));
+
+	page = get_page_from_raw(raw_page);
+
+	if (PageIsNew(page))
+	{
+		index_close(indexRel, AccessShareLock);
+		PG_RETURN_NULL();
+	}
+
+	if (PageGetSpecialSize(page) != MAXALIGN(sizeof(GinPageOpaqueData)))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("input page is not a valid GIN entry tree page"),
+				errdetail("Expected special size %d, got %d.",
+						  (int) MAXALIGN(sizeof(GinPageOpaqueData)),
+						  (int) PageGetSpecialSize(page)));
+
+	opaq = GinPageGetOpaque(page);
+
+	/* we only support entry tree in this function, check that */
+	if (opaq->flags & GIN_META)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("gin_entrypage_items does not support metapages"));
+
+
+	if (opaq->flags & (GIN_LIST | GIN_LIST_FULLROW))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("gin_entrypage_items does not support fast list pages"));
+
+
+	if (opaq->flags & GIN_DATA)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("gin_entrypage_items does not support posting tree pages"),
+				errhint("This appears to be a GIN posting tree page. Please use gin_datapage_items."));
+
+	initStringInfo(&buf);
+	maxoff = PageGetMaxOffsetNumber(page);
+
+	tupdesc = RelationGetDescr(indexRel);
+
+	for (OffsetNumber offset = FirstOffsetNumber;
+		 offset <= maxoff;
+		 offset = OffsetNumberNext(offset))
+	{
+		OffsetNumber indAtt;
+		Datum		values[4];
+		bool		nulls[4] = {0};
+		Datum		attrVal;
+		bool		isnull;
+		IndexTuple	idxtuple;
+		ItemId		iid = PageGetItemId(page, offset);
+
+		if (!ItemIdIsValid(iid))
+			ereport(ERROR, errcode(ERRCODE_INDEX_CORRUPTED), errmsg("invalid ItemId at offset %u", offset));
+
+		idxtuple = (IndexTuple) PageGetItem(page, iid);
+
+		values[0] = UInt16GetDatum(offset);
+
+		if (tupdesc->natts == 1)
+		{
+			indAtt = FirstOffsetNumber;
+
+			/* Here we can safely reuse any tuple descriptor. */
+			attrVal = index_getattr(idxtuple, FirstOffsetNumber, tupdesc, &isnull);
+			if (isnull)
+				ereport(ERROR,
+						errcode(ERRCODE_INDEX_CORRUPTED),
+						errmsg("invalid gin entry page tuple at offset %u", offset));
+		}
+		else
+		{
+			TupleDesc	tmpTupdesc;
+			Datum		res;
+			Form_pg_attribute attr;
+
+			/*
+			 * Multi-column GIN indexes store 2-attribute tuple on each page
+			 * item. First attribute is which heap attribute is stored as the
+			 * second value in pair. To display value with proper output
+			 * function we need to recreate tuple descriptor on each offset.
+			 * NB: It is safe to reuse the original index tuple. See also
+			 * gintuple_get_attrnum.
+			 */
+
+			res = index_getattr(idxtuple, FirstOffsetNumber, tupdesc, &isnull);
+
+			/*
+			 * we do not expect null for first attr in multi-column GIN
+			 */
+			if (isnull)
+				ereport(ERROR,
+						errcode(ERRCODE_INDEX_CORRUPTED),
+						errmsg("invalid gin entry page tuple at offset %u", offset));
+
+			indAtt = DatumGetUInt16(res);
+
+			attr = TupleDescAttr(tupdesc, indAtt - 1);
+
+			tmpTupdesc = CreateTemplateTupleDesc(2);
+
+			TupleDescInitEntry(tmpTupdesc, (AttrNumber) 1, NULL,
+							   INT2OID, -1, 0);
+			TupleDescInitEntry(tmpTupdesc, (AttrNumber) 2, NULL,
+							   attr->atttypid,
+							   attr->atttypmod,
+							   attr->attndims);
+			TupleDescInitEntryCollation(tmpTupdesc, (AttrNumber) 2,
+										attr->attcollation);
+
+			attrVal = index_getattr(idxtuple, OffsetNumberNext(FirstOffsetNumber),
+									tmpTupdesc,
+									&isnull);
+
+			FreeTupleDesc(tmpTupdesc);
+		}
+
+		appendStringInfo(&buf, "%s=", quote_identifier(TupleDescAttr(tupdesc, indAtt - 1)->attname.data));
+
+		if (!isnull)
+		{
+			Oid			foutoid;
+			bool		typisvarlena;
+			Oid			typoid;
+			char	   *value;
+			bool		nq;
+
+			/*
+			 * The following value output and quoting logic is copied from
+			 * record_out().
+			 */
+			typoid = TupleDescAttr(tupdesc, indAtt - 1)->atttypid;
+			getTypeOutputInfo(typoid, &foutoid, &typisvarlena);
+			value = OidOutputFunctionCall(foutoid, attrVal);
+
+			/* Check whether we need double quotes for this value */
+			nq = (value[0] == '\0');	/* force quotes for empty string */
+			for (const char *tmp = value; *tmp; tmp++)
+			{
+				char		ch = *tmp;
+
+				if (ch == '"' || ch == '\\' ||
+					ch == '(' || ch == ')' || ch == ',' ||
+					isspace((unsigned char) ch))
+				{
+					nq = true;
+					break;
+				}
+			}
+
+			/* And emit the string */
+			if (nq)
+				appendStringInfoCharMacro(&buf, '"');
+			for (const char *tmp = value; *tmp; tmp++)
+			{
+				char		ch = *tmp;
+
+				if (ch == '"' || ch == '\\')
+					appendStringInfoCharMacro(&buf, ch);
+				appendStringInfoCharMacro(&buf, ch);
+			}
+			if (nq)
+				appendStringInfoCharMacro(&buf, '"');
+		}
+		else
+		{
+			appendStringInfo(&buf, "NULL");
+		}
+
+		values[3] = CStringGetTextDatum(buf.data);
+		resetStringInfo(&buf);
+
+		if (GinIsPostingTree(idxtuple))
+		{
+			values[1] = ItemPointerGetDatum(&idxtuple->t_tid);
+			nulls[2] = true;
+		}
+		else
+		{
+			int			ndecoded;
+			Datum	   *tids_datum;
+			ItemPointer items_orig;
+			bool		free_items_orig;
+
+			values[1] = ItemPointerGetDatum(&idxtuple->t_tid);
+			/* Get list of item pointers from the tuple. */
+			if (GinItupIsCompressed(idxtuple))
+			{
+				items_orig = ginPostingListDecode((GinPostingList *) GinGetPosting(idxtuple), &ndecoded);
+				free_items_orig = true;
+			}
+			else
+			{
+				items_orig = (ItemPointer) GinGetPosting(idxtuple);
+				ndecoded = GinGetNPosting(idxtuple);
+				free_items_orig = false;
+			}
+
+			tids_datum = palloc_array(Datum, ndecoded);
+			for (int i = 0; i < ndecoded; i++)
+				tids_datum[i] = ItemPointerGetDatum(&items_orig[i]);
+			values[2] = PointerGetDatum(construct_array_builtin(tids_datum, ndecoded, TIDOID));
+
+			pfree(tids_datum);
+
+			if (free_items_orig)
+				pfree(items_orig);
+		}
+
+		/* Build and return the result tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
+	}
+
+	index_close(indexRel, AccessShareLock);
+
+	return (Datum) 0;
+}
+
+/*
+ * gin_datapage_items
+ *
+ * Allows inspection of contents of an posting tree non-leaf page.
+ */
+Datum
+gin_datapage_items(PG_FUNCTION_ARGS)
+{
+	bytea	   *raw_page = PG_GETARG_BYTEA_P(0);
+	ReturnSetInfo *rsinfo = (ReturnSetInfo *) fcinfo->resultinfo;
+	OffsetNumber maxoff;
+	Page		page;
+	GinPageOpaque opaq;
+
+	if (!superuser())
+		ereport(ERROR,
+				errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
+				errmsg("must be superuser to use raw page functions"));
+
+	InitMaterializedSRF(fcinfo, 0);
+	page = get_page_from_raw(raw_page);
+
+	if (PageIsNew(page))
+	{
+		PG_RETURN_NULL();
+	}
+
+	if (PageGetSpecialSize(page) != MAXALIGN(sizeof(GinPageOpaqueData)))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("input page is not a valid GIN data leaf page"),
+				 errdetail("Expected special size %d, got %d.",
+						   (int) MAXALIGN(sizeof(GinPageOpaqueData)),
+						   (int) PageGetSpecialSize(page))));
+
+	opaq = GinPageGetOpaque(page);
+
+	/*
+	 * Reject non-entry-tree GIN pages, which are metapage, fastlist pages,
+	 * and posting tree pages.
+	 */
+
+	if (opaq->flags & (GIN_META))
+		ereport(ERROR,
+				(errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				 errmsg("gin_datapage_items is unsupported for metapage")));
+
+	if (opaq->flags & (GIN_LIST | GIN_LIST_FULLROW))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("gin_datapage_items is unsupported for GIN fast update list"));
+
+	if (!(opaq->flags & GIN_DATA))
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("input page is not a GIN data tree page"));
+
+	if (opaq->flags & GIN_LEAF)
+		ereport(ERROR,
+				errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+				errmsg("input page is a GIN data leaf tree page"),
+				errhint("This appears to be a GIN posting leaf tree page. Please use gin_leafpage_items."));
+
+	maxoff = GinPageGetOpaque(page)->maxoff;
+
+	for (OffsetNumber offset = FirstOffsetNumber;
+		 offset <= maxoff;
+		 offset = OffsetNumberNext(offset))
+	{
+		Datum		values[3];
+		bool		nulls[3];
+		PostingItem *item = GinDataPageGetPostingItem(page, offset);
+
+		memset(nulls, 0, sizeof(nulls));
+
+		values[0] = UInt16GetDatum(offset);
+
+		values[1] = UInt32GetDatum(BlockIdGetBlockNumber(&item->child_blkno));
+		values[2] = ItemPointerGetDatum(&item->key);
+
+		/* Build and return the result tuple. */
+		tuplestore_putvalues(rsinfo->setResult, rsinfo->setDesc, values, nulls);
+	}
+
+	return (Datum) 0;
+}
+
 Datum
 gin_leafpage_items(PG_FUNCTION_ARGS)
 {
diff --git a/contrib/pageinspect/meson.build b/contrib/pageinspect/meson.build
index c43ea400a4d..2f333635838 100644
--- a/contrib/pageinspect/meson.build
+++ b/contrib/pageinspect/meson.build
@@ -38,6 +38,7 @@ install_data(
   'pageinspect--1.10--1.11.sql',
   'pageinspect--1.11--1.12.sql',
   'pageinspect--1.12--1.13.sql',
+  'pageinspect--1.13--1.14.sql',
   'pageinspect.control',
   kwargs: contrib_data_args,
 )
diff --git a/contrib/pageinspect/pageinspect--1.13--1.14.sql b/contrib/pageinspect/pageinspect--1.13--1.14.sql
new file mode 100644
index 00000000000..ef6fa87e0f4
--- /dev/null
+++ b/contrib/pageinspect/pageinspect--1.13--1.14.sql
@@ -0,0 +1,27 @@
+/* contrib/pageinspect/pageinspect--1.13--1.14.sql */
+
+-- complain if script is sourced in psql, rather than via ALTER EXTENSION
+\echo Use "ALTER EXTENSION pageinspect UPDATE TO '1.14'" to load this file. \quit
+
+--
+-- gin_entrypage_items()
+--
+CREATE FUNCTION gin_entrypage_items(IN page bytea, IN reloid OID,
+    OUT itemoffset smallint,
+    OUT downlink tid,
+    OUT tids tid[],
+    OUT keys text)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'gin_entrypage_items'
+LANGUAGE C STRICT PARALLEL SAFE;
+
+--
+-- gin_datapage_items()
+--
+CREATE FUNCTION gin_datapage_items(IN page bytea,
+    OUT itemoffset smallint,
+    OUT downlink int,
+    OUT item_tid tid)
+RETURNS SETOF record
+AS 'MODULE_PATHNAME', 'gin_datapage_items'
+LANGUAGE C STRICT PARALLEL SAFE;
diff --git a/contrib/pageinspect/pageinspect.control b/contrib/pageinspect/pageinspect.control
index cfc87feac03..aee3f598a9e 100644
--- a/contrib/pageinspect/pageinspect.control
+++ b/contrib/pageinspect/pageinspect.control
@@ -1,5 +1,5 @@
 # pageinspect extension
 comment = 'inspect the contents of database pages at a low level'
-default_version = '1.13'
+default_version = '1.14'
 module_pathname = '$libdir/pageinspect'
 relocatable = true
diff --git a/contrib/pageinspect/sql/gin.sql b/contrib/pageinspect/sql/gin.sql
index b57466d7ebf..1a9eaebeebc 100644
--- a/contrib/pageinspect/sql/gin.sql
+++ b/contrib/pageinspect/sql/gin.sql
@@ -1,6 +1,8 @@
-CREATE TABLE test1 (x int, y int[]);
-INSERT INTO test1 VALUES (1, ARRAY[11, 111]);
+CREATE TABLE test1 (x int, y int[], z text[]);
+INSERT INTO test1 VALUES (1, ARRAY[11, 111], ARRAY['a', 'b', 'c']);
 CREATE INDEX test1_y_idx ON test1 USING gin (y) WITH (fastupdate = off);
+CREATE INDEX test2_y_z_idx ON test1 USING gin (y, z) WITH (fastupdate = off);
+CREATE INDEX test3_y_z_idx ON test1 USING gin (y, z) WITH (fastupdate = on);
 
 \x
 
@@ -11,6 +13,10 @@ SELECT * FROM gin_page_opaque_info(get_raw_page('test1_y_idx', 1));
 
 SELECT * FROM gin_leafpage_items(get_raw_page('test1_y_idx', 1));
 
+SELECT * FROM gin_entrypage_items(get_raw_page('test1_y_idx', 1), 'test1_y_idx'::regclass);
+
+SELECT * FROM gin_entrypage_items(get_raw_page('test2_y_z_idx', 1), 'test2_y_z_idx'::regclass);
+
 INSERT INTO test1 SELECT x, ARRAY[1,10] FROM generate_series(2,10000) x;
 
 SELECT COUNT(*) > 0
@@ -18,6 +24,18 @@ FROM gin_leafpage_items(get_raw_page('test1_y_idx',
                         (pg_relation_size('test1_y_idx') /
                          current_setting('block_size')::bigint)::int - 1));
 
+-- Now test posting tree non-leaf page.
+-- This requires inserting many tuples on a single leaf page to trigger page split.
+
+CREATE TABLE test_data_page(i INT[]);
+CREATE INDEX test_data_page_i_idx ON test_data_page USING gin(i) WITH (fastupdate = off);
+
+INSERT INTO test_data_page SELECT ARRAY[1] FROM generate_series(1, 10000);
+
+-- For this index, block 0 is metapage, block 1 is entry tree, block 2 is
+-- posting tree non-leaf page and block 3 & 4 are compressed data leaf pages.
+SELECT * FROM gin_datapage_items(get_raw_page('test_data_page_i_idx', 2));
+
 -- Failure with various modes.
 -- Suppress the DETAIL message, to allow the tests to work across various
 -- page sizes and architectures.
@@ -32,9 +50,21 @@ SELECT * FROM gin_page_opaque_info(get_raw_page('test1', 0));
 SELECT * FROM gin_leafpage_items(get_raw_page('test1', 0));
 \set VERBOSITY default
 
+-- Reject unsupported page types in gin_entrypage_items.
+SELECT * FROM gin_entrypage_items(get_raw_page('test2_y_z_idx', 0), 'test2_y_z_idx'::regclass);
+-- Check the error message for the internal posting tree page.
+SELECT * FROM gin_entrypage_items(get_raw_page('test_data_page_i_idx', 2), 'test_data_page_i_idx'::regclass);
+-- insert new row to trigger new (fast-list) page allocation.
+INSERT INTO test1 VALUES (1, ARRAY[11, 111], ARRAY['a', 'b', 'c']);
+-- double check that the new page is fast-list.
+SELECT * FROM gin_page_opaque_info(get_raw_page('test3_y_z_idx', 2));
+-- reject fast-list pages.
+SELECT * FROM gin_entrypage_items(get_raw_page('test3_y_z_idx', 3), 'test3_y_z_idx'::regclass);
+
 -- Tests with all-zero pages.
 SHOW block_size \gset
 SELECT gin_leafpage_items(decode(repeat('00', :block_size), 'hex'));
+SELECT gin_datapage_items(decode(repeat('00', :block_size), 'hex'));
 SELECT gin_metapage_info(decode(repeat('00', :block_size), 'hex'));
 SELECT gin_page_opaque_info(decode(repeat('00', :block_size), 'hex'));
 
diff --git a/doc/src/sgml/pageinspect.sgml b/doc/src/sgml/pageinspect.sgml
index 3a113439e1d..4ed8826e92e 100644
--- a/doc/src/sgml/pageinspect.sgml
+++ b/doc/src/sgml/pageinspect.sgml
@@ -714,6 +714,60 @@ test=# SELECT first_tid, nbytes, tids[0:5] AS some_tids
  (170,30)  |    376 | {"(170,30)","(170,31)","(170,32)","(170,33)","(170,34)"}
  (173,44)  |    197 | {"(173,44)","(173,45)","(173,46)","(173,47)","(173,48)"}
 (7 rows)
+</screen>
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>gin_entrypage_items(page bytea, reloid oid) returns setof record</function>
+     <indexterm>
+      <primary>gin_entrypage_items</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <function>gin_entrypage_items</function> returns information about
+      the data stored in a entry tree <acronym>GIN</acronym> page.  For example:
+<screen>
+test=# select * from gin_entrypage_items(get_raw_page('gin_test_idx',
+1), 'gin_test_idx'::regclass);
+ itemoffset | downlink | tids |                keys
+------------+----------+------+------------------------------------
+          1 | (3,0)    | {}   | i=113
+          2 | (5,0)    | {}   | j=34173cb38f07f89ddbebc2ac9128303f
+          3 | (2,0)    | {}   | j=a0a080f42e6f13b3a2df133f073095dd
+          4 | (4,0)    | {}   | j=fc490ca45c00b1249bbe3554a4fdf6fb
+(4 rows)
+</screen>
+     </para>
+    </listitem>
+   </varlistentry>
+
+   <varlistentry>
+    <term>
+     <function>gin_datapage_items(page bytea) returns setof record</function>
+     <indexterm>
+      <primary>gin_datapage_items</primary>
+     </indexterm>
+    </term>
+
+    <listitem>
+     <para>
+      <function>gin_datapage_items</function> returns information about
+      the data stored in a posting tree <acronym>GIN</acronym> internal page.  For example:
+<screen>
+test=# select * from gin_datapage_items(get_raw_page('gin_test_idx',
+43));
+ itemoffset | downlink | item_tid
+------------+----------+----------
+          1 |      124 | (162,12)
+          2 |      123 | (314,37)
+          3 |      251 | (467,23)
+          4 |      373 | (0,0)
+(4 rows)
 </screen>
      </para>
     </listitem>
-- 
2.43.0

