SpongeRobert opened a new issue, #2580:
URL: https://github.com/apache/age/issues/2580

   **Versions.** PostgreSQL 18.6, Apache AGE at the head of branch `PG18`,
   `e43dc1a12b78fba4acef9835b2b10379b8d243b4` (the 1.8.0 release commit, head on
   2026-09-24), `shared_preload_libraries = 'age'`. The extension does not have 
to
   be created in any database. `is_age_extension_exists()` is the same on 
`master`
   (`fa109ef1dd`); the check came with `a17bf457fe` ("Make age extension usable
   from shared_preload_libraries").
   
   **Symptom.** Backends fail at connect with `FATAL: failed to re-find shared
   lock object` (SQLSTATE XX000), in every database and under every role, until
   the server restarts. Later the server log may also show
   `lock AccessShareLock on object <db>/3079/0 is already held`.
   
   **What happens.**
   
   1. A session fails a statement inside `BEGIN` and waits for `ROLLBACK`
      (`TBLOCK_ABORT`: `AbortTransaction()` has run, the resource owner of the
      transaction is released).
   2. Its caches are reset: the shared invalidation queue overflowed while the
      backend was not reading it. The relcache callback clears
      `extension_cache_is_valid`, and the catcache entry of `pg_extension` is 
gone.
   3. `ROLLBACK` is parsed. `post_parse_analyze` calls 
`is_age_extension_exists()`,
      which calls `get_extension_oid("age", true)`. The catcache miss opens
      `pg_extension` and then `pg_class`. Their locks are granted on the fast 
path,
      then `ResourceOwnerEnlarge()` fails:
      `ERROR: ResourceOwnerEnlarge called after release started`.
   4. Nothing releases those locks any more. In `TBLOCK_ABORT` the transaction 
is
      already aborted, so the backend exits with the two relations still in the
      fast-path array of its PGPROC.
   5. The next backend on that PGPROC takes the same relation. It reuses the 
slot
      without counting it, and its `LockRelease()` goes to the main lock table,
      which has no entry: `FATAL: failed to re-find shared lock object` at 
connect.
      Its exit does not clear the slot, so every later occupant fails the same 
way.
   
   The core does not read the catalog in an aborted transaction. The hook does 
so
   on every parse, `ROLLBACK` included.
   
   **Evidence.**
   
   - **An assertion of the core.** PostgreSQL 18.6 built from `REL_18_6` with
     `--enable-cassert --enable-debug`, AGE at `e43dc1a`, no patch. The first
     reproduction run aborts backend A on its `ROLLBACK`:
   
     ```
     TRAP: failed Assert("IsTransactionState()"), File: "relcache.c", Line: 2073
     postgres: ... ROLLBACK(ExceptionalCondition+0xa8)
     postgres: ... ROLLBACK(AssertCouldGetRelation+0x2e)
     ...
     postgres: ... ROLLBACK(SearchCatCache+0x45)
     postgres: ... ROLLBACK(SearchSysCache+0x95)
     postgres: ... ROLLBACK(GetSysCacheOid+0x3e)
     postgres: ... ROLLBACK(get_extension_oid+0x40)
     age.so(+0x11d3f)
     age.so(+0x2900f)
     postgres: ... ROLLBACK(parse_analyze_fixedparams+0xe5)
     LOG:  client backend (PID 109) was terminated by signal 6: Aborted
     DETAIL:  Failed process was running: ROLLBACK;
     ```
   
     `AssertCouldGetRelation()` is documented as "In code that reads catalogs in
     the event of a cache miss, call this before checking the cache". The same
     build with the fix below gives no TRAP, no error on `ROLLBACK` and no 
XX000.
   
   - **The stack on a release build at the first error.** Taken with gdb on the
     backend of session A, with pgdg debug symbols; the breakpoint is `errstart`
     at level `ERROR`:
   
     ```
     #0  errstart (elevel=21, domain=0x0) at elog.c:354
     #1  errstart_cold (elevel=21, domain=0x0) at elog.c:329
     #2  ResourceOwnerEnlarge (owner=<optimized out>) at resowner.c:469
     #3  RelationIncrementReferenceCount (rel=0x795a669e11b8) at relcache.c:2191
     #4  RelationIdGetRelation (relationId=1259) at relcache.c:2121
     #5  relation_open (relationId=1259, lockmode=1) at relation.c:58
     #6  table_open (relationId=1259, lockmode=1) at table.c:44
     #7  ScanPgRelation (targetRelId=3079, indexOK=true, 
force_non_historic=false) at relcache.c:371
     #8  RelationBuildDesc (targetRelId=<optimized out>, insertIt=true) at 
relcache.c:1115
     #9  RelationIdGetRelation (relationId=3079) at relcache.c:2144
     #10 relation_open (relationId=3079, lockmode=1) at relation.c:58
     #11 table_open (relationId=<optimized out>, lockmode=1) at table.c:44
     #12 SearchCatCacheMiss (cache=0x5c875436e300, nkeys=1, ...) at 
catcache.c:1554
     #13 SearchCatCacheInternal (...) at catcache.c:1499
     #14 SearchCatCache (...) at catcache.c:1357
     #15 SearchSysCache (key1=1, cacheId=<optimized out>) at syscache.c:217
     #16 GetSysCacheOid (cacheId=27, oidcol=1, ...) at syscache.c:459
     #17 get_extension_oid (extname="age", missing_ok=true) at extension.c:192
     #18 is_age_extension_exists () at src/backend/catalog/ag_catalog.c:88
     #19 is_age_extension_exists () at src/backend/catalog/ag_catalog.c:74
     #20 post_parse_analyze (pstate=..., query=..., jstate=<optimized out>) at 
src/backend/parser/cypher_analyze.c:90
     #21 parse_analyze_fixedparams (..., sourceText="ROLLBACK;", ...) at 
analyze.c:128
     #22 pg_analyze_and_rewrite_fixedparams (...) at postgres.c:683
     #23 exec_simple_query (query_string="ROLLBACK;") at postgres.c:1190
     ```
   
   - **The reproduction** (`repro.sh` below: psql, pgbench and kill, on a fresh
     server). Ten fresh servers per row:
   
     | Build | `rollback` | `quit` (control: the session leaves without 
ROLLBACK) |
     |---|---|---|
     | AGE `e43dc1a` as is | 10 of 10 reproduced | 0 of 10 |
     | the same with the fix below | 0 of 10 | not run |
   | AGE `master` (`fa109ef1dd`) as is | 10 of 10 reproduced | not run |
   | `master` with the fix | 0 of 10 | not run |
   
     Without the overflow of the invalidation queue (step 2) it is 0 of 10: the
     cache of `is_age_extension_exists()` is still valid, so the catalog is not
     read.
   
   **Suggested fix** (`fix.diff` below, against `e43dc1a`; it applies to
   `master` with an offset of 2 lines, and `make installcheck` on `master` with 
it
   passes all 42 tests). A pull request against `master` follows. At the top of 
`is_age_extension_exists()`:
   
   ```c
   if (!IsTransactionState())
       return false;
   ```
   
   Nothing is cached, so the next live transaction asks the catalog again. In an
   aborted transaction only the statements that leave it reach the parser, and 
AGE
   has nothing to analyse in them, so "no extension" is the right answer for 
that
   parse.
   
   The other callers of `is_age_extension_exists()` are `is_age_drop()` (a
   `DROP` statement), `object_access()` (DDL) and `set_rel_pathlist()`
   (planning). By our reading of `exec_simple_query()`, an aborted transaction
   rejects those statements before parse analysis, so only `post_parse_analyze`
   reaches the catalog there. That is from reading the code: we reproduced and
   traced only the `post_parse_analyze` path. The fix covers every caller 
anyway.
   
   Related: #2520, another hook of AGE that misbehaves when `age` is in
   `shared_preload_libraries`.
   
   <details><summary><code>repro.sh</code></summary>
   
   ```bash
   #!/usr/bin/env bash
   # Reproduction: the post_parse_analyze hook of Apache AGE reads pg_extension
   # while parsing the ROLLBACK of a failed transaction; the locks it takes leak
   # into the PGPROC, and the next backend on that PGPROC fails at connect with
   # "FATAL: failed to re-find shared lock object".
   #
   # Needs: a FRESH server with shared_preload_libraries = 'age' (the extension
   # does not have to be created), a superuser, psql and pgbench, and a shell on
   # the server host as the OS user of the server (step 2 stops a backend with
   # kill). Connection parameters come from the usual PG* environment variables.
   # A poisoned PGPROC lives until the server restarts: use a throwaway server.
   #
   # Exit code: 1 when XX000 was seen (reproduced), 0 when every connection of
   # step 4 succeeded, 3 when the run could not be carried out.
   set -uo pipefail
   export PGUSER="${PGUSER:-postgres}" PGDATABASE="${PGDATABASE:-postgres}"
   END="${1:-rollback}" # rollback | quit (the control: the session leaves 
without ROLLBACK)
   
   fifo=$(mktemp -u); out=$(mktemp); mkfifo "$fifo"
   trap 'rm -f "$fifo" "$out" /tmp/age-repro-one.sql' EXIT
   
   # 1. Session A fails a statement inside a transaction and stays in the
   #    failed block; its input is a fifo, so it stays open between the steps.
   psql -XAt <"$fifo" >"$out" 2>&1 &
   exec 3>"$fifo"
   echo "SELECT pg_backend_pid();" >&3
   for _ in $(seq 50); do pid=$(grep -m1 -E '^[0-9]+$' "$out") && break; sleep 
0.1; done
   [ -n "${pid:-}" ] || { echo "session A did not report its pid"; exit 3; }
   echo "BEGIN; SELECT 1/0;" >&3
   sleep 0.3
   
   # 2. Its backend is stopped while another session overflows the shared
   #    invalidation queue; on SIGCONT backend A resets its caches, the cached
   #    "is age installed" flag and the catcache entry of pg_extension among 
them.
   kill -STOP "$pid"
   psql -Xq -c "DO \$\$ BEGIN
        FOR i IN 1..1000 LOOP EXECUTE format('CREATE TABLE age_repro_%s (a 
int)', i); END LOOP;
        FOR i IN 1..1000 LOOP EXECUTE format('DROP TABLE age_repro_%s', i); END 
LOOP;
   END \$\$" >/dev/null || { kill -CONT "$pid"; echo "the overflow did not 
run"; exit 3; }
   kill -CONT "$pid"
   sleep 0.3
   
   # 3. ROLLBACK is parsed: the hook reads pg_extension outside a live 
transaction
   #    and fails with "ResourceOwnerEnlarge called after release started".
   [ "$END" = rollback ] && echo "ROLLBACK;" >&3
   echo '\q' >&3
   exec 3>&-
   wait
   grep -E 'ResourceOwnerEnlarge|ERROR' "$out" || true
   
   # 4. Every free PGPROC is taken at once (the free list is FIFO, so the one
   #    session A gave back is taken last).
   max=$(psql -XAtc "SHOW max_connections")
   used=$(psql -XAtc "SELECT count(*) FROM pg_stat_activity WHERE backend_type 
= 'client backend'")
   n=$((max - used))
   echo 'SELECT 1;' >/tmp/age-repro-one.sql
   res=$(pgbench -n -c "$n" -j 8 -t 1 -f /tmp/age-repro-one.sql 2>&1)
   if grep -qE 'failed to re-find shared (lock|proclock) object' <<<"$res"; then
        grep -m1 -E 'failed to re-find' <<<"$res"
        echo "REPRODUCED (end=$END, connections=$n)"; exit 1
   fi
   if grep -q "actually processed: $n/$n\$" <<<"$res"; then
        echo "not reproduced (end=$END, connections=$n)"; exit 0
   fi
   echo "the saturation did not run as intended:"; tail -3 <<<"$res"; exit 3
   ```
   
   </details>
   
   <details><summary><code>fix.diff</code> (against 
<code>e43dc1a</code>)</summary>
   
   ```diff
   --- a/src/backend/catalog/ag_catalog.c
   +++ b/src/backend/catalog/ag_catalog.c
   @@ -75,6 +75,15 @@
    {
        static bool callback_registered = false;
    
   +    /*
   +     * Outside a live transaction (the ROLLBACK of a failed one) the catalog
   +     * must not be read: the resource owner is already released, and the 
locks
   +     * a catalog scan takes here would leak into the PGPROC. Nothing is 
cached,
   +     * so the next live transaction asks the catalog again.
   +     */
   +    if (!IsTransactionState())
   +        return false;
   +
        if (extension_cache_is_valid)
            return age_extension_exists;
   ```
   
   </details>
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to