Conversation
This commit fixes performance degradation during insertion scenarios by replacing SeqScan with IndexScan. Co-authored-by: Daria Barsukova <d.barsukova@g.nsu.ru> Co-authored-by: Alexandra Bondar <s6311704@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR targets a load-test regression where insertion throughput degraded as label tables grew, by replacing O(N) sequential scans with index-backed lookups (primarily around entity_exists and other hot paths that check entity/label presence).
Changes:
- Switch
entity_exists()to use an index scan when a suitable index is available (fallback to seq scan otherwise). - Refactor several other table lookups (vertex fetch, label enumeration, SET/DELETE code paths) to prefer index scans.
- Add index discovery logic to locate a usable index when a PK index isn’t present.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/backend/utils/adt/agtype.c |
Uses index scan to fetch a vertex tuple by graphid, with fallback to sequential scan. |
src/backend/utils/adt/age_global_graph.c |
Uses index scan to enumerate labels for a graph_oid, filtering kind in the heap. |
src/backend/executor/cypher_utils.c |
Reworks entity_exists() to prefer index scans over seq scans. |
src/backend/executor/cypher_set.c |
Uses index scan to find the tuple to update (SET/REMOVE) instead of seq scan. |
src/backend/executor/cypher_delete.c |
Uses index scan to find tuples for DELETE and to accelerate connected-edge checks where possible. |
src/backend/catalog/ag_label.c |
Uses ag_label_graph_oid_index to accelerate edge-label enumeration for a graph. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/backend/utils/adt/agtype.c
Outdated
| ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_OIDEQ, | ||
| Int64GetDatum(graphid)); |
There was a problem hiding this comment.
The sequential-scan fallback still uses F_OIDEQ to compare the graphid column. Since the index path uses F_GRAPHIDEQ (and the column type is GRAPHIDOID), the fallback should also use F_GRAPHIDEQ/GRAPHID_GET_DATUM to avoid incorrect comparisons when no suitable index is found.
| ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_OIDEQ, | |
| Int64GetDatum(graphid)); | |
| ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_GRAPHIDEQ, | |
| GRAPHID_GET_DATUM(graphid)); |
There was a problem hiding this comment.
This is actually existing code that I didn't write. To keep the PR focused and avoid breaking existing logic, I didn't change its internal mechanics and simply moved it under the else branch. Let me know if you'd still prefer me to fix the F_OIDEQ to F_GRAPHIDEQ comparison.
There was a problem hiding this comment.
Let me think about that.
There was a problem hiding this comment.
If you could fix it, that would be great. Otherwise, it will just keep reminding us and it will still need to be fixed in a diff. pr.
There was a problem hiding this comment.
Done! I've refactored this as requested. I also left a note about fixing this on a similar comment during the second review iteration.
|
@sandy-bes Please address the above Copilot suggestions. Additionally, please make sure to note any AI usage, if used. |
|
@sandy-bes Please see this as well (Opus 4.6 review). All of these items need to be address. Review of PR #2351 — Add index scanThe core idea — replacing O(N) sequential scans with O(log N) index scans in Critical Issues1. RLS bypass in The index-scan branch in 2. heap_tuple = ExecFetchSlotHeapTuple(index_slot, true, &shouldFree);
// ...
heap_tuple = update_entity_tuple(resultRelInfo, slot, estate, heap_tuple);
// ...
if (shouldFree)
heap_freetuple(heap_tuple); // <-- frees the UPDATE result, not the fetched tupleAfter Correctness Issues3. The index path correctly uses 4. Index attno vs heap attno confusion in
Performance Issue5. Index discovery runs per-tuple in hot paths In Code Quality Issues6. Massive code duplication The index discovery pattern (iterate Oid find_usable_index_for_attr(Relation rel, AttrNumber attnum);7. DETACH DELETE duplication in The two-pass connected-edge check (START_ID pass + END_ID pass) duplicates ~120 lines of nearly identical ACL check + RLS check + delete + error logic. A helper function would reduce this significantly. 8. C++ style comments New code uses 9. Brace style inconsistency The PR mixes }
else
{Please follow the existing style. 10. Inconsistent lock levels Index open calls use Summary
The performance improvement is real and valuable. With the security fix (#1), memory safety fix (#2), correctness fixes (#3-4), and a refactoring pass to deduplicate the index discovery logic (#5-7), this will be in good shape. |
|
I've updated the PR description with the steps to reproduce the tests locally. |
I was able to validate the perf claims with Opus earlier. But, more importantly, I can't do anything with this PR until all of the above issues (Copilot & Opus) have been addressed. Including noting any use of AI. Btw, using AI is fine, it just needs to be noted. |
|
Yes, absolutely, I understand that the review process needs to be finalized first. I will do my best to address all the feedback over the next few days. Regarding the use of AI agents: I did not use any AI agents while writing this code. |
|
All review comments and suggestions provided by GitHub Copilot and Opus 4.6 have been addressed and integrated into this patch. |
|
@sandy-bes A lot of regression tests failed. Are you running the installcheck locally to verify the build? |
1fdce2c to
61659f2
Compare
|
I forgot to run installcheck for the latest change in the commit, my bad! It turned out there was a lock mismatch. I just fixed it, the tests are passing now, and I force-pushed the update. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/backend/utils/adt/agtype.c
Outdated
| if (curr_idx_rel->rd_index->indisvalid && | ||
| curr_idx_rel->rd_index->indnatts >= 1 && | ||
| curr_idx_rel->rd_index->indkey.values[0] == attnum) |
There was a problem hiding this comment.
find_usable_index_for_attr() can select any valid index whose first key matches attnum, but all call sites build scan keys with BTEqualStrategyNumber (btree strategy numbering). If a non-btree index exists on that column (or an index AM with different strategy semantics), the scan can behave incorrectly or fail. Restrict the helper to only return btree indexes (and optionally skip partial/expression indexes), or change the scan-key construction to be compatible with the selected index AM/opclass.
There was a problem hiding this comment.
I've updated the helper function to strictly filter for B-Tree indexes (relam == BTREE_AM_OID) and to skip partial indexes. To make this specific behavior explicit and prevent any future misuse, I also took the liberty of renaming the function to find_usable_btree_index_for_attr.
| /* fallback to sequential scan */ | ||
| ScanKeyInit(&scan_keys[0], 1, BTEqualStrategyNumber, F_OIDEQ, | ||
| Int64GetDatum(graphid)); | ||
|
|
||
| scan_desc = table_beginscan(graph_vertex_label, snapshot, 1, scan_keys); | ||
| tuple = heap_getnext(scan_desc, ForwardScanDirection); |
There was a problem hiding this comment.
The index path uses F_GRAPHIDEQ for the same attribute, but the seq-scan fallback still uses F_OIDEQ. That makes correctness depend on whether an index exists (and may be wrong if the column type/operator is graphid). Use the same operator/function for both paths (e.g., F_GRAPHIDEQ with the appropriate datum conversion) so results are consistent regardless of index availability.
There was a problem hiding this comment.
As I mentioned in a similar comment, this was existing code that I simply moved into the else branch, which is why I didn't touch its internal mechanics initially. I'll update the fallback path to use F_GRAPHIDEQ with the correct datum conversion right away.
src/backend/executor/cypher_set.c
Outdated
| update_entity_tuple(resultRelInfo, slot, estate, | ||
| heap_tuple); |
There was a problem hiding this comment.
In the seq-scan path, the return value of update_entity_tuple(...) is assigned back to heap_tuple, but in the index-scan path it is ignored. If update_entity_tuple can return a different HeapTuple (or if callers rely on the returned value for ownership/lifetime), this inconsistency can cause incorrect behavior or memory/tuple lifecycle issues. Make the index-scan path match the existing pattern (assign the return value and handle any required freeing/ownership consistently).
| update_entity_tuple(resultRelInfo, slot, estate, | |
| heap_tuple); | |
| heap_tuple = update_entity_tuple(resultRelInfo, slot, | |
| estate, heap_tuple); |
There was a problem hiding this comment.
I had actually removed that assignment in a previous PR iteration to fix a memory bug (calling heap_freetuple on the updated tuple instead of the fetched one caused issues).
But I completely agree that leaving the return value unassigned is bad practice and breaks consistency. I've refactored this block: we now store the fetched tuple in a dedicated original_tuple variable for safe memory freeing at the end, which allows us to correctly assign the update_entity_tuple result back to heap_tuple. Fixed it.
src/backend/executor/cypher_utils.c
Outdated
|
|
||
| index_rel = index_open(index_oid, RowExclusiveLock); |
There was a problem hiding this comment.
entity_exists() is a read-only existence check, but it opens the index with RowExclusiveLock. This lock mode is stronger than necessary for scanning and can increase lock contention under concurrent workloads (especially since entity_exists is in the hot insertion path). Prefer AccessShareLock for index_open (while keeping the table lock as required by the surrounding logic).
There was a problem hiding this comment.
I originally matched the table's lock level due to the upstream modification paths (create_vertex, etc.), but I see now why AccessShareLock is the correct (and much more concurrent) choice for the index scan itself. Fixed in the latest commit.
| ag_label = table_open(ag_label_relation_id(), AccessShareLock); | ||
|
|
||
| index_oid = find_usable_index_for_attr(ag_label, Anum_ag_label_graph); | ||
|
|
||
| resultRelInfo = create_entity_result_rel_info(estate, "ag_catalog", | ||
| "ag_label"); | ||
|
|
There was a problem hiding this comment.
This function opens ag_label via table_open(...) but later closes resultRelInfo->ri_RelationDesc instead of the ag_label relation it opened. This double-open pattern is easy to get wrong and risks leaking a relation reference/lock if the descriptors are not the same instance. Prefer using a single relation handle throughout (e.g., use resultRelInfo->ri_RelationDesc for scanning and close that, or explicitly close ag_label if it remains separately opened).
| ag_label = table_open(ag_label_relation_id(), AccessShareLock); | |
| index_oid = find_usable_index_for_attr(ag_label, Anum_ag_label_graph); | |
| resultRelInfo = create_entity_result_rel_info(estate, "ag_catalog", | |
| "ag_label"); | |
| resultRelInfo = create_entity_result_rel_info(estate, "ag_catalog", | |
| "ag_label"); | |
| ag_label = resultRelInfo->ri_RelationDesc; | |
| index_oid = find_usable_index_for_attr(ag_label, Anum_ag_label_graph); |
There was a problem hiding this comment.
I see exactly what you mean now: the code explicitly does table_open with AccessShareLock, and then create_entity_result_rel_info opens the exact same relation again via parserOpenTable with RowExclusiveLock.
This is actually existing code that I didn't write. To keep the PR focused and avoid breaking existing logic, I didn't change its internal mechanics. Let me know if you'd still prefer me to fix it.
|
|
||
| /* get all of the label names */ | ||
| while((tuple = heap_getnext(scan_desc, ForwardScanDirection)) != NULL) | ||
| /* We look for 'ag_label_graph_oid_index' or any index starting with 'graph' */ |
There was a problem hiding this comment.
The comment is inaccurate: the code does not look for an index by name/prefix; it selects the first “usable” index whose first key matches the attribute number. Update the comment to reflect the actual selection logic to avoid misleading future maintainers.
| /* We look for 'ag_label_graph_oid_index' or any index starting with 'graph' */ | |
| /* Find a usable index whose first key column is ag_label.graph (Anum_ag_label_graph) */ |
|
@sandy-bes Sorry, you know the drill by now, more Copilot,... I keep hoping it stops complaining :( But, I think we're close. |
|
@jrgemignani No worries at all, I completely understand! As much as it complains, I have to admit Copilot actually gives some really good hints and catches subtle things. |

Motivation / Problem:
As a result of load testing, a significant performance degradation was found in insertion scenarios. The scenarios used were taken from an open-source benchmark and rewritten in pure SQL. Examples of the queries can be found here:
Perf analysis showed that the main bottleneck is the entity_exists function. The root cause lies in the use of a Sequential Scan (SeqScan) to check for the existence of an entity prior to insertion. The time complexity of a SeqScan is O(N), meaning the search time grows linearly as the number of rows in the table increases. The larger the graph became, the longer each individual insertion took. This led to a drop in TPS regardless of the concurrency level (the issue was consistently reproduced with both 1 and 30 threads).
Profile Analysis (Flame Graphs & perf): I am attaching Flame Graphs for comparison.
entity_existsfunction and its underlying sequential scan operations.Changes Made:
Performance Impact:
Benchmarks were conducted on a server with 30 CPU cores and 32 GB of RAM, using a graph ranging from 20,000 to 200,000 objects over a 2-minute duration. The transition to index access completely eliminated the performance degradation associated with data volume growth:
Here are the scripts and commands for local reproduction:
1. Graph creation and population:
The
sfparameter here defines the scale factor of the generated graph.psql -d your_database -f generate_graph.sql -v sf=1
generate_graph.sql
2. Creating wrapper functions for the workload:
This script sets up the environment and creates functions with the correct parameter mapping for Apache AGE.
psql -d your_database -f setup_func_for_workload.sql
setup_func_for_workload.sql
3. Running the load test:
Running 8 clients for 120 seconds.
pgbench -d your_database -f workload_update.sql -D sf=1 -c 8 -T 120 -P
workload_update.sql
Acknowledgments: