Skip to content

Rolled-back DROP INDEX frees shared state: cached backends crash (SIGSEGV/SIGBUS) #506

Description

@loickouao

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions