Bug Description
A rolled-back DROP INDEX on a bm25 index frees the index's shared state while the index survives. Backends that had already used the index keep a pointer to the freed memory and crash on their next BM25 query (SIGSEGV on x86_64, SIGBUS on aarch64). With restart_after_crash=off (CloudNativePG), one such crash restarts the whole instance.
tp_object_access (OAT_DROP) calls tp_cleanup_index_shared_memory() synchronously, inside the dropping transaction: it unregisters the index and dsa_frees its TpSharedIndexState and memtable immediately. If that transaction then aborts, the catalog is restored but the shared state is not.
Two things then go wrong:
- Other backends keep the index's
TpLocalIndexState in local_state_cache (TopMemoryContext, keyed by OID) and never revalidate local_state->shared. Only the dropping backend evicts its own entry.
- The comment in
tp_cleanup_index_shared_memory ("DROP INDEX runs under AccessExclusiveLock on the index, so no concurrent backend can be reading the cache here") holds only until the lock is released. After a rollback, readers come back to the same OID.
We hit this in production through a partition-swap transaction (DETACH PARTITION → DROP INDEX old → … → ATTACH PARTITION) with a lock_timeout that rolled back after the DROP INDEX. The next BM25 queries on the old (still live) index crashed. It happened on two different nights, and it is still present on main (a991f24).
Environment
- PostgreSQL version: 17.9 (CloudNativePG image, Debian bookworm)
- pg_textsearch version: 1.4.0, in
shared_preload_libraries
- Operating system: Debian 12; prod on aarch64 (Graviton), reproduced on x86_64
Steps to Reproduce
Three sessions. It crashes every time for us:
-- setup
CREATE EXTENSION pg_textsearch;
CREATE TABLE docs (id int, txt text);
INSERT INTO docs SELECT i, 'drug trial phase ' || i || ' result ' || md5(i::text) FROM generate_series(1, 20000) i;
CREATE INDEX docs_bm25_idx ON docs USING bm25 (txt) WITH (text_config = 'english');
-- session R: use the index once (caches its local state)
SELECT count(*) FROM (SELECT id FROM docs ORDER BY txt <@> to_bm25query('drug trial', 'docs_bm25_idx') LIMIT 5) s;
-- session S: drop it, then roll back
BEGIN;
DROP INDEX docs_bm25_idx;
ROLLBACK;
-- the index still exists
-- session W: any write into the index
INSERT INTO docs SELECT i, 'drug trial late ' || i FROM generate_series(20001, 20100) i;
-- session R again
SELECT count(*) FROM (SELECT id FROM docs ORDER BY txt <@> to_bm25query('drug trial', 'docs_bm25_idx') LIMIT 5) s;
-- server closed the connection unexpectedly
A fresh connection running the same query afterwards works: it finds no registry entry and rebuilds the state from disk.
Expected Behavior
Rolling back a DROP INDEX leaves the index fully usable by every backend.
Actual Behavior
LOG: server process (PID 40) was terminated by signal 11: Segmentation fault
DETAIL: Failed process was running: SELECT count(*) FROM (SELECT id FROM docs ORDER BY txt <@> to_bm25query('drug trial', 'docs_bm25_idx') LIMIT 5) s
Production backtrace (aarch64 core, SIGBUS BUS_ADRALN):
#0 __aarch64_cas4_acq_rel
#3 LWLockAttemptLock (lock=0xfff75cfcf741) <- misaligned
#5 dshash_find_or_insert (hash_table->control = <segment base + 0x11>)
#6 tp_get_or_create_posting_list src/memtable/stringtable.c:368
#7 tp_cache_apply_document src/memtable/stringtable.c:428
#8 apply_one_record src/memtable/cache.c:558
#9 tp_cache_cold_build src/memtable/cache.c:858
#11 tp_memtable_cache_source_create src/memtable/cache_source.c:389
#13 tp_score_documents / bm25_text_bm25query_score
In the core, *local_state->shared is all zeros (index_oid = 0, memtable_dp = 0), and so is the memtable. string_hash_handle is therefore garbage, which gives the misaligned dshash control pointer. The crashing relation is the old index. The new index built for the swap is still present under its temporary name, which confirms the swap transaction had not committed.
Suggested fix
Defer the free to commit. For example, queue the OID in the drop hook and release the shared state from a XACT_EVENT_COMMIT callback, discarding the queue on abort, the same way PostgreSQL defers file unlinks with smgr pending deletes. Validating a cached local_state against the registry (for instance a generation counter, or shared->index_oid == oid) would also stop other backends from dereferencing freed memory.
Workaround on our side: never let a transaction that drops a bm25 index do anything that can fail after the DROP INDEX. We now drop the old index in its own transaction after the swap commits.
Bug Description
A rolled-back
DROP INDEXon a bm25 index frees the index's shared state while the index survives. Backends that had already used the index keep a pointer to the freed memory and crash on their next BM25 query (SIGSEGV on x86_64, SIGBUS on aarch64). Withrestart_after_crash=off(CloudNativePG), one such crash restarts the whole instance.tp_object_access(OAT_DROP) callstp_cleanup_index_shared_memory()synchronously, inside the dropping transaction: it unregisters the index anddsa_frees itsTpSharedIndexStateand memtable immediately. If that transaction then aborts, the catalog is restored but the shared state is not.Two things then go wrong:
TpLocalIndexStateinlocal_state_cache(TopMemoryContext, keyed by OID) and never revalidatelocal_state->shared. Only the dropping backend evicts its own entry.tp_cleanup_index_shared_memory("DROP INDEX runs under AccessExclusiveLock on the index, so no concurrent backend can be reading the cache here") holds only until the lock is released. After a rollback, readers come back to the same OID.We hit this in production through a partition-swap transaction (
DETACH PARTITION→DROP INDEX old→ … →ATTACH PARTITION) with alock_timeoutthat rolled back after theDROP INDEX. The next BM25 queries on the old (still live) index crashed. It happened on two different nights, and it is still present onmain(a991f24).Environment
shared_preload_librariesSteps to Reproduce
Three sessions. It crashes every time for us:
A fresh connection running the same query afterwards works: it finds no registry entry and rebuilds the state from disk.
Expected Behavior
Rolling back a
DROP INDEXleaves the index fully usable by every backend.Actual Behavior
Production backtrace (aarch64 core, SIGBUS
BUS_ADRALN):In the core,
*local_state->sharedis all zeros (index_oid = 0,memtable_dp = 0), and so is the memtable.string_hash_handleis therefore garbage, which gives the misaligneddshashcontrol pointer. The crashing relation is the old index. The new index built for the swap is still present under its temporary name, which confirms the swap transaction had not committed.Suggested fix
Defer the free to commit. For example, queue the OID in the drop hook and release the shared state from a
XACT_EVENT_COMMITcallback, discarding the queue on abort, the same way PostgreSQL defers file unlinks withsmgrpending deletes. Validating a cachedlocal_stateagainst the registry (for instance a generation counter, orshared->index_oid == oid) would also stop other backends from dereferencing freed memory.Workaround on our side: never let a transaction that drops a bm25 index do anything that can fail after the
DROP INDEX. We now drop the old index in its own transaction after the swap commits.