Skip to content

BE-1020: Add a unique index on entity_ids.entity_uuid - #10076

Merged
TimDiekmann merged 1 commit into
mainfrom
claude/be-1020-unique-entity-uuid-index
Oct 18, 2026
Merged

TimDiekmann merged 1 commit into
mainfrom
claude/be-1020-unique-entity-uuid-index

Conversation

@claude

@claude claude Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Ciaran Morinan, Tim Diekmann · Slack thread

🌟 Purpose

The primary key of entity_ids is (web_id, entity_uuid), so the graph accepts a new entity in one web with an entity UUID that already exists in another web. TypeScript code fetches some entities by entity UUID alone, and moving an entity between webs keeps its entity UUID. Both need the entity UUID to be unique across webs.

This PR adds a unique index on entity_ids (entity_uuid). Removing the lookups by entity UUID alone is tracked in BE-1021.

🔍 Changes

  • V64__entity_uuid_unique.sql adds the index entity_ids_entity_uuid for the deployed refinery migrations.
  • v009__entities/up.sql in graph-migrations creates the same index next to the entity_ids table. These migrations are not deployed yet, so the file is edited in place. Its down.sql drops the table, which drops the index with it.
  • The index is built without CONCURRENTLY. Refinery runs each migration inside a transaction, and Postgres rejects CREATE INDEX CONCURRENTLY inside one. The earlier indexes on entity_ids (V55, V59) are built the same way. The build holds a SHARE lock on entity_ids, which blocks writes to the table until the index is built. Reads continue.
  • Snapshot restore creates entity_ids_tmp with LIKE entity_ids INCLUDING ALL, so the temporary table gets the index too. No code change is needed there.

🚫 Blocked by

  • Nothing. The migration would fail if two entity_ids rows shared an entity UUID. This query returned no rows on production and staging:

    SELECT entity_uuid, count(*) FROM entity_ids GROUP BY entity_uuid HAVING count(*) > 1;

Generated by Claude Code

@vercel

vercel Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
hash Ready Ready Preview Oct 18, 2026 2:47pm UTC
petrinaut Ready Ready Preview Oct 18, 2026 2:47pm UTC
petrinaut-docs Ready Ready Preview Oct 18, 2026 2:47pm UTC
1 Skipped Deployment
Project Deployment Actions Updated
hashdotdesign-tokens Ignored Ignored Preview Oct 18, 2026 2:47pm UTC

Request Review

@TimDiekmann
TimDiekmann marked this pull request as ready for review October 18, 2026 15:06
@TimDiekmann
TimDiekmann requested a review from a team as a code owner October 18, 2026 15:06
Copilot AI balanced review requested due to automatic review settings October 18, 2026 15:06
@TimDiekmann
TimDiekmann added this pull request to the merge queue Oct 18, 2026
@cursor

cursor Bot commented Oct 18, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Schema migration blocks writes on entity_ids during index creation and will fail if production already has duplicate entity UUIDs across webs.

Overview
Adds a global unique constraint on entity_ids.entity_uuid via index entity_ids_entity_uuid, so the same entity UUID cannot appear in more than one web—matching cross-web identity and UUID-only lookups.

The index is defined in the not-yet-deployed v009__entities migration and in deployed refinery migration V64__entity_uuid_unique.sql. The build is not CONCURRENTLY (refinery transaction constraint), so applying V64 takes a SHARE lock on entity_ids and blocks writes until the index finishes.

Deployment note: migration fails if any entity_uuid is duplicated across rows; duplicates must be resolved before rollout.

Reviewed by Cursor Bugbot for commit 05db918. Bugbot is set up for automated code reviews on this repo. Configure here.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

Both migration paths consistently enforce the intended constraint, with duplicate-data checks and write-blocking implications documented.

No open issues 🎉

What changed in this PR

Enforces entity UUID uniqueness across webs, supporting UUID-only lookups and entity moves.

