Skip to content
Open
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
12 changes: 9 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ OBJS = \
src/access/build.o \
src/access/build_context.o \
src/access/build_parallel.o \
src/access/compaction.o \
src/access/scan.o \
src/access/vacuum.o \
src/memtable/arena.o \
Expand Down Expand Up @@ -85,19 +86,22 @@ 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 filtered_seed 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 security_acl 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 = abort aerodocs basic binary_io bmw bmw_skip_advance bulk_load cache_apply cache_memory_cap cache_source cache_spill catalog_stats chain_source compaction compression concurrent_build coverage deletion vacuum vacuum_bitmap vacuum_extended vacuum_rebuild dropped empty explicit_index expression_index filtered_seed 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 security_acl 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
PGXS := $(shell $(PG_CONFIG) --pgxs)
include $(PGXS)

# SQL regression tests
test:
test: test-compaction-ownercheck
@./test/scripts/force_merge_invariant_source.sh
@echo "Running SQL regression tests..."
@$(pg_regress_installcheck) $(REGRESS_OPTS) $(REGRESS)

test-compaction-ownercheck:
@./test/scripts/compaction_ownercheck_source.sh

# Custom local test target with dedicated PostgreSQL instance
test-local: install
@echo "Setting up temporary PostgreSQL instance for local testing..."
Expand Down Expand Up @@ -136,6 +140,7 @@ test-recovery:
@cd test/scripts && ./recovery.sh
@cd test/scripts && ./shutdown_spill.sh
@cd test/scripts && ./standby_reclaim.sh
@cd test/scripts && ./compaction_recovery.sh

test-segment:
@echo "Running multi-backend segment tests..."
Expand Down Expand Up @@ -344,6 +349,7 @@ help:
@echo ""
@echo "Testing targets:"
@echo " make test - Run source guard and SQL regression tests"
@echo " make test-compaction-ownercheck - Check compaction ownership ordering"
@echo " make installcheck - Run SQL regression tests"
@echo " make test-local - Run tests with dedicated PostgreSQL instance"
@echo " make test-all - Run all tests (SQL regression + shell scripts)"
Expand Down Expand Up @@ -378,4 +384,4 @@ help:
@echo " make test-all"
@echo " make format"

.PHONY: test clean-test-dirs installcheck test-concurrency test-recovery test-segment test-stress test-cic test-chinese test-replication test-replication-extended test-logical-replication test-multi-index test-reindex test-shell test-all expected lint-format format format-check format-diff format-single coverage coverage-build coverage-clean coverage-report help
.PHONY: test test-compaction-ownercheck clean-test-dirs installcheck test-concurrency test-recovery test-segment test-stress test-cic test-chinese test-replication test-replication-extended test-logical-replication test-multi-index test-reindex test-shell test-all expected lint-format format format-check format-diff format-single coverage coverage-build coverage-clean coverage-report help
39 changes: 39 additions & 0 deletions docs/background_compaction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Native compaction controls

pg_textsearch provides scheduler-independent SQL functions for inspecting and
compacting one BM25 index:

- `bm25_level_counts(regclass)` returns the persisted segment count for each
of the eight LSM levels.
- `bm25_needs_compaction(regclass)` reports whether any compactable level,
L0 through L6, has reached `pg_textsearch.segments_per_level`.
- `bm25_compact(regclass)` runs the full compaction cascade until no
compactable level remains over threshold. Before its first merge, it
simulates the complete cascade and rejects any destination-capacity
failure without changing the index.
- `bm25_compact_step(regclass)` runs at most one merge batch and returns
whether a batch ran.

The two mutating functions require ownership of the index. They open the
relation with `RowExclusiveLock` and hold the index's `LW_EXCLUSIVE` lock
while merging. `bm25_compact()` holds that lock for the entire cascade.
`bm25_compact_step()` releases it after one batch, allowing callers to split
a cascade across transactions.

Permanent and unlogged indexes cannot be compacted in a read-only transaction,
and no index can be compacted during recovery. A local temporary index remains
available to its owning backend and may be compacted in a read-only
transaction.

Partitioned BM25 parent indexes have no physical storage and are rejected by
all four per-index functions. Call the functions on the physical indexes of
individual partitions instead.

L7 is the terminal level and is not itself compactable.
`bm25_needs_compaction()` therefore considers only L0 through L6. If a full L7
prevents promotion from L6, `bm25_compact()` rejects the complete cascade
before any physical merge, even when valid lower-level debt precedes the
blocked L6 batch. `bm25_compact_step()` plans only its next batch, so it may
merge valid lower-level debt before a later call encounters and rejects the
blocked L6 batch. Operators must resolve such a terminal layout rather than
retrying it as ordinary compaction debt.
30 changes: 30 additions & 0 deletions sql/pg_textsearch--1.4.0--1.5.0-dev.sql
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,33 @@ BEGIN
'Add pg_textsearch to shared_preload_libraries and restart.';
END IF;
END $$;

CREATE FUNCTION @extschema@.bm25_level_counts(idx regclass)
RETURNS int[]
AS 'MODULE_PATHNAME', 'tp_level_counts'
LANGUAGE C STRICT PARALLEL SAFE;

CREATE FUNCTION @extschema@.bm25_compact(idx regclass)
RETURNS void
AS 'MODULE_PATHNAME', 'tp_compact_index'
LANGUAGE C VOLATILE STRICT;

CREATE FUNCTION @extschema@.bm25_compact_step(idx regclass)
RETURNS boolean
AS 'MODULE_PATHNAME', 'tp_compact_index_step'
LANGUAGE C VOLATILE STRICT;

CREATE FUNCTION @extschema@.bm25_needs_compaction(idx regclass)
RETURNS boolean
LANGUAGE sql STABLE
SET search_path = pg_catalog, pg_temp
AS $$
SELECT EXISTS (
SELECT 1
FROM pg_catalog.unnest(@extschema@.bm25_level_counts(idx))
WITH ORDINALITY AS t(cnt, lvl)
WHERE t.lvl <= 7
AND t.cnt >= pg_catalog.current_setting(
'pg_textsearch.segments_per_level')::int
);
$$;
30 changes: 30 additions & 0 deletions sql/pg_textsearch--1.5.0-dev.sql
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,36 @@ RETURNS void
AS 'MODULE_PATHNAME', 'tp_force_merge'
LANGUAGE C VOLATILE STRICT;

CREATE FUNCTION @extschema@.bm25_level_counts(idx regclass)
RETURNS int[]
AS 'MODULE_PATHNAME', 'tp_level_counts'
LANGUAGE C STRICT PARALLEL SAFE;

CREATE FUNCTION @extschema@.bm25_compact(idx regclass)
RETURNS void
AS 'MODULE_PATHNAME', 'tp_compact_index'
LANGUAGE C VOLATILE STRICT;

CREATE FUNCTION @extschema@.bm25_compact_step(idx regclass)
RETURNS boolean
AS 'MODULE_PATHNAME', 'tp_compact_index_step'
LANGUAGE C VOLATILE STRICT;

CREATE FUNCTION @extschema@.bm25_needs_compaction(idx regclass)
RETURNS boolean
LANGUAGE sql STABLE
SET search_path = pg_catalog, pg_temp
AS $$
SELECT EXISTS (
SELECT 1
FROM pg_catalog.unnest(@extschema@.bm25_level_counts(idx))
WITH ORDINALITY AS t(cnt, lvl)
WHERE t.lvl <= 7
AND t.cnt >= pg_catalog.current_setting(
'pg_textsearch.segments_per_level')::int
);
$$;

-- Fast summary function showing only statistics (no content dump)
CREATE FUNCTION @extschema@.bm25_summarize_index(text) RETURNS text
AS 'MODULE_PATHNAME', 'tp_summarize_index'
Expand Down
202 changes: 202 additions & 0 deletions src/access/compaction.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
/*
* Copyright (c) 2025-2026 Tiger Data, Inc.
* Licensed under the PostgreSQL License. See LICENSE for details.
*
* compaction.c - BM25 index compaction inspection and control
*/
#include <postgres.h>

#include <access/relation.h>
#include <access/xlog.h>
#include <catalog/objectaccess.h>
#include <catalog/pg_class.h>
#include <catalog/pg_type.h>
#include <miscadmin.h>
#include <utils/acl.h>
#include <utils/array.h>
#include <utils/lsyscache.h>

#include "access/am.h"
#include "constants.h"
#include "index/metapage.h"
#include "index/state.h"
#include "segment/merge.h"

