Skip to content

Pr2561 rls followup v4 - #2592

Merged
MuhammadTahaNaveed merged 5 commits into
apache:masterfrom
jrgemignani:pr2561-rls-followup-v4
Oct 12, 2026
Merged

MuhammadTahaNaveed merged 5 commits into
apache:masterfrom
jrgemignani:pr2561-rls-followup-v4

Conversation

@jrgemignani

Copy link
Copy Markdown
Contributor

Summary

This five-commit follow-up to #2561 improves VLE graph lifetime, scan
semantics, and RLS handling. It has no SQL, catalog, extension-version, or
function-declaration changes.

Changes

Lifecycle and correctness

  • Pin each traversal's graph context to the query memory context. Invalidated
    contexts are retired and reclaimed after the last query pin is released,
    including error cleanup.
  • Materialize a VLE path only from a context that still contains every
    referenced vertex and edge. If none is available, raise a runtime error
    rather than dereferencing missing graph state.
  • Reuse RLS-filtered graphs within a statement according to PostgreSQL scan
    semantics. The loading traversal retains its scan across the statement's
    own writes. Separate traversals share a graph only when the applied RLS
    quals are fixed for the statement.
  • Return null from vertex_stats() when a vertex is absent from the caller's
    traversal graph.
  • Return no paths when RLS leaves a graph with no visible vertices.

RLS hardening

  • Support roles holding the required column privileges without requiring
    access to ctid. These loads retain owned property values.
  • Refuse RLS graph loading in a parallel operation because a worker cannot
    reproduce all session-dependent policy state. Serial execution and labels
    without RLS are unchanged.

Performance

The 400-edge review workload previously evaluated its policy 160,000 times.
It now evaluates it 400 times. A 200-row RLS DETACH DELETE workload retains
one graph copy instead of one per row and completed in approximately half the
time in local measurements. Non-RLS VLE medians remained within 1.3% of the
comparison revision.

Tests and validation

rls_vle Parts 8-12 cover:

  • interleaved traversals and delayed path materialization;
  • query errors, cache deletion, and stored-path behavior;
  • immutable, stable, volatile, and setting-dependent policies;
  • writes and policy or role changes during a statement;
  • RLS and non-RLS DETACH DELETE;
  • vertex_stats(), column grants, fully hidden graphs, and parallel operations.

PostgreSQL 18.3:

  • debug (--enable-cassert, -O0) and release (-O2);
  • clean -Werror builds;
  • 47/47 tests passed in each mode, including pgvector, fuzzystrmatch, and
    pg_trgm;
  • no compiler warnings.

New regression sections were also checked red/green against their preceding
commits.

Scope

  • No SQL, catalog, extension-version, or function-declaration changes.
  • PostgreSQL inheritance-parent RLS behavior is unchanged.
  • Persisted VLE paths are not made portable across backend sessions.

