TRUNCATE fails with `schema "ag_catalog" does not exist` in databases without AGE, when AGE is in shared_preload_libraries
- Dominant language
- C
- Stars
- 4.8k
- Forks
- 523
- Avg merge
- 1d 2h
- Merged PRs (30d)
- 9
Description
## Summary
When `age` is in `shared_preload_libraries`, **`TRUNCATE` fails in any database that does not have the AGE extension installed**:
```
ERROR: schema "ag_catalog" does not exist
```
This makes AGE unsafe to preload on a shared PostgreSQL cluster: a single database wanting graph support breaks `TRUNCATE` for every other database in the instance.
This is the same class of problem as #2180 (fixed for the `object_access_hook` path by #2161), but on a different code path that was added later and does not have the guard.
## Environment
- PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2), x86_64
- Apache AGE 1.8.0 (`postgresql-18-pgdg-age`, PGDG apt)
- `shared_preload_libraries = age`
## Reproduction
```console
$ psql -U postgres -c "CREATE DATABASE app;"
CREATE DATABASE
$ psql -U postgres -d app -c "CREATE TABLE t (i int);"
CREATE TABLE
$ psql -U postgres -d app -c "TRUNCATE t;"
ERROR: schema "ag_catalog" does not exist
```
The database `app` never had `CREATE EXTENSION age` run in it.
Installing the extension in that database makes it work again:
```console
$ psql -U postgres -d app -c "CREATE EXTENSION age;"
CREATE EXTENSION
$ psql -U postgres -d app -c "TRUNCATE t;"
TRUNCATE TABLE
```
Only `TRUNCATE` is affected. `CREATE TABLE` / `INSERT` / `UPDATE` / `DELETE` / `CREATE INDEX` / `ALTER TABLE` / `VACUUM` / `ANALYZE` / `REINDEX` / `CLUSTER` / `DROP TABLE` / `COPY` / `CREATE EXTENSION ` / `pg_dump` / `pg_restore` all behave normally in the same database.
## Root cause
`ag_ProcessUtility_hook()` in `src/backend/catalog/ag_catalog.c` handles `T_TruncateStmt` and calls `get_graph_oid_for_table()` — which resolves `ag_catalog` — **without first checking whether the AGE extension exists in the current database**:
```c
case T_TruncateStmt:
{
TruncateStmt *tstmt = (TruncateStmt *) parsetree;
ListCell *lc;
foreach(lc, tstmt->relations)
{
RangeVar *rv = (RangeVar *) lfirst(lc);
Oid rel_oid = RangeVarGetRelid(rv, AccessShareLock, true);
if (OidIsValid(rel_oid))
{
Oid graph_oid = get_graph_oid_for_table(rel_oid); /* <-- resolves ag_catalog */
if (OidIsValid(graph_oid))
{
increment_graph_version(graph_oid);
}
}
}
}
break;
```
The file already provides the guard for exactly this purpose — `is_age_extension_exists()`, with the comment:
> `We don't want most of hooks to do anything if the "age" extension isn't created.`
but in the `PG18` branch it is only called from `is_age_drop()` and `object_access()` — the `T_TruncateStmt` branch (lines 172–202) calls `get_graph_oid_for_table()` with no guard at all. Since `shared_preload_libraries` loads the library instance-wide, the hook runs in every database, including those where `ag_catalog` does not exist.
## Suggested fix
Bail out early in the `T_TruncateStmt` branch, consistent with how the other hooks guard themselves:
```c
case T_TruncateStmt:
{
TruncateStmt *tstmt = (TruncateStmt *) parsetree;
ListCell *lc;
if (!is_age_extension_exists())
break;
...
}
```
Guarding the whole `switch` (or the hook entry point) would also work and would protect any future branch from the same mistake.
## Impact / workaround
Workaround is to run `CREATE EXTENSION age` in **every** database of the instance, including `template1` so that newly created databases inherit it. That is fragile: databases created with `TEMPLATE template0` — which `pg_dump`/`pg_restore` output commonly uses — do not inherit it, and `TRUNCATE` starts failing there with an error that gives no hint that AGE is involved.
Happy to submit a PR if the maintainers agree with the approach.
Contributor guide
Research direction
Start in src/backend/catalog/ag_catalog.c at the ag_ProcessUtility_hook() T_TruncateStmt branch, then read is_age_extension_exists() and the nearby guarded hook paths. Reproduce TRUNCATE in a database without AGE while age is preloaded, and verify the behavior in a database with AGE installed. Done means the unextended database can truncate normally without an ag_catalog error, while AGE graph tables retain their existing behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, postgresql
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100