Changes:

  • Adds a unique index in the deployed migration sequence.
  • Adds the same index to the undeployed schema migrations.
File Description
libs/​@local/​graph/​postgres-store/​postgres_migrations/​V64__entity_uuid_unique.sql Adds the global entity UUID unique index.
libs/​@local/​graph/​migrations/​graph-migrations/​v009__entities/​up.sql Applies the same constraint during schema creation.

Review effort: Balanced

Merged via the queue into main with commit 87494ef Oct 18, 2026
79 checks passed
@TimDiekmann
TimDiekmann deleted the claude/be-1020-unique-entity-uuid-index branch October 18, 2026 15:25
@github-actions

Copy link
Copy Markdown
Contributor

Benchmark results

hash-graph-benches – Integrations

policy_resolution_large

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2002 $$28.8 \mathrm{ms} \pm 292 \mathrm{μs}\left({\color{gray}-2.473 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.67 \mathrm{ms} \pm 30.4 \mathrm{μs}\left({\color{gray}-0.105 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 1002 $$13.8 \mathrm{ms} \pm 124 \mathrm{μs}\left({\color{gray}-4.478 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 3314 $$46.8 \mathrm{ms} \pm 492 \mathrm{μs}\left({\color{gray}-0.395 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$16.3 \mathrm{ms} \pm 201 \mathrm{μs}\left({\color{gray}0.205 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 1527 $$27.3 \mathrm{ms} \pm 362 \mathrm{μs}\left({\color{gray}1.43 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 2078 $$29.9 \mathrm{ms} \pm 281 \mathrm{μs}\left({\color{gray}-4.500 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.88 \mathrm{ms} \pm 38.8 \mathrm{μs}\left({\color{gray}-0.549 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 1033 $$15.0 \mathrm{ms} \pm 147 \mathrm{μs}\left({\color{lightgreen}-5.581 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_medium

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 102 $$3.93 \mathrm{ms} \pm 32.0 \mathrm{μs}\left({\color{gray}-2.204 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.04 \mathrm{ms} \pm 22.5 \mathrm{μs}\left({\color{gray}-1.777 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 52 $$3.45 \mathrm{ms} \pm 25.1 \mathrm{μs}\left({\color{gray}-1.599 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 269 $$5.48 \mathrm{ms} \pm 52.9 \mathrm{μs}\left({\color{gray}-1.628 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.65 \mathrm{ms} \pm 29.9 \mathrm{μs}\left({\color{lightgreen}-6.069 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 108 $$4.39 \mathrm{ms} \pm 48.3 \mathrm{μs}\left({\color{gray}-4.184 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 133 $$4.63 \mathrm{ms} \pm 39.7 \mathrm{μs}\left({\color{lightgreen}-5.735 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.50 \mathrm{ms} \pm 26.0 \mathrm{μs}\left({\color{lightgreen}-5.814 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 63 $$4.26 \mathrm{ms} \pm 37.0 \mathrm{μs}\left({\color{lightgreen}-5.673 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_none

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2 $$2.89 \mathrm{ms} \pm 28.9 \mathrm{μs}\left({\color{gray}2.77 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.84 \mathrm{ms} \pm 23.6 \mathrm{μs}\left({\color{gray}2.98 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 2 $$2.97 \mathrm{ms} \pm 26.1 \mathrm{μs}\left({\color{gray}2.04 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 8 $$3.29 \mathrm{ms} \pm 29.4 \mathrm{μs}\left({\color{gray}3.14 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.01 \mathrm{ms} \pm 22.9 \mathrm{μs}\left({\color{gray}0.928 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 3 $$3.36 \mathrm{ms} \pm 26.7 \mathrm{μs}\left({\color{gray}4.34 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_small

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 52 $$3.26 \mathrm{ms} \pm 27.7 \mathrm{μs}\left({\color{gray}1.37 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.98 \mathrm{ms} \pm 29.8 \mathrm{μs}\left({\color{gray}4.12 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 26 $$3.15 \mathrm{ms} \pm 30.0 \mathrm{μs}\left({\color{gray}3.24 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 94 $$3.81 \mathrm{ms} \pm 42.3 \mathrm{μs}\left({\color{gray}4.34 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.42 \mathrm{ms} \pm 42.3 \mathrm{μs}\left({\color{red}7.87 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 27 $$3.66 \mathrm{ms} \pm 47.2 \mathrm{μs}\left({\color{gray}4.49 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 66 $$3.68 \mathrm{ms} \pm 47.1 \mathrm{μs}\left({\color{gray}3.10 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.18 \mathrm{ms} \pm 26.8 \mathrm{μs}\left({\color{gray}0.224 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 29 $$3.58 \mathrm{ms} \pm 42.7 \mathrm{μs}\left({\color{gray}2.98 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_complete

Function Value Mean Flame graphs
entity_by_id;one_depth 1 entities $$33.8 \mathrm{ms} \pm 324 \mathrm{μs}\left({\color{gray}0.321 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 10 entities $$76.5 \mathrm{ms} \pm 786 \mathrm{μs}\left({\color{gray}2.88 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 25 entities $$38.4 \mathrm{ms} \pm 412 \mathrm{μs}\left({\color{gray}0.994 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 5 entities $$42.6 \mathrm{ms} \pm 384 \mathrm{μs}\left({\color{gray}2.01 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 50 entities $$46.2 \mathrm{ms} \pm 439 \mathrm{μs}\left({\color{gray}0.624 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 1 entities $$36.0 \mathrm{ms} \pm 360 \mathrm{μs}\left({\color{gray}0.362 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 10 entities $$436 \mathrm{ms} \pm 2.45 \mathrm{ms}\left({\color{gray}3.37 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 25 entities $$89.9 \mathrm{ms} \pm 812 \mathrm{μs}\left({\color{lightgreen}-6.702 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 5 entities $$86.7 \mathrm{ms} \pm 1.01 \mathrm{ms}\left({\color{gray}4.97 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 50 entities $$318 \mathrm{ms} \pm 2.32 \mathrm{ms}\left({\color{gray}0.135 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 1 entities $$11.2 \mathrm{ms} \pm 103 \mathrm{μs}\left({\color{gray}-0.557 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 10 entities $$11.3 \mathrm{ms} \pm 120 \mathrm{μs}\left({\color{gray}-1.861 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 25 entities $$11.3 \mathrm{ms} \pm 108 \mathrm{μs}\left({\color{gray}-4.362 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 5 entities $$11.2 \mathrm{ms} \pm 118 \mathrm{μs}\left({\color{gray}-1.172 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 50 entities $$11.1 \mathrm{ms} \pm 95.0 \mathrm{μs}\left({\color{gray}-2.755 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_linkless

Function Value Mean Flame graphs
entity_by_id 1 entities $$11.2 \mathrm{ms} \pm 139 \mathrm{μs}\left({\color{gray}3.24 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10 entities $$10.9 \mathrm{ms} \pm 120 \mathrm{μs}\left({\color{gray}-2.100 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 100 entities $$10.9 \mathrm{ms} \pm 89.1 \mathrm{μs}\left({\color{gray}-1.506 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 1000 entities $$11.1 \mathrm{ms} \pm 98.3 \mathrm{μs}\left({\color{gray}-2.560 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10000 entities $$11.4 \mathrm{ms} \pm 133 \mathrm{μs}\left({\color{gray}-3.410 \mathrm{\%}}\right) $$ Flame Graph

representative_read_entity

Function Value Mean Flame graphs
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/block/v/1 $$11.6 \mathrm{ms} \pm 95.4 \mathrm{μs}\left({\color{gray}-3.656 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1 $$11.3 \mathrm{ms} \pm 105 \mathrm{μs}\left({\color{gray}-3.387 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1 $$12.0 \mathrm{ms} \pm 117 \mathrm{μs}\left({\color{gray}3.66 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1 $$11.8 \mathrm{ms} \pm 118 \mathrm{μs}\left({\color{gray}2.13 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2 $$11.2 \mathrm{ms} \pm 91.7 \mathrm{μs}\left({\color{lightgreen}-5.296 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1 $$11.5 \mathrm{ms} \pm 87.1 \mathrm{μs}\left({\color{gray}-2.390 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1 $$11.6 \mathrm{ms} \pm 132 \mathrm{μs}\left({\color{gray}-3.064 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1 $$11.3 \mathrm{ms} \pm 125 \mathrm{μs}\left({\color{gray}-4.657 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1 $$11.7 \mathrm{ms} \pm 101 \mathrm{μs}\left({\color{gray}-0.076 \mathrm{\%}}\right) $$ Flame Graph

representative_read_entity_type

Function Value Mean Flame graphs
get_entity_type_by_id Account ID: bf5a9ef5-dc3b-43cf-a291-6210c0321eba $$8.39 \mathrm{ms} \pm 93.5 \mathrm{μs}\left({\color{gray}-2.149 \mathrm{\%}}\right) $$ Flame Graph

representative_read_multiple_entities

Function Value Mean Flame graphs
entity_by_property traversal_paths=0 0 $$59.7 \mathrm{ms} \pm 795 \mathrm{μs}\left({\color{lightgreen}-5.948 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$114 \mathrm{ms} \pm 936 \mathrm{μs}\left({\color{gray}-3.870 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$64.6 \mathrm{ms} \pm 717 \mathrm{μs}\left({\color{gray}-4.885 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$75.7 \mathrm{ms} \pm 787 \mathrm{μs}\left({\color{lightgreen}-6.104 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$85.2 \mathrm{ms} \pm 821 \mathrm{μs}\left({\color{gray}-4.183 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$90.1 \mathrm{ms} \pm 1.12 \mathrm{ms}\left({\color{lightgreen}-8.097 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=0 0 $$48.8 \mathrm{ms} \pm 424 \mathrm{μs}\left({\color{gray}-4.730 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$76.6 \mathrm{ms} \pm 579 \mathrm{μs}\left({\color{gray}-2.744 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$56.1 \mathrm{ms} \pm 521 \mathrm{μs}\left({\color{gray}-1.449 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$65.8 \mathrm{ms} \pm 658 \mathrm{μs}\left({\color{gray}-1.875 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$66.1 \mathrm{ms} \pm 618 \mathrm{μs}\left({\color{gray}-1.299 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$64.2 \mathrm{ms} \pm 507 \mathrm{μs}\left({\color{gray}-4.901 \mathrm{\%}}\right) $$

scenarios

Function Value Mean Flame graphs
full_test query-limited $$114 \mathrm{ms} \pm 921 \mathrm{μs}\left({\color{lightgreen}-5.071 \mathrm{\%}}\right) $$ Flame Graph
full_test query-unlimited $$125 \mathrm{ms} \pm 800 \mathrm{μs}\left({\color{lightgreen}-5.441 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-limited $$24.4 \mathrm{ms} \pm 226 \mathrm{μs}\left({\color{gray}-2.598 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-unlimited $$526 \mathrm{ms} \pm 1.57 \mathrm{ms}\left({\color{gray}-0.456 \mathrm{\%}}\right) $$ Flame Graph

This branch was successfully deployed

3 active deployments
Preview – petrinaut-docs — 05db918c Deployed Oct 18, 2026 by vercel[bot]
Preview – hash — 05db918c Deployed Oct 18, 2026 by vercel[bot]
Preview – petrinaut — 05db918c Deployed Oct 18, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team

Development

Successfully merging this pull request may close these issues.

3 participants