Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ OBJS = \
src/index/state.o \
src/index/registry.o \
src/index/metapage.o \
src/index/freepage.o \
src/index/limit.o \
src/index/resolve.o \
src/index/source.o \
Expand All @@ -82,7 +83,7 @@ PG_CPPFLAGS += -Wno-unknown-warning-option -Wno-clobbered -Wno-packed-not-aligne
# PG_CPPFLAGS += -DDEBUG_DUMP_INDEX

# Test configuration
REGRESS = abort aerodocs basic binary_io bmw bmw_skip_advance bulk_load cache_apply cache_memory_cap cache_source cache_spill catalog_stats chain_source compression concurrent_build coverage deletion vacuum vacuum_bitmap vacuum_extended vacuum_rebuild dropped empty explicit_index expression_index force_merge implicit index inheritance large_documents limits lock manyterms memory memtable_append memtable_page memtable_spill memtable_spill_dead memtable_reclaim merge mixed parallel_build parallel_bmw partitioned partitioned_many partial_index pgstats queries quoted_identifiers rescan schema scoring1 scoring2 scoring3 scoring4 scoring5 scoring6 security segment segment_integrity segment_reclaim strings temp_table text_array text_config unsupported updates vector vector_v1_rejected unlogged_index wand
REGRESS = abort aerodocs basic binary_io bmw bmw_skip_advance bulk_load cache_apply cache_memory_cap cache_source cache_spill catalog_stats chain_source compression concurrent_build coverage deletion vacuum vacuum_bitmap vacuum_extended vacuum_rebuild dropped empty explicit_index expression_index force_merge implicit index inheritance large_documents limits lock manyterms memory memtable_append memtable_page memtable_spill memtable_spill_dead memtable_reclaim merge mixed parallel_build parallel_bmw partitioned partitioned_many partial_index pgstats queries quoted_identifiers rescan schema scoring1 scoring2 scoring3 scoring4 scoring5 scoring6 security segment segment_integrity segment_reclaim tombstone_reuse tombstone_recover strings temp_table text_array text_config unsupported updates vector vector_v1_rejected unlogged_index wand
REGRESS_OPTS = --inputdir=test --outputdir=test

PG_CONFIG ?= pg_config
Expand Down
24 changes: 24 additions & 0 deletions sql/pg_textsearch--1.4.0-dev.sql
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,35 @@ CREATE FUNCTION @extschema@.bm25_pending_free_pages(index_name text)
AS 'MODULE_PATHNAME', 'tp_pending_free_pages'
LANGUAGE C STRICT STABLE;

