Skip to content

BE-705: Add a dedicated entities-table query endpoint to the Graph - #9094

Open
TimDiekmann wants to merge 5 commits into
mainfrom
t/be-705-graph-entities-table-endpoint
Open

BE-705: Add a dedicated entities-table query endpoint to the Graph#9094
TimDiekmann wants to merge 5 commits into
mainfrom
t/be-705-graph-entities-table-endpoint

Conversation

@TimDiekmann

@TimDiekmann TimDiekmann commented Jul 24, 2026

Copy link
Copy Markdown
Member

🌟 What is the purpose of this PR?

The entities table currently assembles its rows client-side from a subgraph query, which pays for edge resolution and closed-schema transport the table never renders. This PR adds a dedicated /entities/query/table endpoint to the Graph that serves flat, render-ready rows built from materialized columns — with the type summary, the count, and the page read in one repeatable-read transaction, so follow-up pages continue on the first page's database state.

🔗 Related links

🔍 What does this change?

  • Contract (hash-graph-store): QueryEntitiesTableParams with tagged web/type scopes (include/exclude, where type exclusion works on base URLs so noisy system types stay hidden across versions), a flat 13-operator property-filter enum, and a closed sort-key enum over the materialized, indexable columns.
  • Cursor: an opaque base64 token sealing two snapshot instants, the derived type universe, the sort, the draft visibility, and the keyset position. A continuation reads all of these from the token and ignores the re-sent request. Storing bare instants (rather than temporal axes) makes the interval axes that would break the one-row-per-entity shape unrepresentable; the first page takes no axes at all — the server mints the snapshot.
  • Page query: when no explicit type filter is given, the visible-type universe is derived from the summary and drives the page query as an indexable include clause. The count rides along in the summary scan (count(*) FILTER (WHERE ord = 1)) when the page carries no narrowing filters, saving a second full scan.
  • Protection & permissions: user property filters pass through the same filter-protection rewrite as the generic read path (no enumeration oracle through row presence or the count), rows are property-masked, and link endpoints pass the knowledge-edge permission check — an endpoint the actor cannot view is dropped from the row.
  • Performance: the summary scan disables JIT — the planner misestimates its set-returning functions by orders of magnitude, which pushes the statement over the JIT thresholds and burns substantial compile time on every execution.
  • API surface: REST handler with the configured entity limit, OpenAPI schemas, queryEntitiesTable through the Node API/GraphQL, and branded types in the TypeScript SDK.

Pre-Merge Checklist 🚀

🚢 Has this modified a publishable library?

This PR:

  • does not modify any publishable blocks or libraries, or modifications do not need publishing

📜 Does this require a change to the docs?

The changes in this PR:

  • are internal and do not require a docs change

🕸️ Does this require a change to the Turbo Graph?

The changes in this PR:

  • do not affect the execution graph

⚠️ Known issues

  • The first page of a large web pays an O(N) summary scan for the type pills and the derived universe. Follow-ups below make it scale.

🐾 Next steps

  • Pre-aggregate web × type counts so the pills and universe become O(#types)
  • Materialize archived (and later created_by_id) into entity_edition_cache to drop two joins from the summary scan — measured to cut its cost roughly in half
  • Deduplicate identical right-hand join chains in the SelectCompiler
  • Move the Grid and Graph views off the subgraph query (frontend PR follows)

🛡 What tests cover this?

  • 16 integration tests in tests/graph/integration/postgres/table.rs: paging invariants (no duplicate rows, short page ends the sequence), label sort across the NULL keyset boundary, cursor sort pinning against a contradicting re-sent request, link-endpoint permission nulling, property filters, conversions, archived/draft visibility, web/type scoping incl. empty include, and the multi-type count fold
  • 3 table-endpoint tests in email_filter_protection.rs: equals/startsWith enumeration via rows and count, response masking
  • Serde round-trips for the params, scopes, and cursor tokens (garbage tokens rejected), plus SQL goldens for the page, exclusion scopes, all 13 property operators, sort keys, and the summary fold

❓ How to test this?

  1. Checkout the branch and start the Graph: cargo run --bin hash-graph --all-features -- server
  2. POST /entities/query/table with {"filter": {}, "limit": 10, "includeSummary": true} and an X-Authenticated-User-Actor-Id header
  3. Confirm rows, summary (count + type pills), and a cursor on full pages; re-send with "cursor" and confirm the next page continues the sequence

📹 Demo

Covered by the frontend PR stacked on this one.

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
hash Ready Ready Preview, Comment Jul 27, 2026 6:48pm
petrinaut Ready Ready Preview, Comment Jul 27, 2026 6:48pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Jul 27, 2026 6:48pm

@github-actions github-actions Bot added area/deps Relates to third-party dependencies (area) area/apps > hash* Affects HASH (a `hash-*` app) area/apps > hash-api Affects the HASH API (app) area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team area/tests New or updated tests area/apps labels Jul 24, 2026
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.38131% with 346 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.55%. Comparing base (495a617) to head (8cfdd9f).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...store/src/store/postgres/knowledge/entity/table.rs 69.19% 225 Missing and 2 partials ⚠️
...ore/src/store/postgres/knowledge/entity/summary.rs 62.26% 40 Missing ⚠️
libs/@local/graph/store/src/entity/table.rs 90.50% 28 Missing and 2 partials ⚠️
libs/@local/graph/api/src/rest/entity/query/mod.rs 0.00% 27 Missing ⚠️
...s-store/src/store/postgres/knowledge/entity/mod.rs 0.00% 18 Missing ⚠️
...i/src/graphql/resolvers/knowledge/entity/entity.ts 0.00% 2 Missing ⚠️
libs/@local/graph/api/src/rest/entity/mod.rs 0.00% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9094      +/-   ##
==========================================
+ Coverage   59.36%   59.55%   +0.19%     
==========================================
  Files        1406     1408       +2     
  Lines      136519   137734    +1215     
  Branches     6407     6418      +11     
==========================================
+ Hits        81042    82027     +985     
- Misses      54487    54712     +225     
- Partials      990      995       +5     
Flag Coverage Δ
apps.hash-ai-worker-ts 1.99% <ø> (ø)
apps.hash-api 12.09% <0.00%> (-0.01%) ⬇️
blockprotocol.type-system 40.84% <ø> (ø)
local.claude-hooks 0.00% <ø> (ø)
local.harpc-client 51.49% <ø> (ø)
local.hash-backend-utils 2.55% <ø> (ø)
local.hash-graph-sdk 10.02% <ø> (ø)
local.hash-isomorphic-utils 5.75% <ø> (ø)
rust.antsi 0.00% <ø> (ø)
rust.error-stack 90.89% <ø> (ø)
rust.harpc-codec 84.70% <ø> (ø)
rust.harpc-net 96.21% <ø> (+0.01%) ⬆️
rust.harpc-tower 67.03% <ø> (ø)
rust.harpc-types 0.00% <ø> (ø)
rust.harpc-wire-protocol 92.23% <ø> (ø)
rust.hash-codec 72.76% <ø> (ø)
rust.hash-graph-api 7.37% <0.00%> (-0.05%) ⬇️
rust.hash-graph-authorization 62.59% <ø> (ø)
rust.hash-graph-embeddings 91.88% <ø> (ø)
rust.hash-graph-postgres-store 29.66% <66.93%> (+2.26%) ⬆️
rust.hash-graph-store 42.16% <90.50%> (+3.44%) ⬆️
rust.hash-graph-temporal-versioning 47.95% <ø> (ø)
rust.hash-graph-types 0.00% <ø> (ø)
rust.hash-graph-validation 84.71% <ø> (ø)
rust.hashql-ast 89.63% <ø> (ø)
rust.hashql-compiletest 28.39% <ø> (ø)
rust.hashql-core 78.98% <ø> (ø)
rust.hashql-diagnostics 72.51% <ø> (ø)
rust.hashql-eval 79.82% <ø> (ø)
rust.hashql-hir 89.09% <ø> (ø)
rust.hashql-mir 87.92% <ø> (ø)
rust.hashql-syntax-jexpr 94.04% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread libs/@local/graph/store/src/entity/table.rs Fixed
Comment thread tests/graph/integration/postgres/table.rs Fixed
Comment thread tests/graph/integration/postgres/table.rs Fixed
Comment thread tests/graph/integration/postgres/table.rs Fixed
Comment thread tests/graph/integration/postgres/table.rs Fixed
Comment thread tests/graph/integration/postgres/table.rs Fixed
@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 98 untouched benchmarks


Comparing t/be-705-graph-entities-table-endpoint (8cfdd9f) with main (15de8ae)1

Open in CodSpeed

Footnotes

  1. No successful run was found on main (495a617) during the generation of this report, so 15de8ae was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@claude claude Bot 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.

In-depth review of the full diff against the actual base branch t/be-598-investigate-fetch-keys-then-hydrate-projection-for-the (this PR is stacked on #9048; stack-wide context noted where relevant), focused on correctness and performance as requested, with Linear BE-705 as the requirements baseline. No blocking defects on the default path; the Important items below are worth addressing before (or immediately after) merge, everything under Suggestions is non-blocking.

Important

  1. includeDrafts: true breaks the keys-first precondition: link-endpoint hydration joins fan out over draft editions and can mix editions in one row. The comment above compiler.set_statement_shape(StatementShape::KeysFirst) (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:524) vouches that hydration joins match at most one row per key — but that only holds when drafts are excluded. temporal_metadata_conditions (libs/@local/graph/postgres-store/src/store/postgres/query/compile/mod.rs:526) adds draft_id IS NULL to entity_temporal_metadata join conditions only when !self.include_drafts, compiler-wide — including the endpoint hydration joins (visible in the table_page_statement golden). With includeDrafts: true, an endpoint entity with a live edition plus drafts matches multiple temporal-metadata rows, and the golden shows the right endpoint's edition_id, label, versioned_urls, and direct_types are read through independent duplicate join chains (entity_temporal_metadata_2_2_2/_2_2_3/_2_2_4) — so a single product row can combine the label from one edition with the type arrays of another, after which LinkEndpointIndices::decode's .take(direct_type_count) (table.rs:191) truncates the wrong array and hardcodes draft_id: None even for a draft edition. The outer DISTINCT ON collapses the fan-out arbitrarily, so the endpoint shown (and the edition fed to filter_knowledge_edges) is nondeterministic. The stacked frontend (#9095) hardcodes includeDrafts: false, but the endpoint accepts true and the cursor pins it for whole page sequences; the only draft test (drafts_are_hidden_unless_requested, tests/graph/integration/postgres/table.rs:1126) uses a link-less draft, so the path is untested. Fix: pin endpoint hydration joins to the non-draft edition regardless of the root query's draft visibility (drafts of root entities are rows; drafts of their endpoints are not addressable link targets), or include the endpoint draft_id in the join key.

  2. The derived type universe is uncapped — in the page statement's parameter list and in the cursor token. When no explicit type filter is given, type_universe collects every distinct visible type id from the summary with no bound (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:440), then (a) type_include_filter (table.rs:996) binds one text parameter per versioned URL (the compiler merges the Filter::Any into a single GIN-indexable versioned_urls && ARRAY[$..] clause, but each element is still a bind parameter), and (b) the whole list is embedded verbatim in EntityTableCursor (libs/@local/graph/store/src/entity/table.rs:261), base64-JSON'd into every response and re-sent, re-parsed, and re-compiled on every continuation. BE-705 explicitly specifies the cursor carries the universe "up to a practical cap" and notes the include clause is result-neutral (the scope filter alone is authoritative), so the graph is "free to cap it … or drop it per request" — the implementation has no cap anywhere. At realistic scale (hundreds–thousands of visible type versions) that means tens-of-KB opaque tokens both ways per page and O(#types) bind parameters per statement; at the extreme the extended-protocol Bind limit (65,535 parameters) fails the page query outright. Because the clause is a planner aid, dropping (or truncating) the universe past N types is a safe, purely planner-side guard that bounds both the token and the statement.

  3. The new /entities/query/table handler bypasses QueryLogger, unlike every sibling read handler in the same file. query_entities, query_entity_subgraph, and summarize_entities all take Option<Extension<QueryLogger>>, capture(...) the raw request, and send() after the store call; query_entities_table (libs/@local/graph/api/src/rest/entity/query/mod.rs:252) omits the extension entirely, and no QueryEntitiesTable variant was added to OpenApiQuery (libs/@local/graph/api/src/rest/mod.rs:356). This matters beyond consistency: #9095 moves the entities-table view off queryEntitySubgraph (which is logged) onto this endpoint, so a class of production query traffic that is captured today silently vanishes from the query log with no signal that logging stopped. The fix is mechanical — add the variant and wire the capture/send pair like the siblings. (Noting search_entities/cluster_entities also skip the logger, so the convention isn't universal — but all three direct siblings of this handler log, including the lightweight summarize endpoint.)

  4. Snapshot pinning — the endpoint's headline invariant — has zero integration coverage. The entire cursor design (pinned transaction_time/decision_time, the sealed type universe, instant_axes() at libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:901) exists so that follow-up pages continue on the first page's database state, per the PR description and BE-705. No test exercises it: every paging test in tests/graph/integration/postgres/table.rs (e.g. collect_all_pages, a_continuation_reads_its_sort_from_the_token at tests/graph/integration/postgres/table.rs:865) runs against a static dataset, with all mutations before page 1. If the continuation branch regressed to Timestamp::now(), or the universe were re-derived per page, every current test would still pass because each page sees identical data anyway. A test that takes page 1, then creates an entity / patches an existing one / creates an entity of a brand-new type, then continues from the cursor — asserting the new entity is absent, the patched entity shows its old edition, and the pinned universe excludes the new type — would cover the single most consequential untested behavior in the PR.

  5. No test verifies the summary count and type pills respect policy restrictions. link_endpoints_hide_entities_the_actor_cannot_view (tests/graph/integration/postgres/table.rs:951) inserts the suite's only Forbid ViewEntity policy but never requests the summary, and no table test queries as a second actor with reduced visibility; the email_filter_protection.rs additions cover the count only through the property-filter rewrite (the table_count path). That leaves scope_summary (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:710) — a brand-new statement path with its own JIT-off and fold machinery, and the sole source of the pills and of the count on the common no-filter path — untested for the property that it excludes entities the actor cannot view. Dropping the single add_filter(policy_filter) call from the summary compiler would leak other webs' counts and types with every current test still green (the page query applies its own policy filter, hiding the forbidden row). Extending the Forbid-policy test to request includeSummary and assert count == 6 (not 7; the test also creates a friend-of link) and person pill == 3 (not 4) would pin it.

Suggestions

  1. A well-formed cursor token whose position mismatches its own sort silently degrades the keyset instead of erroring. The continuation builds EntityQuerySorting { paths: sorting_records(sort, include_drafts), cursor: position } (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:529) without checking arity, and EntityQuerySorting::compile zips paths with cursor.values (libs/@local/graph/postgres-store/src/store/postgres/query/mod.rs:169), truncating silently: an empty position compiles with no keyset condition (and no ordering), a short one drops the uuid tiebreaker, and the weakened cursor is re-emitted in every subsequent token; a wrong CursorField type surfaces as a Postgres bind error (500) rather than a 400. The token design otherwise takes rejection seriously (deny_unknown_fields, the garbage_cursor_tokens_are_rejected test at libs/@local/graph/store/src/entity/table.rs:521), but only shape-garbage is rejected — semantic mismatches pass serde. Since sort and include_drafts are both known from the token, validating position.values.len() against the sorting-record count (and rejecting as an invalid cursor) closes this, plus a test pinning the intended behavior. Tamper-only blast radius (all filters still apply), hence non-blocking.

  2. The cursor payload has no version discriminator and rejects unknown fields. EntityTableCursorPayload is deny_unknown_fields with no version marker (libs/@local/graph/store/src/entity/table.rs:312). Because clients hold tokens opaquely across requests, any future payload change breaks in-flight pagination in both directions during a rolling deploy (old instance 422s a new token; new instance 422s an old one). Blast radius is "restart from page one", but a one-byte version field — or defaulted optional fields instead of deny_unknown_fields — makes the sealed shape evolvable, and this PR mints the format, so now is the cheapest moment.

  3. includeDrafts sits inside EntityTableFilter beside includeArchived but obeys the opposite continuation semantics. On a continuation, draft visibility is read from the token and the re-sent request is ignored (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:353), while include_archived — like webs, type excludes, and property filters — is honored from the re-sent request via scope_filter. Two adjacent booleans (libs/@local/graph/store/src/entity/table.rs:231, :233) with different trust models is a client foot-gun: toggling one mid-scroll takes effect, toggling the other is silently ignored. Since include_drafts is pinned because it shapes the keyset (the third draft_id sort column), it behaves like sort — which the PR already places top-level. Moving it next to sort before clients depend on the wire shape would make the pinning legible.

  4. Re-sending includeSummary on a continuation silently re-runs the O(N) summary scan every page. let scope_summaries = if params.include_summary || needs_universe (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:425) honors the re-sent flag even with a cursor present, where the pinned instants make the result byte-identical to page 1's — so a client that naively keeps includeSummary in its request state pays the PR's acknowledged O(N) hot spot on every "load more". #9095 guards this client-side (includeSummary: !cursor), but SDK/GraphQL/REST consumers aren't protected, and the field has no doc (libs/@local/graph/store/src/entity/table.rs:388 area). At minimum document that the summary is intended for cursor-less requests; a cursor.is_none() short-circuit is defensible too (with the caveat that it forecloses deliberately re-requesting the summary later).

  5. The universe-only summary scan aggregates type titles nobody reads. scope_summary always requests type_ids: true, type_titles: true (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:731). In the needs_universe && !include_summary path only the type-id keys are consumed, yet requesting titles adds a second unnest(type_titles[1:direct_types]) SRF and a min(t.title) per group inside the very scan the PR flags as the O(N) hot spot (and whose SRF misestimation is the stated reason for disabling JIT). Passing type_titles: params.include_summary would trim it — EntitySummaryQuery::new/statement/decode need a small tweak since type_ids || type_titles share TypeColumns today.

  6. Full inheritance-depth type arrays are transported and parsed per row, then truncated client-side. RowIndices::decode reads the whole entity_edition_cache.versioned_urls/type_titles arrays and then .take(direct_type_count) (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:262); the same pattern runs twice more per link row in LinkEndpointIndices::decode (table.rs:191). Every inherited entry is shipped and parsed into a validated VersionedUrl only to be discarded — the summary branch already slices versioned_urls[1:direct_types] server-side. Per-page cost is bounded, so this is an optimization, not a blocker (and the generic SelectCompiler lacks array-slice expressions today, so it's not a one-liner).

  7. The pinned snapshot instant is minted from the wall clock, not the snapshot. Timestamp::now() (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:356) is taken after the REPEATABLE READ snapshot was established, so a writer whose recorded transaction_time start is <= now but which commits after the snapshot is invisible to page 1's summary/count/universe yet matches transaction_time @> now in the fresh transactions serving continuations — rows can bleed into later pages uncounted, and a bled-in entity with a type outside the pinned universe is silently dropped, contradicting the documented "follow-up pages continue on the first page's database state". Since writers stamp their own pre-commit start times, no reader-side instant fully closes this; the actionable ask is a code comment documenting the accepted residual window.

  8. Type exclusion matches inherited types, but the doc and the test only cover direct types. Exclude compiles to NOT(base_urls @> ...) (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:861), and per the V51 migration base_urls covers all inheritance depths — so excluding a base URL hides entities whose inherited types include it, while the summary pills are direct-type-only. Likely intended (it matches the legacy noisy-system-types semantics), but the variant doc ("Entities carrying an excluded type are left out entirely", libs/@local/graph/store/src/entity/table.rs:205) never says inheritance counts, and the integration test only excludes a directly-carried type. Document the semantics on the variant and pin them with a subtype test so the behavior is a contract rather than an accident of the cache layout.

  9. The multi-type exclusion case the code specifically claims to handle is untested. scope_filter's comment says placing the exclusion in the shared scope "catches multi-type entities that also carry a universe type", and the store docs promise excluded entities leave the rows, count, and summary alike — but excluded_type_base_urls_leave_the_universe_and_the_summary (tests/graph/integration/postgres/table.rs:913) uses only single-type entities. If the exclusion migrated into the page's type clause (the natural refactor the comment warns against), a person+page entity would leak back into the rows via its person type with no test failing. Reusing the multi-type fixture with page/ excluded and asserting rows == 3, count == 3, person pill == 3 would pin it.

  10. Direct-vs-inherited truncation in row decode is exercised but never asserted. The seeded friend-of link type inherits from the block-protocol link type, so link_rows_carry_their_endpoints does run the .take(direct_type_count) path — but it asserts only the endpoints' entity_type_ids (inheritance-free persons), never the link row's own. Delete the .take(...) calls (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:264, :269) and the whole suite still passes while rows show inherited types. One assertion — link_row.entity_type_ids == vec[friend_of_type] — pins the contract; the summary's [1:direct_types] slice has the same gap (no test asserts inherited types are absent from the pills).

  11. Cursor pinning of include_drafts is untested — only sort pinning has a contradiction test. a_continuation_reads_its_sort_from_the_token sharply verifies a contradicting re-sent sort is ignored, but drafts_are_hidden_unless_requested (tests/graph/integration/postgres/table.rs:1164) re-sends include_drafts: true on every continuation, so token and request always agree. A regression that read draft visibility from the request would silently desync the keyset arity (three sort columns vs. a two-value position — and the zip truncates rather than erroring, see Suggestion 1), producing wrong pages with no failure. A one-page test flipping includeDrafts on the continuation and asserting the sequence is unchanged would mirror the sort test.

  12. The exact-fill test passes even if the behavior it names regresses. a_page_exactly_filling_the_limit_hands_out_a_final_empty_page relies on collect_all_pages, whose let Some(new_cursor) = response.cursor else { break; } (tests/graph/integration/postgres/table.rs:461) silently tolerates a full page returning no cursor — with total == limit, the loop would break after page 1 with rows.len() == 5 and still pass, which is exactly the boundary the documented contract ("a page that exactly fills the limit still hands one out") is about. Asserting response.cursor.is_some() inside the helper whenever page_len == limit closes it and strengthens every other paging test.

  13. The TimeInterval cursor never executes against Postgres. EditionCreatedAtDecisionTime, TypeTitle, and Archived exist only as ORDER BY goldens (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:1234); no test pages them against the database. The tie/keyset machinery itself is base-branch code already DB-tested elsewhere (sorting.rs pages tied non-NULL values with chunk size 1), and TypeTitle's column expression mirrors the DB-tested Label — so the one gap with real exposure is EditionCreatedAtDecisionTime: it maps to EntityQueryPath::DecisionTime, whose keyset value is a tstzrange (CursorField::TimeInterval), and no test anywhere pages by a TimeInterval cursor. One collect_all_pages run under that key would give the ToSql/comparison path its first-ever execution.

  14. statement_count_without_types_keeps_the_count_branch duplicates the pre-existing statement_count_only verbatim. The PR-added test (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/summary.rs:469) constructs the identical count-only EntitySummaryQuery and asserts the identical SQL string as the existing test at summary.rs:494. Its name suggests it was meant to vary something — e.g. count + web_ids without types, a distinct un-goldened shape. Repurpose or delete.

  15. EntityTableRow uses parallel arrays for type ids and titles. entity_type_ids: Vec<VersionedUrl> / entity_type_titles: Vec<String> (libs/@local/graph/store/src/entity/table.rs:425) make the invalid state (mismatched lengths) representable on the wire and force clients to zip by index; a single Vec<{ entityTypeId, title }> rules it out by construction (the same PR's EntityTableSummary already uses the mismatch-free map shape). The inconsistency is visible in-PR: EntityTableLinkEndpoint (table.rs:403) carries ids with no titles, so endpoint chips resolve through closedMultiEntityTypes while row chips read inline titles — two rendering paths for the same concept in one response. Painful to change once clients depend on it.

  16. [Stack context] The endpoint hard-codes KeysFirst with no runtime kill-switch. Carrying over the base-branch (#9048) observation, newly relevant here: compiler.set_statement_shape(StatementShape::KeysFirst) (libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs:527) is unconditional, and no env var or PostgresStoreSettings field can force SinglePass at runtime — the only fallbacks are structural ShapeFallback conditions this endpoint never triggers. The generic read path at least conditions its opt-in on has_embeddings_filter(); and #9095 deletes the frontend's previous table query path, so a keys-first plan regression in production (the compiler's own doc admits "a misestimated filter can still bait it into an ordered scan that never fills the limit") has no mitigation short of revert-and-redeploy. Thread a settings/env override mirroring how settings.filter_protection is consulted a few lines above, or document the intended operational rollback.

Strengths

  • The one-transaction REPEATABLE READ design (summary → derived universe → page) genuinely closes the staleness window BE-705 describes, and pinning bare instants in the cursor makes the interval axes that would break the one-row-per-entity shape unrepresentable — the continuation correctly survives concurrent edition rewrites because closed decision intervals land in new row versions the pinned transaction_time excludes.
  • Keyset pagination is unusually careful: the token seals sort, draft visibility, and snapshot so a contradicting re-sent request cannot desynchronize the keyset (adversarially tested in a_continuation_reads_its_sort_from_the_token), and NULL sort boundaries are handled in both orderings including the exhausted-trailing-NULL-group case — with the label-sort test paging limit-2 across the NULL keyset boundary, the hardest path of the cursor machinery.
  • Permission and protection parity with the generic read path is real, not asserted: user property filters pass through the same transform_filter rewrite before they can influence rows or the count (closing the enumeration oracle, re-verified end-to-end for this endpoint in email_filter_protection.rs), rows are property-masked, and link endpoints are nulled via the same filter_knowledge_edges policy check the subgraph traversal uses — batched over the whole page, applied before the closed-type maps are assembled.
  • The keys-first shape is exactly right for the misestimating planner: the roots/limited CTEs carry only narrow key columns through filter+sort+LIMIT, wide jsonb/array columns are hydrated for at most limit rows, and both CTEs stay single-reference so PG12+ inlines them — pinned by the table_page_statement golden.
  • The count fold (count(*) FILTER (WHERE ord = 1)) is correct given every edition has at least one direct type, decode initializes to 0 so empty scopes are right, and the ride-along is gated precisely on count_matches_scope, where the derived universe clause is provably result-neutral — saving a second full scan.
  • SET LOCAL jit = off for the summary scan is a targeted fix for a real Postgres failure mode (SRF row-count misestimates blowing past JIT cost thresholds), correctly transaction-scoped with a comment explaining why the remaining limited keyset reads are unaffected.
  • The derived universe compiles to a single GIN-indexable versioned_urls && ARRAY[...] overlap and base-URL exclusions merge into one NOT(base_urls && ...) — estimable, index-friendly shapes rather than OR chains; instant-only axes let the summary skip DISTINCT ON (Deduplication::Skip) unless a to-many join actually exists.
  • No N+1 on the hot path: one filter_knowledge_edges call per page, get_closed_multi_entity_types/resolve-definitions run once over deduplicated type ids, and the endpoint types of both link sides are verifiably included in the response's closed-type maps.
  • The test suite bakes paging invariants into every test via collect_all_pages (no duplicate rows across pages, page never exceeds limit, short page carries no continuation), and SQL goldens pin the page statement, exclusion scopes, all 13 property operators, sort keys, and the summary fold, so statement-shape regressions fail loudly. Boundary coverage is broad: empty include lists match nothing, excluded web yields zero count and empty pills, exact page size, archived/draft visibility both ways, garbage cursor tokens rejected, and the multi-type count fold.

Generated by Claude Code

@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 12:20 Inactive
@cursor

cursor Bot commented Jul 25, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
New read path touches authorization, sensitive-property filter rewriting, and complex Postgres query planning; behavior is heavily tested but mistakes could affect data visibility or table performance at scale.

Overview
Introduces a dedicated entities table read path so table UIs can fetch flat, materialized rows (plus optional type summary and count) in one transaction instead of coordinating subgraph queries on the client.

Graph store & Postgres: New query_entities_table on EntityStore runs summary, count, and paging inside a single repeatable-read transaction. First pages without an explicit type filter derive a pinned type universe from the scope summary (capped at 512 types) to keep queries planner-friendly; continuation uses an opaque base64 cursor that seals snapshot instants, sort, universe, and keyset position so re-sent requests cannot drift. Table-specific filters cover web include/exclude, type narrowing/exclusion by base URL, archived visibility, and a fixed set of property operators; rows can include link endpoints with permission checks and the same filter-protection/masking as generic reads. Summary SQL folds total count into the type aggregate when possible and disables JIT on the summary scan.

API layers: REST POST /entities/query/table and OpenAPI schemas; GraphQL queryEntitiesTable resolver delegating to the TypeScript SDK’s queryEntitiesTable. Property conversion is refactored to convert_properties for reuse on table rows.

Tests & misc: Integration tests for paging, cursors, scoping, link permissions, and email filter protection on the table path; marks generated OpenAPI as linguist-generated. Frontend entities visualizer TODO for BE-705 is removed (client wiring is follow-up).

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

@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 25, 2026 12:53 Inactive
Comment thread libs/@local/graph/api/src/rest/entity/query/mod.rs
@TimDiekmann
TimDiekmann requested a review from a team July 25, 2026 13:00
Base automatically changed from t/be-598-investigate-fetch-keys-then-hydrate-projection-for-the to main July 27, 2026 17:27
The entities table no longer assembles its rows from a subgraph: the new
/entities/query/table endpoint serves flat rows built from materialized
columns, with the type summary, the count, and the page read in one
repeatable-read transaction. When no explicit type filter is given, the
visible-type universe is derived from the summary and drives the page
query as an indexable include clause. The count rides along in the
summary scan when the page carries no narrowing filters.

Follow-up pages continue on the first page's database state through an
opaque cursor sealing two snapshot instants, the type universe, the sort,
the draft visibility, and the keyset position. Property filters pass
through the filter-protection rewrite, rows are masked like the generic
read path, and link endpoints pass the knowledge-edge permission check.

The endpoint is exposed through the Node API as queryEntitiesTable and
branded in the TypeScript SDK.
Eq derives for the property-filter types, a slice pattern instead of
repeated indexing, expects for the long test bodies and the impl, and a
reasoned expect for the summary request's independent include switches.
The dedicated endpoint this PR adds is what the note asked for, and the
table view moves onto it in the follow-up PR.
…draft rows

A selection used to replace the base-URL exclusions rather than narrow
within them, so picking a type pill brought the noisy system types back
into the rows, the count, and the pills. The exclusions move to their own
filter field that always applies, and the selection is a plain list of
versioned ids: absent for no narrowing, empty to match nothing. The
visible-type universe stays a Graph-internal notion.

Draft entities are no longer rows. Including them dropped the compiler's
draft_id IS NULL from every temporal-metadata join, including the ones
hydrating a link row's endpoints — an endpoint with drafts matched one row
per edition, letting a product row mix columns from different editions
while the outer DISTINCT ON collapsed the fan-out arbitrarily. Excluding
drafts makes the one-row-per-key invariant structural.

The derived universe is capped: past the limit it is dropped rather than
grown into the cursor token and the statement's parameter list, which the
scope filter makes safe. Cursor positions of the wrong arity are rejected
instead of compiling into a silently weakened keyset.

New tests pin the snapshot a continuation reads, the summary's policy
scope, and that a selection cannot bring back an excluded type.
A continuation whose sequence had no universe pinned — because it exceeded
the limit — read that as "not derived yet" and paid the full summary scan
again, on every page, only to drop the universe again each time. The token
already says what its sequence runs on, so only a first page derives one.

The cap moves into a function of its own and gets a test: the boundary and
the deterministic order it needs for the cursor were unpinned.
@claude
claude Bot force-pushed the t/be-705-graph-entities-table-endpoint branch from 84f441b to 8cfdd9f Compare July 27, 2026 18:28

@cursor cursor Bot 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.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8cfdd9f. Configure here.

let type_filter = type_selection
.as_ref()
.or(type_universe.as_ref())
.map(|entity_type_ids| type_include_filter(entity_type_ids));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pinned universe loses to type selection

Medium Severity

type_selection.as_ref().or(type_universe.as_ref()) prefers a re-sent entity_type_ids over a cursor-pinned type_universe. Sort is read from the token and ignores a contradicting request (and has a test for that), but a pinned universe is not. A continuation that also carries a type selection drops the sealed include clause, and the next cursor then omits type_universe entirely, so later pages can skip, repeat, or widen relative to the original sequence.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8cfdd9f. Configure here.

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.

Verified against 8cfdd9f — this is the documented contract rather than a bug, so no change needed:

  • A sequence started with a type selection never pins a universe (needs_universe requires type_selection.is_none()), and the client re-sends entityTypeIds on every continuation to keep the narrowing. The request's selection winning over the pinned universe is what makes that legitimate flow work; ignoring it when a cursor is present would break it. See the type_universe doc in libs/@local/graph/store/src/entity/table.rs ("the sequence runs on a type selection the client re-sends").
  • The pinned universe is a result-neutral planner aid — the scope filter alone decides row membership, and the snapshot instants + sort + keyset position always come from the token. So a continuation that swaps in its own entityTypeIds can't repeat rows, and the rows it gets are exactly what its own filter asked for at the pinned snapshot.
  • Changing any filter mid-cursor (webs, propertyFilters, includeArchived, …) has the same effect; entityTypeIds isn't special. The sort field is token-protected only because an honored contradicting sort would flip the keyset comparison and make the position meaningless — there's no equivalent hazard for a changed type filter.

The one real gap here is defense-in-depth: uniformly rejecting (or documenting) filter changes alongside a cursor would make the contract explicit. That's a design tightening, not a fix for this pair specifically.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark results

@rust/hash-graph-benches – Integrations

policy_resolution_large

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2002 $$27.8 \mathrm{ms} \pm 215 \mathrm{μs}\left({\color{gray}-0.839 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.43 \mathrm{ms} \pm 24.1 \mathrm{μs}\left({\color{gray}-1.473 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 1002 $$13.5 \mathrm{ms} \pm 127 \mathrm{μs}\left({\color{gray}2.70 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 3314 $$45.0 \mathrm{ms} \pm 426 \mathrm{μs}\left({\color{gray}2.75 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$14.9 \mathrm{ms} \pm 145 \mathrm{μs}\left({\color{gray}1.63 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 1527 $$25.4 \mathrm{ms} \pm 208 \mathrm{μs}\left({\color{gray}3.20 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 2078 $$29.2 \mathrm{ms} \pm 218 \mathrm{μs}\left({\color{gray}-1.116 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.80 \mathrm{ms} \pm 24.8 \mathrm{μs}\left({\color{gray}-0.390 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 1033 $$14.4 \mathrm{ms} \pm 141 \mathrm{μs}\left({\color{gray}-3.854 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_medium

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 102 $$3.89 \mathrm{ms} \pm 33.2 \mathrm{μs}\left({\color{gray}1.20 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.01 \mathrm{ms} \pm 21.7 \mathrm{μs}\left({\color{gray}-0.788 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 52 $$3.37 \mathrm{ms} \pm 21.2 \mathrm{μs}\left({\color{gray}-0.701 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 269 $$5.23 \mathrm{ms} \pm 38.2 \mathrm{μs}\left({\color{gray}-0.841 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.57 \mathrm{ms} \pm 21.7 \mathrm{μs}\left({\color{gray}-0.887 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 108 $$4.13 \mathrm{ms} \pm 28.0 \mathrm{μs}\left({\color{gray}-0.721 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 133 $$4.46 \mathrm{ms} \pm 38.2 \mathrm{μs}\left({\color{gray}0.837 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.46 \mathrm{ms} \pm 22.9 \mathrm{μs}\left({\color{gray}-0.675 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 63 $$4.13 \mathrm{ms} \pm 29.7 \mathrm{μs}\left({\color{gray}0.094 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_none

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2 $$2.71 \mathrm{ms} \pm 30.9 \mathrm{μs}\left({\color{gray}-1.756 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.56 \mathrm{ms} \pm 23.7 \mathrm{μs}\left({\color{gray}-2.003 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 2 $$2.71 \mathrm{ms} \pm 22.6 \mathrm{μs}\left({\color{gray}-2.819 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 8 $$2.93 \mathrm{ms} \pm 14.0 \mathrm{μs}\left({\color{gray}-1.445 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$2.75 \mathrm{ms} \pm 26.8 \mathrm{μs}\left({\color{gray}-0.154 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 3 $$2.92 \mathrm{ms} \pm 15.7 \mathrm{μs}\left({\color{gray}-1.348 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_small

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 52 $$3.07 \mathrm{ms} \pm 19.3 \mathrm{μs}\left({\color{gray}-3.424 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.74 \mathrm{ms} \pm 16.7 \mathrm{μs}\left({\color{gray}-4.032 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 26 $$3.01 \mathrm{ms} \pm 19.0 \mathrm{μs}\left({\color{gray}-2.387 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 94 $$3.46 \mathrm{ms} \pm 24.9 \mathrm{μs}\left({\color{gray}-3.135 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$2.99 \mathrm{ms} \pm 21.2 \mathrm{μs}\left({\color{gray}-1.584 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 27 $$3.29 \mathrm{ms} \pm 19.6 \mathrm{μs}\left({\color{gray}-2.782 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 66 $$3.37 \mathrm{ms} \pm 15.9 \mathrm{μs}\left({\color{gray}-3.524 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.02 \mathrm{ms} \pm 25.9 \mathrm{μs}\left({\color{gray}-1.450 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 29 $$3.35 \mathrm{ms} \pm 20.5 \mathrm{μs}\left({\color{gray}-3.039 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_complete

Function Value Mean Flame graphs
entity_by_id;one_depth 1 entities $$43.7 \mathrm{ms} \pm 296 \mathrm{μs}\left({\color{gray}-0.927 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 10 entities $$34.7 \mathrm{ms} \pm 262 \mathrm{μs}\left({\color{gray}-2.465 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 25 entities $$35.9 \mathrm{ms} \pm 239 \mathrm{μs}\left({\color{lightgreen}-6.752 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 5 entities $$33.2 \mathrm{ms} \pm 235 \mathrm{μs}\left({\color{gray}-3.352 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 50 entities $$44.5 \mathrm{ms} \pm 234 \mathrm{μs}\left({\color{gray}0.518 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 1 entities $$51.8 \mathrm{ms} \pm 501 \mathrm{μs}\left({\color{gray}-3.370 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 10 entities $$42.2 \mathrm{ms} \pm 324 \mathrm{μs}\left({\color{gray}-1.782 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 25 entities $$93.5 \mathrm{ms} \pm 560 \mathrm{μs}\left({\color{gray}-4.575 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 5 entities $$35.8 \mathrm{ms} \pm 292 \mathrm{μs}\left({\color{gray}-0.756 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 50 entities $$278 \mathrm{ms} \pm 1.13 \mathrm{ms}\left({\color{lightgreen}-14.122 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 1 entities $$11.2 \mathrm{ms} \pm 90.0 \mathrm{μs}\left({\color{lightgreen}-6.542 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 10 entities $$11.4 \mathrm{ms} \pm 93.4 \mathrm{μs}\left({\color{gray}-2.622 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 25 entities $$11.3 \mathrm{ms} \pm 75.3 \mathrm{μs}\left({\color{gray}-2.281 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 5 entities $$11.4 \mathrm{ms} \pm 99.2 \mathrm{μs}\left({\color{gray}-1.759 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 50 entities $$11.2 \mathrm{ms} \pm 77.2 \mathrm{μs}\left({\color{gray}-2.224 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_linkless

Function Value Mean Flame graphs
entity_by_id 1 entities $$11.2 \mathrm{ms} \pm 98.2 \mathrm{μs}\left({\color{gray}-2.158 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10 entities $$11.5 \mathrm{ms} \pm 96.0 \mathrm{μs}\left({\color{gray}-3.081 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 100 entities $$11.5 \mathrm{ms} \pm 80.1 \mathrm{μs}\left({\color{gray}-0.832 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 1000 entities $$11.4 \mathrm{ms} \pm 84.0 \mathrm{μs}\left({\color{gray}-2.046 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10000 entities $$11.6 \mathrm{ms} \pm 111 \mathrm{μs}\left({\color{gray}-1.645 \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.7 \mathrm{ms} \pm 89.6 \mathrm{μs}\left({\color{gray}-2.648 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1 $$12.2 \mathrm{ms} \pm 82.3 \mathrm{μs}\left({\color{gray}1.67 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1 $$11.8 \mathrm{ms} \pm 77.1 \mathrm{μs}\left({\color{gray}-1.980 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1 $$11.6 \mathrm{ms} \pm 82.3 \mathrm{μs}\left({\color{gray}-4.188 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2 $$12.3 \mathrm{ms} \pm 102 \mathrm{μs}\left({\color{gray}3.63 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1 $$12.1 \mathrm{ms} \pm 89.2 \mathrm{μs}\left({\color{gray}1.09 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/playlist/v/1 $$12.1 \mathrm{ms} \pm 103 \mathrm{μs}\left({\color{gray}2.30 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1 $$11.9 \mathrm{ms} \pm 78.1 \mathrm{μs}\left({\color{gray}-0.431 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/uk-address/v/1 $$12.0 \mathrm{ms} \pm 96.7 \mathrm{μs}\left({\color{gray}-1.850 \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.59 \mathrm{ms} \pm 46.6 \mathrm{μs}\left({\color{gray}-3.051 \mathrm{\%}}\right) $$ Flame Graph

representative_read_multiple_entities

Function Value Mean Flame graphs
entity_by_property traversal_paths=0 0 $$61.4 \mathrm{ms} \pm 535 \mathrm{μs}\left({\color{gray}2.12 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$113 \mathrm{ms} \pm 790 \mathrm{μs}\left({\color{lightgreen}-5.517 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$66.7 \mathrm{ms} \pm 573 \mathrm{μs}\left({\color{lightgreen}-5.183 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$76.7 \mathrm{ms} \pm 656 \mathrm{μs}\left({\color{gray}-2.221 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$87.0 \mathrm{ms} \pm 635 \mathrm{μs}\left({\color{gray}-3.216 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$91.5 \mathrm{ms} \pm 831 \mathrm{μs}\left({\color{lightgreen}-9.388 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=0 0 $$44.2 \mathrm{ms} \pm 330 \mathrm{μs}\left({\color{lightgreen}-5.685 \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 $$72.6 \mathrm{ms} \pm 514 \mathrm{μs}\left({\color{gray}-3.011 \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 $$51.0 \mathrm{ms} \pm 365 \mathrm{μs}\left({\color{lightgreen}-5.550 \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 $$61.6 \mathrm{ms} \pm 427 \mathrm{μs}\left({\color{gray}-2.782 \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 $$61.3 \mathrm{ms} \pm 308 \mathrm{μs}\left({\color{lightgreen}-5.747 \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 $$61.8 \mathrm{ms} \pm 480 \mathrm{μs}\left({\color{gray}-4.980 \mathrm{\%}}\right) $$

scenarios

Function Value Mean Flame graphs
full_test query-limited $$120 \mathrm{ms} \pm 664 \mathrm{μs}\left({\color{gray}-3.704 \mathrm{\%}}\right) $$ Flame Graph
full_test query-unlimited $$133 \mathrm{ms} \pm 633 \mathrm{μs}\left({\color{gray}-0.690 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-limited $$19.0 \mathrm{ms} \pm 147 \mathrm{μs}\left({\color{lightgreen}-7.940 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-unlimited $$524 \mathrm{ms} \pm 1.26 \mathrm{ms}\left({\color{gray}-3.529 \mathrm{\%}}\right) $$ Flame Graph

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/apps > hash* Affects HASH (a `hash-*` app) area/apps > hash-api Affects the HASH API (app) area/apps area/deps Relates to third-party dependencies (area) area/libs Relates to first-party libraries/crates/packages (area) area/tests New or updated tests type/eng > backend Owned by the @backend team type/eng > frontend Owned by the @frontend team

Development

Successfully merging this pull request may close these issues.

2 participants