Skip to content

POC: background BM25 compaction via pg_durable - #471

Draft
tjgreen42 wants to merge 60 commits into
mainfrom
task2-bm25-compact
Draft

POC: background BM25 compaction via pg_durable#471
tjgreen42 wants to merge 60 commits into
mainfrom
task2-bm25-compact

Conversation

@tjgreen42

@tjgreen42 tjgreen42 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Status

This branch is a preserved proof of concept and umbrella, not a merge
candidate. It demonstrates background BM25 compaction through pg_durable and
records the design, measurements, and hardening work from the experiment.

The mergeable implementation is being rebuilt as five focused changes:

  1. compaction engine correctness;
  2. native per-index compaction APIs;
  3. scheduler-neutral transaction dispatch;
  4. the pg_durable per-index adapter; and
  5. the recurring recovery backstop and operator packaging.

The extraction contract is
docs/superpowers/specs/2026-08-29-background-compaction-pr-series-design.md.

POC behavior

Spills in background mode record transaction-local requests. At PRE_COMMIT,
the configured callback submits pg_durable work with
transaction_mode => 'new'. The durable stepped cascade performs one merge
batch per transaction.

Default behavior is unchanged: pg_textsearch.compaction_mode defaults to
inline.

Documentation

  • Architecture and behavior: docs/background_compaction.md
  • Operator setup: scripts/durable_compaction/README.md

Important limitations

Extraction PRs

  1. #474: Enforce compaction capacity invariants
  2. #475: Add per-index compaction controls
  3. #477: Dispatch compaction requests at commit
  4. #476: Add pg_durable compaction adapter
  5. #478: Add durable compaction recovery backstop

Upstream dependency:
microsoft/pg_durable#354.

Add pg_textsearch.compaction_request_function, naming a function of one
regclass argument that is invoked once per index that requested
compaction during the transaction. In background compaction mode the
spill path enqueues a request instead of compacting inline, and the
pre-commit hook dispatches it, so the request commits atomically with
the writer's transaction and rolls back with it.

The dispatch runs after tp_bulk_load_spill_check(), because that check
can itself spill and therefore enqueue a request.

Two details of the pre-commit environment shape the implementation:

- PreCommit_Portals() has already torn down the portal, so no active
  snapshot remains for SPI. One is pushed for the duration of the call.

- The dispatch is wrapped in two nested subtransactions rather than
  one. At pre-commit the top-level blockState is TBLOCK_END, which
  BeginInternalSubTransaction() accepts but which the assertion at the
  tail of RollbackAndReleaseCurrentSubTransaction() omits from the
  states a parent may be in, so rolling a single subtransaction back
  from here crashes an assert-enabled build. Stock PostgreSQL trips the
  same assertion when a deferred constraint trigger written in PL/pgSQL
  catches an error, so this is an upstream oversight rather than a
  misuse. An outer subtransaction that performs no work of its own
  gives the inner rollback a TBLOCK_SUBINPROGRESS parent, which the
  assertion accepts.

A failing request function is therefore isolated: it warns and the
writer still commits. Parallel workers, autovacuum, recovery and
prepared transactions never dispatch.
Adds scripts/durable_compaction/, the operator-side glue that lets
pg_textsearch.compaction_mode = 'background' hand segment compaction
to a pg_durable durable task.

- 01_setup_role.sql creates the textsearch_compactor role (LOGIN,
  NOSUPERUSER, INHERIT), grants df usage, and gives it the index
  owner's privileges.
- 02_wrapper.sql defines bm25_request_compaction(regclass), a
  SECURITY DEFINER wrapper that builds the DSL itself and drives
  bm25_compact_step() through df.loop(), so each merge batch runs in
  its own transaction.
- 03_backstop.sql starts the hourly bm25_compact_pending() sweep as
  the compactor.
- README.md documents setup order, the passwordless pg_hba
  requirement, shared_preload_libraries, the asynchronous bgworker
  init race, and the GUCs.

The role cannot be called pg_textsearch_compactor: PostgreSQL
reserves the pg_ role namespace.
Two gaps found while verifying the pg_durable glue end to end.