-- INTERNAL-ONLY test scaffold (issues #426, #427): return the live
-- head tombstone page to the index FSM so the next allocator can pick
-- it up, reproducing the stale-FSM / non-atomic-claim page-reuse
-- hazard without an actual crash. Superuser-only; not a supported API.
CREATE FUNCTION @extschema@.bm25_test_recycle_tombstone_head(
index_name text)
RETURNS bigint
AS 'MODULE_PATHNAME', 'tp_test_recycle_tombstone_head'
LANGUAGE C VOLATILE STRICT;

-- INTERNAL-ONLY test scaffold (issue #427): overwrite the head
-- tombstone page's magic so the chain node is corrupt, simulating a
-- page-reuse clobber, to exercise the drain's self-healing recovery.
-- Superuser-only; not a supported API.
CREATE FUNCTION @extschema@.bm25_test_corrupt_tombstone_head(
index_name text)
RETURNS bigint
AS 'MODULE_PATHNAME', 'tp_test_corrupt_tombstone_head'
LANGUAGE C VOLATILE STRICT;

-- Revoke public execute on debug functions (superuser-only).
REVOKE EXECUTE ON FUNCTION @extschema@.bm25_dump_index(text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION @extschema@.bm25_summarize_index(text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION @extschema@.bm25_pending_free_pages(text)
FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION
@extschema@.bm25_test_recycle_tombstone_head(text) FROM PUBLIC;
REVOKE EXECUTE ON FUNCTION
@extschema@.bm25_test_corrupt_tombstone_head(text) FROM PUBLIC;

-- The bm25_test_memtable_page / bm25_test_memtable_append /
-- bm25_test_chain_source / bm25_memtable_chain /
Expand Down
15 changes: 14 additions & 1 deletion src/access/vacuum.c
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

#include "access/am.h"
#include "access/build_context.h"
#include "index/freepage.h"
#include "index/metapage.h"
#include "index/state.h"
#include "memtable/page.h"
Expand Down Expand Up @@ -1277,8 +1278,20 @@ tp_reclaim_dead_memtable_pages(Relation indexrel, Relation heaprel)
hash_search(reachable, &blk, HASH_FIND, &found);
if (!found)
{
RecordFreeIndexPage(indexrel, blk);
/*
* Release the SHARE lock before returning the page to
* the FSM: tp_record_free_index_page re-locks
* EXCLUSIVE to write the recyclable free stamp so a
* later allocator can tell this deliberately-freed
* page from a live one. The page is DEAD (unlinked)
* and unreachable, and is not yet in the FSM, so no
* concurrent backend can allocate or resurrect it
* between the release and the stamp.
*/
UnlockReleaseBuffer(buf);
tp_record_free_index_page(indexrel, blk);
reclaimed_pages++;
continue;
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/constants.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
0x5450544F /* "TPTO" - Tapir Tombstone: parks displaced segment pages \
* for deferred, standby-safe FSM reclaim (issue #380) */
#define TP_TOMBSTONE_VERSION 1
#define TP_FREE_PAGE_MAGIC \
0x54504650 /* "TPFP" - Tapir Free Page: a page deliberately returned \
* to the index FSM and safe to recycle (issues #426, #427) \
*/

/*
* Page format versions - bump when on-disk format changes.
Expand Down
123 changes: 123 additions & 0 deletions src/debug/dump.c
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
#include <postgres.h>

#include <access/genam.h>
#include <access/generic_xlog.h>
#include <access/htup_details.h>
#include <catalog/namespace.h>
#include <fmgr.h>
#include <miscadmin.h>
#include <storage/bufmgr.h>
#include <storage/freespace.h>
#include <storage/indexfsm.h>
#include <utils/builtins.h>
#include <utils/dsa.h>
#include <utils/lsyscache.h>
Expand Down Expand Up @@ -829,6 +832,126 @@ tp_pending_free_pages(PG_FUNCTION_ARGS)
PG_RETURN_INT64((int64)count);
}

/*
* bm25_test_recycle_tombstone_head(index_name) -> block number
*
* INTERNAL-ONLY test scaffold (issues #426, #427). Returns the
* still-live head tombstone page's block to the index FSM, then
* rebuilds the FSM upper levels so GetFreeIndexPage() can hand the
* block back out. This reproduces — without an actual crash — the
* stale-FSM / non-atomic-claim hazard that lets an allocator pick up
* a block that is still referenced by the deferred-free tombstone
* chain (or, symmetrically, the on-disk memtable chain).
*
* Superuser-only; signature and existence are subject to change or
* removal in ANY release without notice. Not part of the public API.
*/
PG_FUNCTION_INFO_V1(tp_test_recycle_tombstone_head);

Datum
tp_test_recycle_tombstone_head(PG_FUNCTION_ARGS)
{
text *index_name_text = PG_GETARG_TEXT_PP(0);
char *index_name = text_to_cstring(index_name_text);
Oid index_oid;
Relation index_rel;
BlockNumber head;

if (!superuser())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
errmsg("must be superuser to recycle tombstone head")));

index_oid = tp_resolve_index_name_shared(index_name);
if (!OidIsValid(index_oid))
ereport(ERROR,
(errcode(ERRCODE_UNDEFINED_OBJECT),
errmsg("index \"%s\" not found", index_name)));

index_rel = index_open(index_oid, RowExclusiveLock);

head = tp_tombstone_read_head(index_rel);
if (head == InvalidBlockNumber)
{
index_close(index_rel, RowExclusiveLock);
ereport(ERROR,
(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("index \"%s\" has no tombstone chain to recycle",
index_name)));
}

RecordFreeIndexPage(index_rel, head);
IndexFreeSpaceMapVacuum(index_rel);

index_close(index_rel, RowExclusiveLock);

PG_RETURN_INT64((int64)head);
}

/*
* bm25_test_corrupt_tombstone_head(index_name) -> block number
*
* INTERNAL-ONLY test scaffold (issue #427). Overwrites the head
* tombstone page's magic (WAL-logged) so it no longer validates as a
* tombstone page, simulating a chain node that a page-reuse bug
* already clobbered in production. Used to exercise the drain's
* self-healing recovery path on an already-corrupt chain.
*
* Superuser-only; signature and existence are subject to change or
* removal in ANY release without notice. Not part of the public API.
*/
PG_FUNCTION_INFO_V1(tp_test_corrupt_tombstone_head);

Datum
tp_test_corrupt_tombstone_head(PG_FUNCTION_ARGS)
{
text *index_name_text = PG_GETARG_TEXT_PP(0);
char *index_name = text_to_cstring(index_name_text);
Oid index_oid;
Relation index_rel;
BlockNumber head;
Buffer buf;
Page page;
GenericXLogState *state;
TpTombstonePage t;

if (!superuser())
ereport(ERROR,
(errcode(ERRCODE_INSUFFICIENT_PRIVILEGE),
errmsg("must be superuser to corrupt tombstone head")));

index_oid = tp_resolve_index_name_shared(index_name);
if (!OidIsValid(index_oid))
ereport(ERROR,
(errcode(ERRCODE_UNDEFINED_OBJECT),
errmsg("index \"%s\" not found", index_name)));

index_rel = index_open(index_oid, RowExclusiveLock);

head = tp_tombstone_read_head(index_rel);
if (head == InvalidBlockNumber)
{
index_close(index_rel, RowExclusiveLock);
ereport(ERROR,
(errcode(ERRCODE_OBJECT_NOT_IN_PREREQUISITE_STATE),
errmsg("index \"%s\" has no tombstone chain to corrupt",
index_name)));
}

buf = ReadBuffer(index_rel, head);
LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
state = GenericXLogStart(index_rel);
page = GenericXLogRegisterBuffer(state, buf, GENERIC_XLOG_FULL_IMAGE);
t = tp_tombstone_page(page);
t->magic = 0; /* no longer a valid tombstone page */
GenericXLogFinish(state);
UnlockReleaseBuffer(buf);

index_close(index_rel, RowExclusiveLock);

PG_RETURN_INT64((int64)head);
}

/*
* Page visualization support - only available in debug builds.
* These functions write to arbitrary file paths, so they are gated
Expand Down
149 changes: 149 additions & 0 deletions src/index/freepage.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025-2026 Tiger Data, Inc.
* Licensed under the PostgreSQL License. See LICENSE for details.
*
* freepage.c - Recyclable free-page stamping for safe FSM reuse.
*
* See freepage.h for the rationale (issues #380, #426, #427).
*/
#include <postgres.h>

#include <access/generic_xlog.h>
#include <miscadmin.h>
#include <storage/bufmgr.h>
#include <storage/indexfsm.h>

#include "constants.h"
#include "index/freepage.h"

bool
tp_page_is_recyclable(Page page)
{
TpFreePageData *f = (TpFreePageData *)PageGetContents(page);

return f->magic == TP_FREE_PAGE_MAGIC;
}

void
tp_record_free_index_page(Relation index, BlockNumber blk)
{
Buffer buf;
Page page;
PageHeader ph;
GenericXLogState *state;
TpFreePageData *f;

if (blk == TP_METAPAGE_BLKNO)
elog(ERROR, "pg_textsearch: refusing to free metapage (block 0)");

buf = ReadBuffer(index, blk);
LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);

state = GenericXLogStart(index);
page = GenericXLogRegisterBuffer(state, buf, 0);

f = (TpFreePageData *)PageGetContents(page);
f->magic = TP_FREE_PAGE_MAGIC;
f->flags = 0;
f->freed_fxid = ReadNextFullTransactionId();

/*
* Collapse the GenericXLog page hole so the stamp above lands in
* the WAL-logged lower region: computeDelta() diffs only
* [0, pd_lower) and [pd_upper, BLCKSZ), ignoring the hole in
* between. Setting pd_lower = pd_upper = pd_special = BLCKSZ
* records the whole page while leaving the body bytes untouched,
* so the delta is just the header + stamp (a few dozen bytes) even
* though a merge/vacuum may free many pages. Same page-hole
* convention as tp_tombstone_page_init.
*/
ph = (PageHeader)page;
ph->pd_lower = BLCKSZ;
ph->pd_upper = BLCKSZ;
ph->pd_special = BLCKSZ;

GenericXLogFinish(state);
UnlockReleaseBuffer(buf);

RecordFreeIndexPage(index, blk);
}

Buffer
tp_fsm_claim_free_buffer(Relation index)
{
for (;;)
{
BlockNumber blk;
Buffer buf;
Page page;

CHECK_FOR_INTERRUPTS();

blk = GetFreeIndexPage(index);
if (blk == InvalidBlockNumber)
return InvalidBuffer; /* FSM empty: caller extends */

/*
* Drop obviously bogus FSM entries. GetFreeIndexPage() has
* already marked `blk` used, so simply skipping it removes the
* entry from circulation (no infinite loop, no re-offer).
*/
if (blk == TP_METAPAGE_BLKNO ||
blk >= RelationGetNumberOfBlocks(index))
continue;

buf = ReadBuffer(index, blk);
LockBuffer(buf, BUFFER_LOCK_EXCLUSIVE);
page = BufferGetPage(buf);

if (tp_page_is_recyclable(page))
return buf; /* caller reinitializes + WAL-logs under the lock */

/*
* The FSM offered a block that is NOT a deliberately-freed
* page — a stale (crash) or double-allocated (non-atomic
* GetFreeIndexPage) entry that still points at a live
* structure page. Reusing it would corrupt that structure
* (issues #426, #427). GetFreeIndexPage already cleared the
* FSM slot, so release the page and try the next candidate.
*/
UnlockReleaseBuffer(buf);
}
}

BlockNumber
tp_fsm_claim_free_block(Relation index)
{
Buffer buf = tp_fsm_claim_free_buffer(index);
BlockNumber blk;
GenericXLogState *state;
Page page;
TpFreePageData *f;

if (!BufferIsValid(buf))
return InvalidBlockNumber;

blk = BufferGetBlockNumber(buf);

/*
* This variant releases the buffer lock before returning the block —
* the caller reinitializes the page later, under a fresh lock. To
* keep the claim atomic against a concurrent allocator that the
* non-atomic GetFreeIndexPage() handed the same block, clear the free
* stamp under the lock now (WAL-logged). That concurrent allocator
* blocks on this buffer lock, then observes a page that is no longer
* recyclable and skips it, so it cannot double-allocate the block.
* A crash between here and the caller's reinitialization only leaks
* the block (reclaimed by REINDEX); it is out of the FSM and linked
* into no structure. pd_lower is already BLCKSZ from the stamp, so
* the cleared magic lands in GenericXLog's logged region.
*/
state = GenericXLogStart(index);
page = GenericXLogRegisterBuffer(state, buf, 0);
f = (TpFreePageData *)PageGetContents(page);
f->magic = 0;
GenericXLogFinish(state);
UnlockReleaseBuffer(buf);

return blk;
}
Loading
Loading