jrgemignani and others added 5 commits October 12, 2026 10:45
A traversal points into a backend-local graph context that the next
acquisition could free. A context loaded through RLS is never valid for a
new acquisition, so when two traversals ran in one query the second one
freed the first one's context. Interleaved traversals lost results, and
materializing a path after its context was freed dereferenced a NULL
pointer (review issue 1 on apache#2561).

Pin a traversal's graph context to the query's per-query memory context.
A pinned context that becomes invalid is retired: it is no longer offered
to new acquisitions, and a memory-context callback frees it when the last
query holding it ends, including after an error. Deleting the caches
retires pinned contexts the same way. A VLE path is materialized from a
context that holds all of its vertices and edges; when there is none, the
materializers raise an error instead of dereferencing a missing context.

Within one query, reuse a pinned context under the rules PostgreSQL
gives a statement's scans, so that the policies run once for each row a
load scans instead of again for every traversal. The 400-edge workload
from the review evaluated its policy 160,000 times and now evaluates it
400 times, for stable and volatile policies alike:

- A load made with another role, enforcement setting or snapshot is a
  different scan.
- A later command id means that a traversal which has not loaded the
  graph yet must see the statement's own writes. The traversal that
  loaded it keeps it while the statement writes, as a traversal of a
  graph without RLS does. Pinning a new copy for every row would keep
  them all until the query ends.
- Policies and privileges hold for the whole statement: PostgreSQL
  applies the policies when it plans the statement and checks the
  privileges when it starts it, so a change made while the statement
  runs does not end the reuse.
- When the RLS quals call only IMMUTABLE functions, besides values
  such as current_user that cannot change unnoticed, any traversal in
  the query may share the graph. Otherwise only the traversal that
  loaded it reuses it, as when PostgreSQL rescans the result of a scan,
  and another traversal loads again: a STABLE function such as
  current_setting() can give another result once the statement changes
  the setting. A traversal is identified by its FmgrInfo, which stays
  the same when it runs again for another row.

Policies from the row-security hooks become the same security quals as
catalog policies, so they are inspected as well. RLS-filtered contexts
are still never reused across statements.

An RLS traversal in a DETACH DELETE query loaded its graph again for
every row. A reload saw a deleted vertex whose edge was still visible,
and the traversal failed with "add_valid_vertex_edges: no vertex found",
while the same query without RLS succeeded. Keeping the graph makes the
two return the same rows.

Tests cover interleaved traversals, two RLS graphs materialized in one
statement, policy evaluation counts for one and two traversals with
immutable, stable and volatile policies across traversal shapes, a
policy reading a setting the statement changes, the contexts left after
each query, a statement's own write, a policy change and a role switch
made while a statement runs, and traversals before and after a DETACH
DELETE with and without RLS, including the graph copies held.
They also build stored paths after the caches are deleted and for a
role whose context lacks their edges, run queries that fail after their
traversal, and delete the caches while a query holds its graph.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
vertex_stats() looked the vertex up in the traversal graph and used the
entry without checking it. Since apache#2561, row-level security can hide a
vertex from that graph while an untyped MATCH, which scans the parent
label table, still returns it, so vertex_stats() dereferenced a NULL
entry and terminated the backend (review issue 2 on apache#2561).

Return null when the vertex is not in the graph the caller can traverse.
The statistics of visible vertices are computed over that graph, so
edges to hidden vertices are not counted.

The new rls_vle part hides a vertex with a policy and checks the
statistics of every vertex an untyped MATCH returns.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The RLS loaders read each visible row's ctid so that properties could be
fetched from the heap later. A role granted SELECT on every user column
of a label, but not on the table, can read the label with MATCH, yet a
traversal failed with "permission denied" because column privileges do
not cover ctid (review issue 3 on apache#2561).

A role that holds SELECT on the table keeps the ctid and the lazy fetch.
Otherwise the loader selects the properties column and the entry keeps
an owned, detoasted copy, which is freed with the graph context. An
entry stores either the tuple location or the properties in the same
field, so vertex and edge entries keep their size. The column
privileges are checked before the load as before, and the SPI query
enforces them, so no row or column the role cannot read is loaded.

The new rls_vle part grants only column privileges on an RLS-protected
edge label and then on a vertex label, and checks that traversals return
the expected paths and properties.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A traversal raised "age_vle: empty graph" when the graph held no
vertices. Since apache#2561 the traversal graph holds only the vertices the
caller may see, so a policy that hides every vertex turned an ordinary
query into an error, although MATCH over the same labels returns no
rows (review issue 4 on apache#2561).

Return no paths instead. The check runs before the traversal state is
allocated, which reduces what a cached call on this path leaves in
long-lived memory from 352 to 64 bytes. The remaining bytes come from
parsing the graph name argument in the long-lived context. That predates
PR apache#2561, also happens on every cache miss, and is left to separate
hardening.

The new rls_vle part hides every vertex and checks a variable-length
MATCH, a zero-length lower bound and a direct age_vle() call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The traversal graph loads RLS-protected labels through an SPI query, so
the policies are evaluated in whichever process loads the graph.
vertex_stats() and age_graph_stats() are parallel safe, so under a
parallel plan a worker loaded the graph. A worker does not have all of
the caller's session state; inet_client_addr(), for one, is NULL there.
A policy that hid edges from a client connection therefore admitted
them in the worker, and the statistics counted edges the caller could
not see.

Raise an error (SQLSTATE 25000) instead of loading an RLS-protected
label in parallel mode. Roles the policies do not apply to, labels
without RLS, serial plans and age.enforce_rls_in_traversal = off are
unaffected. The function declarations stay parallel safe, so other
queries keep their parallel plans.

The new rls_vle part forces a parallel plan and checks that the
restricted role is refused while the owner and a serial plan still get
statistics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jrgemignani
jrgemignani force-pushed the pr2561-rls-followup-v4 branch from 6392ef9 to d205f26 Compare October 12, 2026 17:47

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

3 open findings
What changed in this PR

This PR refines Apache AGE VLE traversal behavior around graph-context lifetime and reuse, particularly under RLS, to better match PostgreSQL scan semantics and to avoid dereferencing missing graph state during delayed path materialization.

Changes:

  • Add query-lifetime pinning/retirement for graph global contexts and reuse rules keyed to snapshot/role/call-site for RLS scans.
  • Make VLE path/edge materialization locate a context that still contains all referenced entities and error cleanly if none exists.
  • Expand RLS handling (column-privilege loads without ctid, disallow parallel-mode RLS loads) and add extensive regression coverage.
File Description
src/​include/​utils/​age_global_graph.h Exposes new APIs for acquiring/pinning contexts and finding a context suitable for path materialization.
src/​backend/​utils/​adt/​age_vle.c Pins traversal contexts to per-query memory; avoids erroring on fully hidden graphs; materialization now validates referenced entities exist.
src/​backend/​utils/​adt/​age_global_graph.c Implements pin/retire lifecycle, RLS qual-variance detection, column-privilege property storage, and parallel-mode refusal for RLS loads.
regress/​sql/​rls_vle.sql Adds Parts 8–12 covering context lifetime, reuse semantics, column-privilege loads, empty-visible graphs, and parallel restrictions.
regress/​expected/​rls_vle.out Updates expected output for the new regression sections and error messages.

🧠 Review effort: Lite


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/backend/utils/adt/age_global_graph.c
Comment thread src/backend/utils/adt/age_global_graph.c
Comment thread src/backend/utils/adt/age_vle.c
@jrgemignani jrgemignani reopened this Oct 12, 2026
@MuhammadTahaNaveed
MuhammadTahaNaveed merged commit 026a0cb into apache:master Oct 12, 2026
13 checks passed
jrgemignani added a commit that referenced this pull request Oct 13, 2026
…2593)

Since #2592, a query reuses an RLS-filtered graph across its traversals
when a walker over the planned row-security quals judges that the
policies cannot change during the statement. The walker copies
PostgreSQL's contain_mutable_functions_walker, treats SQL value
functions such as CURRENT_DATE and current_user as fixed, and reads the
quals through SPI_plan_get_plan_sources(), which PostgreSQL leaves
undocumented.

The classification is not reliable. CURRENT_DATE, CURRENT_TIME,
LOCALTIME and LOCALTIMESTAMP depend on the session TimeZone, which a
statement can change with set_config(), and the inspected quals do not
contain function default arguments, which the planner inserts later. A
policy comparing CURRENT_DATE with an immutable date expression showed 5
links to the first of two traversals and, after the second switched the
time zone, the same 5 links to the second, where two scans of the label
return 5 and 0. The walker also uses node types that PostgreSQL 16 and
earlier do not have, so it cannot be backported unchanged, and every new
node type must be tracked by hand.

Remove the walker and reuse a graph loaded through RLS only for the
traversal that loaded it, as already done for policies the walker
classified as varying. A traversal still loads the graph once instead of
once per row, so the 400-edge workload from the #2561 review still
evaluates its policy 400 times instead of 160,000. A query with several
traversals of one RLS-protected graph loads it once per traversal.
Graphs loaded without RLS are unaffected.

In rls_vle 8.3, two traversals with an immutable policy, alone and with
current_user, now evaluate it 40 times instead of 20; the other counts
are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants