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
$ 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:
$ 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 <other> / 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:
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:
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.
Summary
When
ageis inshared_preload_libraries,TRUNCATEfails in any database that does not have the AGE extension installed:This makes AGE unsafe to preload on a shared PostgreSQL cluster: a single database wanting graph support breaks
TRUNCATEfor every other database in the instance.This is the same class of problem as #2180 (fixed for the
object_access_hookpath by #2161), but on a different code path that was added later and does not have the guard.Environment
postgresql-18-pgdg-age, PGDG apt)shared_preload_libraries = ageReproduction
The database
appnever hadCREATE EXTENSION agerun in it.Installing the extension in that database makes it work again:
Only
TRUNCATEis affected.CREATE TABLE/INSERT/UPDATE/DELETE/CREATE INDEX/ALTER TABLE/VACUUM/ANALYZE/REINDEX/CLUSTER/DROP TABLE/COPY/CREATE EXTENSION <other>/pg_dump/pg_restoreall behave normally in the same database.Root cause
ag_ProcessUtility_hook()insrc/backend/catalog/ag_catalog.chandlesT_TruncateStmtand callsget_graph_oid_for_table()— which resolvesag_catalog— without first checking whether the AGE extension exists in the current database:The file already provides the guard for exactly this purpose —
is_age_extension_exists(), with the comment:but in the
PG18branch it is only called fromis_age_drop()andobject_access()— theT_TruncateStmtbranch (lines 172–202) callsget_graph_oid_for_table()with no guard at all. Sinceshared_preload_librariesloads the library instance-wide, the hook runs in every database, including those whereag_catalogdoes not exist.Suggested fix
Bail out early in the
T_TruncateStmtbranch, consistent with how the other hooks guard themselves: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 agein every database of the instance, includingtemplate1so that newly created databases inherit it. That is fragile: databases created withTEMPLATE template0— whichpg_dump/pg_restoreoutput commonly uses — do not inherit it, andTRUNCATEstarts 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.