Skip to content

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

Description

@luckydududu

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_catalogwithout 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.

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