|
| 1 | +# Eliminating SPI from RI Triggers: A Fast Path for Foreign Key Checks |
| 2 | + |
| 3 | +## Introduction |
| 4 | + |
| 5 | +Referential Integrity (RI) triggers in PostgreSQL traditionally execute SQL queries via **SPI** (Server Programming Interface) to verify that inserted or updated rows in a referencing table have matching rows in the referenced (primary key) table. For bulk operations—large `INSERT` or `UPDATE` statements—this means starting and tearing down a full executor plan for **each row**, with significant overhead from `ExecutorStart()` and `ExecutorEnd()`. |
| 6 | + |
| 7 | +Amit Langote has been working on eliminating this overhead by performing RI checks as **direct index probes** instead of SQL plans. The latest iteration of this work, "Eliminating SPI / SQL from some RI triggers - take 3," achieves up to **57% speedup** for bulk foreign key checks by bypassing the SPI executor and calling the index access method directly when the constraint semantics allow it. |
| 8 | + |
| 9 | +The patch set has evolved through several versions, with Junwang Zhao joining the effort in late 2025. The current direction is a **hybrid fast-path + fallback** design: use a direct index probe for straightforward cases, and fall back to the existing SPI path when correctness requires executor behavior that would be difficult or risky to replicate. |
| 10 | + |
| 11 | +## Why This Matters |
| 12 | + |
| 13 | +Foreign key constraints are ubiquitous. Every `INSERT` or `UPDATE` into a referencing table triggers RI checks that must verify each new or modified row against the referenced table's primary key. With the traditional approach: |
| 14 | + |
| 15 | +```sql |
| 16 | +CREATE TABLE pk (a int PRIMARY KEY); |
| 17 | +CREATE TABLE fk (a int REFERENCES pk); |
| 18 | + |
| 19 | +INSERT INTO pk SELECT generate_series(1, 1000000); |
| 20 | +INSERT INTO fk SELECT generate_series(1, 1000000); -- 1M RI checks |
| 21 | +``` |
| 22 | + |
| 23 | +Each of the 1 million inserts triggers an RI check that: |
| 24 | + |
| 25 | +1. Builds a query plan to scan the PK index. |
| 26 | +2. Runs `ExecutorStart()` and `ExecutorEnd()`. |
| 27 | +3. Executes the plan to find (or not find) the matching row. |
| 28 | + |
| 29 | +This per-row plan setup/teardown dominates the cost. With Amit's v3 patches, the same bulk insert drops from **~1000 ms** to **~432 ms** (57% faster) on his benchmark machine—by probing the PK index directly without going through the executor. |
| 30 | + |
| 31 | +## Technical Background |
| 32 | + |
| 33 | +### The Traditional RI Path |
| 34 | + |
| 35 | +RI trigger functions in `ri_triggers.c` (e.g. `RI_FKey_check`) call `ri_PerformCheck()`, which: |
| 36 | + |
| 37 | +1. Builds an SQL string for a query like `SELECT 1 FROM pk WHERE pk.a = $1`. |
| 38 | +2. Uses `SPI_prepare` and `SPI_execute_plan` to run it. |
| 39 | +3. The executor performs an index scan on the PK, returning a row if the referenced value exists. |
| 40 | + |
| 41 | +This works correctly for all cases—partitioned tables, temporal foreign keys, concurrent updates—but pays the full plan-execution cost per row. |
| 42 | + |
| 43 | +### The Fast-Path Idea |
| 44 | + |
| 45 | +For simple foreign keys (non-partitioned referenced table, non-temporal semantics), the check is conceptually: "probe the PK index for this value; if found and lockable, the check passes." That can be done by: |
| 46 | + |
| 47 | +1. Opening the PK relation and its unique index. |
| 48 | +2. Building a scan key from the FK column values. |
| 49 | +3. Calling `index_getnext()` (or equivalent) to find the tuple. |
| 50 | +4. Locking it with `LockTupleKeyShare` under the current snapshot. |
| 51 | + |
| 52 | +No SQL, no plan, no executor. Just a direct index probe and tuple lock. |
| 53 | + |
| 54 | +## Patch Evolution |
| 55 | + |
| 56 | +### v1: The Original Approach (December 2024) |
| 57 | + |
| 58 | +The first patch set (3 patches) introduced: |
| 59 | + |
| 60 | +- **0001**: Refactoring of the `PartitionDesc` interface to explicitly pass the snapshot needed for `omit_detached` visibility (detach-pending partitions). This addressed a bug where PK lookups could return incorrect results under `REPEATABLE READ` because `find_inheritance_children()`'s visibility of detach-pending partitions depended on `ActiveSnapshot`, which RI lookups were manipulating. |
| 61 | +- **0002**: Avoid using SPI in RI trigger functions by introducing a direct index probe path. |
| 62 | +- **0003**: Avoid using an SQL query for some RI checks—the main performance optimization. |
| 63 | + |
| 64 | +Amit noted that temporal foreign key queries would remain on the SPI path, as their plans involve range overlap and aggregation and are not amenable to a simple index probe. He also added an equivalent of `EvalPlanQual()` for the new path to handle concurrent updates correctly under `READ COMMITTED`. |
| 65 | + |
| 66 | +### v2: Junwang's Hybrid Fast Path (December 2025) |
| 67 | + |
| 68 | +Junwang Zhao took the work forward with a hybrid design: |
| 69 | + |
| 70 | +- **0001**: Add fast path for foreign key constraint checks. Applies when the referenced table is not partitioned and the constraint does not involve temporal semantics. |
| 71 | +- **0002**: Cache fast-path metadata (operator hash entries, operator OIDs, strategy numbers, subtypes). At that stage, the metadata cache did not yet improve performance. |
| 72 | + |
| 73 | +Benchmarks (1M rows, `numeric` PK / `bigint` FK): |
| 74 | + |
| 75 | +- Head: INSERT 13.5s, UPDATE 15s |
| 76 | +- Patched: INSERT 8.2s, UPDATE 10.1s |
| 77 | + |
| 78 | +### v3: Amit's Rework with Per-Statement Caching (February 2026) |
| 79 | + |
| 80 | +Amit reworked Junwang's patches into two patches: |
| 81 | + |
| 82 | +- **0001**: Functionally complete fast path. Includes concurrency handling, `REPEATABLE READ` crosscheck, cross-type operators, security context (RLS/ACL), and metadata caching. Most logic lives in `ri_FastPathCheck()`; `RI_FKey_check` just gates the call and falls back to SPI when needed. |
| 83 | +- **0002**: Per-statement resource caching. Instead of sharing `EState` between `trigger.c` and `ri_triggers.c`, a new **AfterTriggerBatchCallback** mechanism fires at the end of each trigger-firing cycle. It allows caching the PK relation, index, scan descriptor, and snapshot across all FK trigger invocations within a single cycle, rather than opening and closing them per row. |
| 84 | + |
| 85 | +Benchmarks on Amit's machine: |
| 86 | + |
| 87 | +| Scenario | Master | 0001 | 0001+0002 | |
| 88 | +|----------|--------|------|-----------| |
| 89 | +| 1M rows, numeric/bigint | 2444 ms | 1382 ms (43% faster) | 1202 ms (51% faster) | |
| 90 | +| 1M rows, int/int | 1000 ms | 520 ms (48% faster) | 432 ms (57% faster) | |
| 91 | + |
| 92 | +The incremental gain from 0002 (~13–17%) comes from eliminating per-row relation open/close, scan begin/end, slot allocation/free, and replacing per-row `GetSnapshotData()` with a snapshot copy in the cache. |
| 93 | + |
| 94 | +## Design: When to Use Fast Path vs. SPI |
| 95 | + |
| 96 | +The fast path applies when: |
| 97 | + |
| 98 | +- The referenced table is **not partitioned**. |
| 99 | +- The constraint does **not** involve temporal semantics (range overlap, `range_agg()`, etc.). |
| 100 | +- Multi-column keys, cross-type equality (via index opfamily), collation matching, and RLS/ACL are all handled directly in the fast path. |
| 101 | + |
| 102 | +The code falls back to SPI when: |
| 103 | + |
| 104 | +1. **Concurrent updates or deletes**: If `table_tuple_lock()` reports that the target tuple was updated or deleted, the code delegates to SPI so that `EvalPlanQual` and visibility rules apply as today. |
| 105 | +2. **Partitioned referenced tables**: Require routing the probe through the correct partition via `PartitionDirectory`. Can be added later as a separate patch. |
| 106 | +3. **Temporal foreign keys**: Use range overlap and containment semantics that inherently involve aggregation; they stay on the SPI path. |
| 107 | + |
| 108 | +Security behavior mirrors the existing SPI path: the fast path temporarily switches to the parent table's owner with `SECURITY_LOCAL_USERID_CHANGE | SECURITY_NOFORCE_RLS` around the probe, matching `ri_PerformCheck()`. |
| 109 | + |
| 110 | +## Future Directions |
| 111 | + |
| 112 | +**David Rowley** suggested off-list that batching multiple FK values into a single index probe could further improve performance, leveraging the `ScalarArrayOp` btree improvements from PostgreSQL 17. The idea: buffer FK values across trigger invocations in the per-constraint cache, build a `SK_SEARCHARRAY` scan key, and let the btree AM traverse matching leaf pages in one sorted pass instead of one tree descent per row. Locking and recheck would remain per-tuple. This could be explored as a separate patch on top of the current series. |
| 113 | + |
| 114 | +## Current Status |
| 115 | + |
| 116 | +- The series is in PG19-Drafts. Amit moved it there in October 2025; Junwang Zhao is continuing the work. |
| 117 | +- Amit's v3 patches (February 2026) are in reasonable shape and ready for review. He welcomes feedback, especially on concurrency handling in `ri_LockPKTuple()` and the snapshot lifecycle in 0002. |
| 118 | +- Pavel Stehule has offered to help with testing and review. |
| 119 | + |
| 120 | +## Conclusion |
| 121 | + |
| 122 | +Eliminating SPI from RI triggers for simple foreign key checks yields substantial performance gains for bulk operations. The hybrid fast-path + fallback design addresses reviewer concerns about correctness by deferring to SPI whenever executor behavior is non-trivial to replicate. The per-statement resource caching in v3 adds a second layer of optimization by amortizing relation/index setup across many rows within a single trigger-firing cycle. |
| 123 | + |
| 124 | +For workloads with large bulk inserts or updates on tables with foreign keys—common in ETL, staging loads, and data migrations—this work could significantly reduce runtimes. The current limitations (partitioned PKs, temporal FKs) leave those cases on the existing path, preserving correctness while optimizing the majority of FK workloads. |
| 125 | + |
| 126 | +## References |
| 127 | + |
| 128 | +- [Thread: Eliminating SPI / SQL from some RI triggers - take 3](https://www.postgresql.org/message-id/flat/CA%2BHiwqF4C0ws3cO%2Bz5cLkPuvwnAwkSp7sfvgGj3yQ%3DLi6KNMqA%40mail.gmail.com) |
| 129 | +- [1] Simplifying foreign key/RI checks (earlier thread) |
| 130 | +- [2] Eliminating SPI from RI triggers - take 2 (earlier thread) |
0 commit comments