pg_textsearch.segments_per_level is read in two different sessions:
the writer's, when deciding whether to enqueue a request at
PRE_COMMIT, and the compactor's worker session, when evaluating the
df.loop condition. Setting it only on the writer produces a task that
starts, finds nothing to do against the default threshold and reports
'completed' while the level counts never move -- a silent no-op that
looks like success. Document the split and show setting it at a scope
the worker inherits.

df.nodes.error is always empty in pg_durable 0.2.6; a failed node
carries status='failed' with no message, and status_details holds only
the execution id. The real error is only in the Postgres server log,
so the Operating query no longer selects a column that is always
blank, and the limitation records where to look instead.
Add test/scripts/durable_compaction.sh, an end-to-end shell test that
brings up its own PostgreSQL instance with pg_durable and pg_textsearch
preloaded, wires up the three glue scripts in
scripts/durable_compaction/ as an operator would, and exercises every
criterion from the background compaction plan: atomicity of the
enqueue-at-commit, that background mode actually drops segment
counts, inline vs background write latency, the failure/self-heal
path when compactor ownership is revoked and restored, a multi-level
cascade that splits across transactions while a concurrent writer
commits, a writer with EXECUTE-only privileges still enqueuing an
attributed task, and the scheduled backstop sweep compacting an index
with compaction_mode = 'off' and no writer involvement.

Add a test-durable Makefile target that runs the new script.  It is
deliberately not wired into test-all/test-shell since CI has no
pg_durable.

Parameterize scripts/durable_compaction/03_backstop.sql with an
optional -v cron=... override (default unchanged) so the test can run
the sweep on a fast cadence; the value is threaded through a custom
GUC because psql's :'var' interpolation does not reach inside the
script's DO $$ ... $$ body.
The durable compaction test invoked initdb/pg_ctl/psql/createdb bare,
so it silently used whichever Postgres happened to be first on PATH.
On a machine whose PATH leads with a system Postgres the cluster died
at startup with only "could not access file: pg_durable" in a log
under a temp directory, several layers away from the real cause.

Pin every binary to pg_config --bindir, matching the convention
already used in shutdown_spill.sh. That is the correct coupling: the
install pg_config points at is the one make install populated, and
therefore the only $libdir where pg_textsearch.so is discoverable.

Add a preflight check that both modules are present in
pg_config --pkglibdir and, if not, name the missing module, the
directory searched and the pg_config responsible.

Also rename the backstop's cadence GUC from pg_durable_test.cron to
pg_textsearch_backstop.cron; the override is a supported feature of a
shipped script, not a test-only hook, and the old name implied
otherwise.
The latency benchmark only averaged spill-round transactions, and
most spill rounds merge nothing. That measures the fixed enqueue cost
background mode adds to every request and almost nothing else, so it
concluded background was uniformly slower and that no crossover scale
existed. The enqueue cost is real -- about 0.06s -- but it is not the
cost this design exists to remove.

Add a second scenario that isolates the transaction that does cross
the threshold: pile up segments with compaction off, then time the
single commit that triggers the cascade. Over 200,000 rows in eight
segments it measures 0.982s inline against 0.084s background, and at
segments_per_level = 2, forcing a deeper cascade, 2.794s against
0.097s. Both modes converge on the same final level layout, so the
background path defers the merge rather than skipping it.

Two bugs made the first version of this scenario report nonsense.
memtable_pages_threshold and bulk_load_threshold are PGC_SUSET, so
the per-session SET as app_owner failed 18 times; because the failures
happened inside a command substitution the script still exited 0 and
printed 0.006s for both modes. The thresholds now go through
ALTER SYSTEM like every other GUC here, and the scenario asserts it
actually built the segments it is about to merge instead of silently
timing an empty one.

Correct the report and the operator doc accordingly: state the trade
as a latency-variance trade with a table of both cases, and tell
operators the scale below which background mode costs more than it
saves.
Add a self-contained, three-act demo of pg_durable-driven background
BM25 segment compaction:

- Act 1: an inline-mode INSERT blows through a calibrated
  statement_timeout; the merge still completes (segment merges are
  non-transactional GenericXLog page mutations), so the client pays
  the full cost and still gets an error.
- Act 2: the identical insert under background mode commits well
  inside the same timeout, and the merge is verified to actually
  happen via level counts (not just instance status).
- Act 3: concurrent ranking-invariance, no-torn-reads, atomicity,
  and writer-latency checks against a live cascade, all as hard
  assertions confirmed to run while the cascade is still 'running'.

Also documents two findings from running it: inline mode's
statement_timeout does not roll back already-applied merge work, and
the measured max concurrent-writer latency (2.802s) under a held
LW_EXCLUSIVE merge batch, in docs/background_compaction_report.md.
The demo established that cancelling an inline compaction leaves the
merge applied, and recorded the root cause as undetermined. It is
determinable, and it is sharper than "segment pages are not
transactional".

merge.c calls CHECK_FOR_INTERRUPTS() every 1000 terms, but it does so
while the backend holds the per-index LW_EXCLUSIVE, and
LWLockAcquire() calls HOLD_INTERRUPTS() precisely so that interrupts
cannot interfere with shared memory manipulation
(lwlock.c:1216-1219). CHECK_FOR_INTERRUPTS() is a no-op while
InterruptHoldoffCount is non-zero, so every one of those checks is
dead code until LWLockRelease() resumes interrupts. Corpus size is
not the limiting factor: the demo dictionary is about 25,600 terms,
so the checks are reached thousands of times and simply cannot act.

This matters beyond the demo. statement_timeout cannot bound a
client's exposure to an inline merge -- it waits for the whole merge,
receives an error, and the compaction is applied anyway, so the write
is lost and the work is not. A writer stalled behind a merge batch is
uncancellable for the same reason. That is a stronger argument for
background mode than the latency table.

Record the consequences, including that the interrupt checks are
misleading as written and become meaningful only once a merge builds
its output without holding the lock, which is the direction "Future
work: non-blocking merges" already describes.

Also report the concurrent-writer stall as a range. An independent
rerun measured 4.471s where the first measured 2.802s; quoting one
figure implied a stability the measurement does not have.
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.67583% with 78 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/access/compaction.c 65.18% 47 Missing ⚠️
src/segment/merge.c 91.54% 12 Missing ⚠️
src/index/compaction_request.c 93.19% 10 Missing ⚠️
src/mod.c 62.50% 6 Missing ⚠️
src/access/build.c 96.22% 2 Missing ⚠️
src/index/metapage.c 83.33% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

pgspot rejected the extension SQL: bm25_needs_compaction(),
bm25_indexes_needing_compaction() and bm25_compact_pending() are the
first SQL- and plpgsql-bodied functions in this extension, so they are
the first to be subject to its search_path rules.  Without an explicit
SET clause every unqualified operator and reference in their bodies is
resolvable through the caller's search_path, which is a search_path
injection vector; CAST(c.oid AS regclass) was reported as a hard error
and eleven further findings as warnings, and pgspot fails on either.

Pin search_path on all three, and additionally qualify the two
references pgspot named (pg_catalog.unnest, pg_catalog.regclass) so the
error stays fixed even if a SET clause is later dropped.  The remaining
findings were operator references, which the pin resolves without
having to spell every operator as OPERATOR(pg_catalog.=).

Apply the same change to the 1.4.0 upgrade script, which ships these
definitions verbatim, and extend both pgspot workflows to check the
upgrade script into the current version -- previously only the base
install file was checked, so an upgrading user could have received SQL
that CI never looked at.  Already-released upgrade scripts stay
excluded: they are frozen and carry pre-existing warnings.

The compaction test replaced bm25_indexes_needing_compaction() with a
stub that referenced its target index unqualified.  A stub without a
SET clause inherits the pinned path of its plpgsql caller, so the name
no longer resolved and the FOR loop query failed during planning --
outside the per-index exception handler the test exists to exercise.
Give the stub the same pinned path as the function it stands in for and
qualify the reference, restoring the intended warn-and-continue path.

Note that a SET clause makes a SQL function ineligible for inlining.
Both functions are called once per sweep or loop iteration, so this
costs nothing measurable here.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant