apache / apache/age

TRUNCATE fails with `schema "ag_catalog" does not exist` in databases without AGE, when AGE is in shared_preload_libraries

Open Beginner friendly
#2,520 1 comment 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.