/*
* Open a bm25 index by OID, validating that it is in fact a bm25
* index and (optionally) that the caller owns it.
*/
static Relation
tp_open_bm25_index(Oid indexoid, LOCKMODE lockmode, bool need_owner)
{
Relation index_rel;

/*
* Reject nonowners before queuing for a heavyweight lock. Recheck after
* opening because ALTER OWNER can complete while this caller waits.
*/
if (need_owner &&
!object_ownercheck(RelationRelationId, indexoid, GetUserId()))
{
char *relname = get_rel_name(indexoid);

if (relname == NULL)
ereport(ERROR,
(errcode(ERRCODE_UNDEFINED_OBJECT),
errmsg("relation with OID %u does not exist", indexoid)));
aclcheck_error(ACLCHECK_NOT_OWNER, OBJECT_INDEX, relname);
}

index_rel = relation_open(indexoid, lockmode);

if (index_rel->rd_indam == NULL ||
index_rel->rd_indam->ambuild != tp_build)
{
char *relname = pstrdup(RelationGetRelationName(index_rel));

relation_close(index_rel, lockmode);
ereport(ERROR,
(errcode(ERRCODE_WRONG_OBJECT_TYPE),
errmsg("\"%s\" is not a bm25 index", relname)));
}

if (index_rel->rd_rel->relkind == RELKIND_PARTITIONED_INDEX)
{
char *relname = pstrdup(RelationGetRelationName(index_rel));

relation_close(index_rel, lockmode);
ereport(ERROR,
(errcode(ERRCODE_WRONG_OBJECT_TYPE),
errmsg("\"%s\" is a partitioned bm25 index", relname),
errhint("Use a physical partition index instead.")));
}

if (need_owner &&
!object_ownercheck(RelationRelationId, indexoid, GetUserId()))
{
char *relname = pstrdup(RelationGetRelationName(index_rel));

relation_close(index_rel, lockmode);
aclcheck_error(ACLCHECK_NOT_OWNER, OBJECT_INDEX, relname);
}
Comment thread
tjgreen42 marked this conversation as resolved.

return index_rel;
}

PG_FUNCTION_INFO_V1(tp_level_counts);

Datum
tp_level_counts(PG_FUNCTION_ARGS)
{
Oid indexoid = PG_GETARG_OID(0);
Relation index_rel;
TpIndexMetaPageData *metap;
Datum elems[TP_MAX_LEVELS];
ArrayType *result;
int i;

index_rel = tp_open_bm25_index(indexoid, AccessShareLock, false);

metap = tp_get_metapage(index_rel);
for (i = 0; i < TP_MAX_LEVELS; i++)
elems[i] = Int32GetDatum((int32)metap->level_counts[i]);
pfree(metap);

relation_close(index_rel, AccessShareLock);

result = construct_array(
elems, TP_MAX_LEVELS, INT4OID, sizeof(int32), true, TYPALIGN_INT);
PG_RETURN_ARRAYTYPE_P(result);
}

PG_FUNCTION_INFO_V1(tp_compact_index);

Datum
tp_compact_index(PG_FUNCTION_ARGS)
{
Oid indexoid = PG_GETARG_OID(0);
Relation index_rel;
TpLocalIndexState *index_state;

if (RecoveryInProgress())
ereport(ERROR,
(errcode(ERRCODE_READ_ONLY_SQL_TRANSACTION),
errmsg("cannot compact a bm25 index during recovery")));

index_rel = tp_open_bm25_index(indexoid, RowExclusiveLock, true);

if (!RelationUsesLocalBuffers(index_rel))
PreventCommandIfReadOnly("bm25 index compaction");

index_state = tp_get_local_index_state(indexoid);
if (index_state == NULL)
{
char *relname = pstrdup(RelationGetRelationName(index_rel));

relation_close(index_rel, RowExclusiveLock);
ereport(ERROR,
(errcode(ERRCODE_INTERNAL_ERROR),
errmsg("could not get index state for \"%s\"", relname)));
}

tp_acquire_index_lock(index_state, LW_EXCLUSIVE);
PG_TRY();
{
tp_compaction_preflight(index_rel);
tp_maybe_compact_level(index_rel, 0);
}
PG_FINALLY();
{
tp_release_index_lock(index_state);
relation_close(index_rel, RowExclusiveLock);
}
PG_END_TRY();

PG_RETURN_VOID();
}

PG_FUNCTION_INFO_V1(tp_compact_index_step);

Datum
tp_compact_index_step(PG_FUNCTION_ARGS)
{
Oid indexoid = PG_GETARG_OID(0);
Relation index_rel;
TpLocalIndexState *index_state;
bool batch_ran;

if (RecoveryInProgress())
ereport(ERROR,
(errcode(ERRCODE_READ_ONLY_SQL_TRANSACTION),
errmsg("cannot compact a bm25 index during recovery")));

index_rel = tp_open_bm25_index(indexoid, RowExclusiveLock, true);

if (!RelationUsesLocalBuffers(index_rel))
PreventCommandIfReadOnly("bm25 index compaction");

index_state = tp_get_local_index_state(indexoid);
if (index_state == NULL)
{
char *relname = pstrdup(RelationGetRelationName(index_rel));

relation_close(index_rel, RowExclusiveLock);
ereport(ERROR,
(errcode(ERRCODE_INTERNAL_ERROR),
errmsg("could not get index state for \"%s\"", relname)));
}

tp_acquire_index_lock(index_state, LW_EXCLUSIVE);
PG_TRY();
{
batch_ran = tp_compact_step(index_rel);
}
PG_FINALLY();
{
tp_release_index_lock(index_state);
relation_close(index_rel, RowExclusiveLock);
}
PG_END_TRY();

PG_RETURN_BOOL(batch_ran);
}
Loading
Loading