Skip to content

BE-2: Use the Postgres clock for writing timestamps and resolving temporal axes - #9111

Draft
claude[bot] wants to merge 10 commits into
mainfrom
c/be-2-use-postgres-clock-for-timestamps
Draft

BE-2: Use the Postgres clock for writing timestamps and resolving temporal axes#9111
claude[bot] wants to merge 10 commits into
mainfrom
c/be-2-use-postgres-clock-for-timestamps

Conversation

@claude

@claude claude Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Requested by Tim Diekmann · Slack thread

Blocked on BE-688. The design intent is transaction-frozen now() as the single clock, with no timestamp threaded through operations. That cannot be expressed while the integration harness runs each test in one transaction, where a frozen now() makes a create-then-archive produce an empty tstzrange. This branch therefore uses statement_timestamp() and passes the operation's reading explicitly. Per-test databases (BE-688) unblock the switch.

🌟 What is the purpose of this PR?

Before: the Graph resolved temporal axes (the "now" a query runs against) with the host's clock, while ontology writes stamp rows with the Postgres clock. With any drift between the two (~50 ms is enough), a client could archive an entity type and still get it back from the next read, or unarchive it and have it stay invisible — the read's "now" lands on the wrong side of the interval bound the write just recorded. Entity writes were host-clock-stamped too, so DB-now()-based lookups (e.g. user-by-email) had the mirror-image race.

After: the Postgres clock is the only clock for query-relevant timestamps. Reads resolve their temporal axes with a timestamp read from the database, and all writes — entity and ontology alike — take their transaction time from a database clock reading fetched once per operation and passed to every statement as a parameter. Archive-then-read and unarchive-then-read are consistent regardless of host clock drift, and all timestamps within one operation share a single value.

How (zero extra round trips on the hot paths): every operation already starts by building PolicyComponents, whose first statement is the determine_actor actor lookup. That statement now additionally returns statement_timestamp(), and PolicyComponents carries the reading so all resolve_with(...) calls and write stamps of the operation reuse it. statement_timestamp() (rather than now()) is used for all database clock reads: it aligns with the REPEATABLE READ snapshot (taken at the transaction's first statement, not at BEGIN) and it still advances per statement inside a shared transaction — the integration-test harness runs each test in one rollback transaction, where a frozen now() gives distinct operations identical timestamps (and empty, undecodable intervals for archive-after-create).

🔗 Related links

🔍 What does this change?

  • hash-graph-authorization:
    • PrincipalStore gains determine_actor_with_timestamp (actor lookup + statement_timestamp() in one statement; for the public actor only the clock is read) and current_timestamp (standalone clock read, used as a fallback).
    • PolicyComponents carries the database clock reading, exposed via PolicyComponents::timestamp(); the builder accepts with_timestamp(...) for callers that already captured the operation's reading.
  • hash-graph-store: the parameterless QueryTemporalAxesUnresolved::resolve() is removed — callers must supply a timestamp via resolve_with(...), so the host clock can no longer leak into query resolution.
  • hash-graph-postgres-store, read paths:
    • All 22 production resolution call sites now use resolve_with(...) with the operation's database timestamp: entity reads (query_entities, query_entity_subgraph, summarize_entities, get_entity_by_id), ontology reads (query/count/subgraph for data/property/entity types), has_permission_for_*, and the StoreProvider validation lookups (which now carry the surrounding operation's timestamp).
    • query_entities/query_entity_subgraph previously resolved twice per request (query pass and permission pass) with two different host-clock values; both passes now share the one database reading.
  • hash-graph-postgres-store, write paths:
    • create_entities takes its transaction time from its first in-transaction statement (the actor lookup) instead of the host clock before the transaction; remove_nanosecond() is dropped there since Postgres timestamps are µs-precision by construction.
    • patch_entity takes its transaction time from lock_entity_edition, whose SQL now filters on COALESCE($n, statement_timestamp()) and returns the reading (LockedEntityEdition::locked_at); a client-supplied decision time is still honoured in the lock's WHERE.
    • Entity deletion reads the clock from the write transaction instead of Timestamp::now().
    • Ontology writes (create_ontology_temporal_metadata, archive_ontology_type, unarchive_ontology_type, and their create_ontology_metadata / update_owned_ontology_id wrappers) no longer stamp with inline SQL now(): they take the operation's database clock reading as a parameter — archive/unarchive reuse the PolicyComponents reading, create/update fetch one at the start of their transaction. This gives distinct per-operation stamps even when operations share a transaction, while all statements of one operation still share a single value.
    • Snapshot-restore validation reads the clock once for its provider.
  • Integration tests: an archive → assert-gone → unarchive → assert-back round-trip test for ontology types (none existed), and a test asserting the resolved pinned timestamp of a subgraph response is bracketed by two database clock readings.

Round-trip accounting: authenticated reads and entity writes get the timestamp for free from the existing actor-lookup statement. A standalone SELECT statement_timestamp() only happens on paths that never ran the lookup first: ontology create/update transactions, public-actor requests, PolicyComponents built from an already-resolved ActorId without a supplied timestamp (create_web, policy CRUD internals), entity deletion, snapshot restore, and the get_closed_multi_entity_types REST endpoint. No hot read path pays an extra round trip.

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

  • Policy tables (policy_edition etc.) still stamp with inline SQL now(). They are not resolved through QueryTemporalAxes and are read with SQL-side now() comparisons on the same database clock, so they are unaffected by the drift this PR fixes; moving them onto the parameterized mechanism would be a mechanical follow-up.
  • The entities-table read path added by BE-705: Add a dedicated entities-table query endpoint to the Graph #9094 on main now takes its page snapshot instants from the operation's database clock reading rather than the host clock, so they are taken slightly earlier, within the same read transaction.
  • The [patch.crates-io] git pins (libp2p et al.) are unreachable from the sandbox this was authored in, so local cargo check/clippy/nextest could not run at all — rustfmt passes and the diff was reviewed carefully, but compile and test verification is deferred to CI. The Cargo.lock change is the single hand-added dependency edge for hash-graph-authorizationhash-graph-temporal-versioning (workspace members carry no checksums); mise was likewise unavailable, so the generated package.json wiring was mirrored by hand exactly like sibling crates.

🐾 Next steps

  • A clippy disallowed-methods guardrail for Timestamp::now()/OffsetDateTime::now_utc() in the store crates could enforce this statically; left out to avoid noise from the legitimate test-only uses.
  • When BE-332 moves authentication into the Graph, its auth statement becomes the natural first statement of PolicyComponents building and should keep returning the clock reading.
  • Interval::from_sql still panics (unimplemented!) on empty ranges. No write path can produce one anymore (per-operation stamps are strictly increasing within a transaction), but decoding one defensively as an error would be safer.

🛡 What tests cover this?

  • New: archive_unarchive_round_trip and resolved_temporal_axes_use_database_clock in tests/graph/integration/postgres/data_type.rs. The round-trip test also guards the ontology write stamps: with transaction-frozen now() stamps it fails inside the rollback-transaction harness (archive of a type created in the same test produced an empty, undecodable interval).
  • Existing: the full graph integration suite exercises every rewired read/write path; query/compile/tests.rs covers the compiler against explicitly resolved axes.

❓ How to test this?

  1. Run the graph integration tests (cargo nextest run --package hash-graph-integration) against a local Postgres.
  2. For the original repro: run Postgres with a clock offset from the host (e.g. under a VM whose clock drifts ahead), then create → archive → query and unarchive → query an entity type via the REST API; the query results now flip immediately with the write.

…poral axes

Temporal axes were resolved with the graph host's clock while ontology
writes stamp rows with the Postgres clock, so any drift between the two
made archive/unarchive-then-read return stale results. The database
clock is now the single time authority:

- `determine_actor` gains a sibling `determine_actor_with_timestamp`
  which returns `statement_timestamp()` from the same statement, and
  `PolicyComponents` carries that reading so every operation gets a
  database clock value without an extra round trip.
- All production `QueryTemporalAxesUnresolved::resolve()` call sites now
  use `resolve_with` with the operation's database timestamp; the
  parameterless `resolve()` is removed so the host clock can no longer
  leak into query resolution.
- Entity writes source their transaction time from the database:
  `create_entities` from its first in-transaction statement,
  `patch_entity` from the locking statement (which now filters on and
  returns `statement_timestamp()`), and entity deletion from a
  `current_timestamp` read on the write transaction.
- Adds an archive/unarchive round-trip integration test and a test
  asserting resolved axes come from the database clock.
@vercel

vercel Bot commented Jul 27, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
hash Building Building Preview Jul 30, 2026 4:28pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
hashdotdesign-tokens Ignored Ignored Preview Jul 30, 2026 4:28pm
petrinaut Skipped Skipped Jul 30, 2026 4:28pm

@github-actions github-actions Bot added area/deps Relates to third-party dependencies (area) area/libs Relates to first-party libraries/crates/packages (area) type/eng > backend Owned by the @backend team area/tests New or updated tests labels Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 2.29885% with 170 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.35%. Comparing base (639beae) to head (43e58c6).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...s-store/src/store/postgres/ontology/entity_type.rs 0.00% 62 Missing ⚠️
...cal/graph/postgres-store/src/store/postgres/mod.rs 0.00% 38 Missing ⚠️
...cal/graph/authorization/src/policies/components.rs 11.76% 30 Missing ⚠️
...s-store/src/store/postgres/knowledge/entity/mod.rs 0.00% 11 Missing ⚠️
...res-store/src/store/postgres/ontology/data_type.rs 0.00% 8 Missing ⚠️
...store/src/store/postgres/ontology/property_type.rs 0.00% 6 Missing ⚠️
...local/graph/postgres-store/src/store/validation.rs 0.00% 6 Missing ⚠️
.../graph/postgres-store/src/snapshot/entity/batch.rs 0.00% 5 Missing ⚠️
...tore/src/store/postgres/knowledge/entity/delete.rs 0.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9111      +/-   ##
==========================================
- Coverage   59.55%   59.35%   -0.21%     
==========================================
  Files        1408     1407       -1     
  Lines      137734   136311    -1423     
  Branches     6418     6382      -36     
==========================================
- Hits        82028    80904    -1124     
+ Misses      54711    54416     -295     
+ Partials      995      991       -4     
Flag Coverage Δ
apps.hash-ai-worker-ts 1.99% <ø> (ø)
apps.hash-api 12.09% <ø> (ø)
local.hash-backend-utils 2.55% <ø> (ø)
local.hash-graph-sdk 10.02% <ø> (ø)
local.hash-isomorphic-utils 6.37% <ø> (+0.62%) ⬆️
rust.hash-graph-authorization 62.21% <11.76%> (-0.38%) ⬇️
rust.hash-graph-store 42.19% <ø> (+0.03%) ⬆️
rust.hash-graph-validation 84.71% <ø> (ø)
rust.hashql-compiletest 28.39% <ø> (ø)
rust.hashql-eval 79.82% <ø> (ø)

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 tests/graph/integration/postgres/data_type.rs Fixed
@codspeed-hq

codspeed-hq Bot commented Jul 27, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 98 untouched benchmarks


Comparing c/be-2-use-postgres-clock-for-timestamps (43e58c6) with main (639beae)1

Open in CodSpeed

Footnotes

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

`yarn install --immutable` rejects the install when a package.json
declares a dependency the lockfile does not record. Also import `Cow`
from `alloc` to match the integration-test crate's convention.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 27, 2026 14:40 Inactive
Comment thread libs/@local/graph/authorization/src/policies/components.rs Fixed
- add the `timestamp` field to the remaining `PolicyComponents` test
  initializer
- drop the `EntityTypeStore` trait import which is no longer used now
  that the entity read paths call the inherent
  `get_closed_multi_entity_types_impl`
- expect `clippy::too_many_lines` on `execute_entity_deletion`
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 27, 2026 14:50 Inactive
Clippy's `missing_const_for_fn` flags `set_timestamp`, and making it
const makes `with_timestamp` const-eligible as well.
Ontology create/archive/unarchive statements stamped rows with inline
SQL `now()`, which is frozen for the lifetime of a transaction. Inside
a shared transaction — such as the integration-test harness's rollback
transaction — archiving a type created earlier in the same transaction
therefore produced an empty interval, which cannot be decoded when the
statement returns it.

The statements now take the operation's database clock reading as a
parameter, like the entity write paths: archive/unarchive reuse the
reading carried by their `PolicyComponents`, while create/update fetch
one at the start of their transaction. All statements of one operation
keep sharing a single value, and separate operations get distinct
stamps even within one transaction.
@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.6 \mathrm{ms} \pm 178 \mathrm{μs}\left({\color{gray}-2.642 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.49 \mathrm{ms} \pm 24.8 \mathrm{μs}\left({\color{gray}-3.618 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 1002 $$13.5 \mathrm{ms} \pm 110 \mathrm{μs}\left({\color{gray}-1.475 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 3314 $$44.8 \mathrm{ms} \pm 522 \mathrm{μs}\left({\color{gray}0.167 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$15.1 \mathrm{ms} \pm 146 \mathrm{μs}\left({\color{lightgreen}-6.933 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 1527 $$25.4 \mathrm{ms} \pm 209 \mathrm{μs}\left({\color{gray}-0.238 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 2078 $$29.3 \mathrm{ms} \pm 285 \mathrm{μs}\left({\color{gray}-0.367 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.80 \mathrm{ms} \pm 23.7 \mathrm{μs}\left({\color{gray}-3.857 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 1033 $$14.8 \mathrm{ms} \pm 154 \mathrm{μs}\left({\color{gray}-0.356 \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 26.5 \mathrm{μs}\left({\color{gray}0.445 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$3.08 \mathrm{ms} \pm 18.2 \mathrm{μs}\left({\color{gray}-0.122 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 52 $$3.46 \mathrm{ms} \pm 22.0 \mathrm{μs}\left({\color{gray}0.160 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 269 $$5.39 \mathrm{ms} \pm 46.1 \mathrm{μs}\left({\color{gray}0.539 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.64 \mathrm{ms} \pm 26.0 \mathrm{μs}\left({\color{gray}0.619 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 108 $$4.26 \mathrm{ms} \pm 26.7 \mathrm{μs}\left({\color{gray}0.859 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 133 $$4.57 \mathrm{ms} \pm 28.7 \mathrm{μs}\left({\color{gray}-0.351 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.56 \mathrm{ms} \pm 19.9 \mathrm{μs}\left({\color{gray}0.045 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 63 $$4.21 \mathrm{ms} \pm 29.0 \mathrm{μs}\left({\color{gray}-0.105 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_none

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 2 $$2.74 \mathrm{ms} \pm 19.4 \mathrm{μs}\left({\color{gray}-0.576 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.57 \mathrm{ms} \pm 15.8 \mathrm{μs}\left({\color{gray}-1.010 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 2 $$2.73 \mathrm{ms} \pm 17.6 \mathrm{μs}\left({\color{gray}0.411 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 8 $$3.02 \mathrm{ms} \pm 21.0 \mathrm{μs}\left({\color{gray}-0.553 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$2.80 \mathrm{ms} \pm 21.5 \mathrm{μs}\left({\color{gray}-0.627 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 3 $$3.02 \mathrm{ms} \pm 23.3 \mathrm{μs}\left({\color{gray}0.585 \mathrm{\%}}\right) $$ Flame Graph

policy_resolution_small

Function Value Mean Flame graphs
resolve_policies_for_actor user: empty, selectivity: high, policies: 52 $$3.09 \mathrm{ms} \pm 16.5 \mathrm{μs}\left({\color{gray}-0.324 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: low, policies: 1 $$2.81 \mathrm{ms} \pm 15.9 \mathrm{μs}\left({\color{gray}0.153 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: empty, selectivity: medium, policies: 26 $$3.06 \mathrm{ms} \pm 26.9 \mathrm{μs}\left({\color{gray}-0.504 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: high, policies: 94 $$3.52 \mathrm{ms} \pm 20.5 \mathrm{μs}\left({\color{gray}-0.148 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: low, policies: 1 $$3.05 \mathrm{ms} \pm 18.3 \mathrm{μs}\left({\color{gray}0.309 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: seeded, selectivity: medium, policies: 27 $$3.37 \mathrm{ms} \pm 21.9 \mathrm{μs}\left({\color{gray}0.257 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: high, policies: 66 $$3.45 \mathrm{ms} \pm 21.8 \mathrm{μs}\left({\color{gray}-1.225 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: low, policies: 1 $$3.03 \mathrm{ms} \pm 21.5 \mathrm{μs}\left({\color{gray}0.669 \mathrm{\%}}\right) $$ Flame Graph
resolve_policies_for_actor user: system, selectivity: medium, policies: 29 $$3.45 \mathrm{ms} \pm 32.1 \mathrm{μs}\left({\color{gray}0.566 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_complete

Function Value Mean Flame graphs
entity_by_id;one_depth 1 entities $$44.0 \mathrm{ms} \pm 272 \mathrm{μs}\left({\color{gray}-1.061 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 10 entities $$34.7 \mathrm{ms} \pm 211 \mathrm{μs}\left({\color{gray}-0.287 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 25 entities $$37.5 \mathrm{ms} \pm 258 \mathrm{μs}\left({\color{gray}-0.965 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 5 entities $$33.3 \mathrm{ms} \pm 224 \mathrm{μs}\left({\color{gray}-0.324 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;one_depth 50 entities $$44.9 \mathrm{ms} \pm 239 \mathrm{μs}\left({\color{gray}1.86 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 1 entities $$51.5 \mathrm{ms} \pm 460 \mathrm{μs}\left({\color{gray}-0.850 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 10 entities $$42.3 \mathrm{ms} \pm 209 \mathrm{μs}\left({\color{gray}0.733 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 25 entities $$95.7 \mathrm{ms} \pm 589 \mathrm{μs}\left({\color{gray}1.38 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 5 entities $$46.5 \mathrm{ms} \pm 3.64 \mathrm{ms}\left({\color{red}31.7 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;two_depth 50 entities $$316 \mathrm{ms} \pm 1.18 \mathrm{ms}\left({\color{gray}1.39 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 1 entities $$11.5 \mathrm{ms} \pm 70.6 \mathrm{μs}\left({\color{gray}-0.500 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 10 entities $$11.8 \mathrm{ms} \pm 68.3 \mathrm{μs}\left({\color{gray}0.013 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 25 entities $$11.8 \mathrm{ms} \pm 71.3 \mathrm{μs}\left({\color{gray}0.163 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 5 entities $$11.7 \mathrm{ms} \pm 81.1 \mathrm{μs}\left({\color{gray}0.841 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id;zero_depth 50 entities $$11.7 \mathrm{ms} \pm 94.0 \mathrm{μs}\left({\color{gray}0.305 \mathrm{\%}}\right) $$ Flame Graph

read_scaling_linkless

Function Value Mean Flame graphs
entity_by_id 1 entities $$11.6 \mathrm{ms} \pm 91.0 \mathrm{μs}\left({\color{gray}1.37 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10 entities $$11.8 \mathrm{ms} \pm 91.1 \mathrm{μs}\left({\color{gray}0.428 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 100 entities $$11.7 \mathrm{ms} \pm 68.4 \mathrm{μs}\left({\color{gray}0.505 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 1000 entities $$11.9 \mathrm{ms} \pm 78.2 \mathrm{μs}\left({\color{gray}1.19 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id 10000 entities $$12.2 \mathrm{ms} \pm 84.4 \mathrm{μs}\left({\color{gray}1.45 \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 $$12.0 \mathrm{ms} \pm 84.0 \mathrm{μs}\left({\color{gray}0.221 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/book/v/1 $$12.1 \mathrm{ms} \pm 143 \mathrm{μs}\left({\color{gray}-0.474 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/building/v/1 $$11.9 \mathrm{ms} \pm 66.4 \mathrm{μs}\left({\color{gray}-0.901 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/organization/v/1 $$12.0 \mathrm{ms} \pm 68.4 \mathrm{μs}\left({\color{gray}-0.278 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/page/v/2 $$12.1 \mathrm{ms} \pm 80.7 \mathrm{μs}\left({\color{gray}-0.487 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/person/v/1 $$12.0 \mathrm{ms} \pm 83.8 \mathrm{μs}\left({\color{gray}-0.380 \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 79.6 \mathrm{μs}\left({\color{gray}-1.058 \mathrm{\%}}\right) $$ Flame Graph
entity_by_id entity type ID: https://blockprotocol.org/@alice/types/entity-type/song/v/1 $$12.1 \mathrm{ms} \pm 72.1 \mathrm{μs}\left({\color{gray}-2.167 \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 69.1 \mathrm{μs}\left({\color{gray}-0.274 \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.94 \mathrm{ms} \pm 52.3 \mathrm{μs}\left({\color{gray}-0.308 \mathrm{\%}}\right) $$ Flame Graph

representative_read_multiple_entities

Function Value Mean Flame graphs
entity_by_property traversal_paths=0 0 $$61.6 \mathrm{ms} \pm 551 \mathrm{μs}\left({\color{gray}-0.447 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=255 1,resolve_depths=inherit:1;values:255;properties:255;links:127;link_dests:126;type:true $$118 \mathrm{ms} \pm 521 \mathrm{μs}\left({\color{gray}0.181 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:0;link_dests:0;type:false $$70.0 \mathrm{ms} \pm 562 \mathrm{μs}\left({\color{gray}1.57 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:0;links:1;link_dests:0;type:true $$81.0 \mathrm{ms} \pm 654 \mathrm{μs}\left({\color{gray}1.03 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:0;properties:2;links:1;link_dests:0;type:true $$90.6 \mathrm{ms} \pm 479 \mathrm{μs}\left({\color{gray}2.04 \mathrm{\%}}\right) $$
entity_by_property traversal_paths=2 1,resolve_depths=inherit:0;values:2;properties:2;links:1;link_dests:0;type:true $$96.7 \mathrm{ms} \pm 587 \mathrm{μs}\left({\color{gray}2.06 \mathrm{\%}}\right) $$
link_by_source_by_property traversal_paths=0 0 $$46.7 \mathrm{ms} \pm 276 \mathrm{μs}\left({\color{gray}1.72 \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 $$78.0 \mathrm{ms} \pm 395 \mathrm{μs}\left({\color{gray}3.94 \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 $$53.1 \mathrm{ms} \pm 341 \mathrm{μs}\left({\color{gray}1.65 \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 $$64.6 \mathrm{ms} \pm 389 \mathrm{μs}\left({\color{gray}4.47 \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 $$67.1 \mathrm{ms} \pm 398 \mathrm{μs}\left({\color{gray}3.19 \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 $$66.3 \mathrm{ms} \pm 293 \mathrm{μs}\left({\color{gray}3.79 \mathrm{\%}}\right) $$

scenarios

Function Value Mean Flame graphs
full_test query-limited $$114 \mathrm{ms} \pm 780 \mathrm{μs}\left({\color{lightgreen}-11.840 \mathrm{\%}}\right) $$ Flame Graph
full_test query-unlimited $$127 \mathrm{ms} \pm 681 \mathrm{μs}\left({\color{lightgreen}-9.900 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-limited $$20.3 \mathrm{ms} \pm 131 \mathrm{μs}\left({\color{gray}0.011 \mathrm{\%}}\right) $$ Flame Graph
linked_queries query-unlimited $$541 \mathrm{ms} \pm 1.30 \mathrm{ms}\left({\color{gray}-3.676 \mathrm{\%}}\right) $$ Flame Graph

Route the entities-table read path's snapshot instants through the
operation's database clock reading instead of the process clock, and
update the newly added compiler tests to resolve their temporal axes
explicitly.
@TimDiekmann
TimDiekmann marked this pull request as ready for review July 30, 2026 15:52
@TimDiekmann
TimDiekmann self-requested a review July 30, 2026 15:52
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Touches core read/write and permission paths across the graph store and removes the host-clock fallback for temporal resolution; incorrect timestamp threading could cause subtle consistency or visibility bugs.

Overview
Makes Postgres statement_timestamp() the single time authority for query resolution and writes, so reads and writes cannot disagree when the host clock drifts from the database.

Authorization / policy context: PolicyComponents now carries a database clock reading (timestamp(), with_timestamp). Building components captures time via determine_actor_with_timestamp (actor lookup + clock in one round trip) or current_timestamp when the actor is already known. PrincipalStore adds those methods plus CurrentTimestampError.

Temporal resolution: QueryTemporalAxesUnresolved::resolve() is removed; all call sites must use resolve_with(timestamp) from the operation’s reading (usually from policy components).

Postgres store: Entity and ontology reads, permission checks, validation StoreProvider, and table paging resolve axes with that shared timestamp. Writes stop using host Timestamp::now() / inline SQL now()—entity create/delete/patch take transaction time from the first in-transaction statement (actor lookup or lock_entity_edition returning locked_at); ontology create/update/archive/unarchive pass an explicit transaction_time parameter. get_closed_multi_entity_types is refactored to get_closed_multi_entity_types_impl with resolved axes.

Tests: Ontology archive/unarchive round-trip and a test that subgraph resolved pinned time is bracketed by two DB clock readings.

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

Comment thread libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs Outdated
… components

has_permission_for_entities_impl accepted the surrounding operation's clock
reading but only used it to resolve the temporal axes. For an already-resolved
AuthenticatedActor::Id the policy components builder therefore found no
timestamp and issued a standalone SELECT statement_timestamp(), whose result
was then discarded. Pass the reading to the builder instead, so the entity read
path with include_permissions performs no extra clock round trip.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 30, 2026 15:59 Inactive

@TimDiekmann TimDiekmann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should add this to the missing endpoints as well as you highlighted in slack.

Comment thread libs/@local/graph/authorization/src/policies/store/mod.rs Outdated
Comment thread libs/@local/graph/authorization/src/policies/store/mod.rs Outdated
Comment thread libs/@local/graph/authorization/src/policies/components.rs Outdated

let temporal_axes = params
.temporal_axes
.resolve_with(timestamp.unwrap_or_else(|| policy_components.timestamp()));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This implies that the timestamp is always resolved in the policy components, but it may not be needed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

It is always resolved: PolicyComponents::timestamp is Timestamp<()>, not Option (policies/components.rs:69), so every build ends with a reading whether or not the operation goes on to use one. The cost is not uniform though — for an unresolved actor it rides along on the lookup statement and is free; only an already-resolved actor with no supplied reading pays a statement of its own.

This call site always needs one regardless: the temporal axes are resolved with it two lines down (postgres/knowledge/entity/mod.rs:1146-1148). The unwrap_or_else you flagged is gone — the caller's reading is forwarded into the builder instead, so the permission check runs on the caller's instant rather than a second one.

Making it lazy would mean timestamp() becoming async and fallible, and a reading taken later than the components' other statements would break the "all timestamps in one operation agree" property this PR is after. So I kept it eager and stopped the docs implying it is free: policies/components.rs:93 and :374-378.


Generated by Claude Code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

My concern was why we pass in a timestamp when policy components always have one. Getting a timestamp should always happen inside of a transaction, so they should never differ as well, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right for now()/transaction_timestamp(), which is frozen for the whole transaction — two reads inside one transaction cannot differ, and the parameter would be dead weight. But the store's clock is statement_timestamp(), not now(): postgres/mod.rs:1313 and the statement_timestamp() column in determine_actor_with_timestamp at postgres/mod.rs:1293. That advances per statement, so two reads in one transaction do differ.

That matters here because one request builds PolicyComponents twice, not once: query_entities_impl builds for ViewEntity at entity/mod.rs:757, then has_permission_for_entities_impl builds a second one for UpdateEntity at entity/mod.rs:1133 (same shape in query_entity_subgraph_impl at :834 and :1010). Both do run inside the one REPEATABLE READ, READ ONLY transaction opened in query_entities at :1862, so under now() they'd agree by construction. Under statement_timestamp() they don't, and the entities would be read at one instant while permissions resolved at another — hence threading the first reading into the second build.

The only reason we're on statement_timestamp() is the test harness: tests/graph/integration/postgres/lib.rs:222 runs each test inside a single transaction that is rolled back, so a frozen now() gives a create and a subsequent archive in one test the identical stamp, and Postgres canonicalises tstzrange(T, T, '[)') to empty. That's the constraint recorded in TODO(BE-688) at postgres/mod.rs:3404.

So the real choice is yours: keep statement_timestamp() and thread the reading, or move to now() and delete the parameter — self-consistent and clearly nicer, but it can't land until the harness uses per-test databases (BE-688). I'd keep the threading for now and drop the parameter as part of BE-688, since that's the point at which the simplification is actually free. One caveat either way: the ontology archive/unarchive paths don't open an explicit transaction, so each statement is its own — the reading still has to be passed explicitly there whichever clock function we pick.


Generated by Claude Code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The only reason we're on statement_timestamp() is the test harness

This contradicts the reason why we're doing this in the first place. We must not change the code expectation to meet a test, that's reversed. If we read different timestamps in a single call. I'd say we block this on BE-688. Not a fan, but honest.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed — the harness shouldn't be setting the semantics, and blocking on BE-688 is the honest call.

End state: now() as the single clock, Option<Timestamp<()>> off has_permission_for_entities_impl, with_timestamp/set_timestamp off the builder, and PolicyComponents resolving one reading per transaction.

One caveat on reaching it, because it isn't uniform across the write paths. Create and update already build their components inside the transaction — data_type.rs:511 opens it, :528 builds on &transaction, :736 commits; update is :956/:1033/:1192 — so now() gives those one reading per operation and the parameter simply goes away. Archive and unarchive do not: archive_data_type builds on self at data_type.rs:1222 and unarchive_data_type at :1268, with no begin_transaction anywhere in either, so each statement is its own transaction and now() in the policy lookup and now() in the write are different values. Same shape in property_type.rs:1011/:1059 and entity_type.rs:1813/:1861. Threading would survive the switch on those six unless they're wrapped first.

The wrapping itself is mechanical — it's the create/update shape, and PolicyComponents::builder(&transaction) is already the established pattern — but it moves the policy read inside the write transaction, so I'd treat it as its own small change with its own review rather than something that rides along with the now() switch.

Which would you prefer: hold this PR as-is and do the now() switch once BE-688 lands, or make the switch now and accept the ontology integration tests being red until it does?


Generated by Claude Code

Comment thread libs/@local/graph/authorization/src/policies/store/mod.rs
Each arm of the actor match yields a concrete clock reading, so the
builder no longer carries an `Option` past the lookup. Behaviour is
unchanged: a supplied reading is taken as-is, an unresolved actor
captures one on its lookup statement, and an already-resolved actor
without one reads the clock on its own.

The entities-table page reads its closed entity types at the operation's
instant instead of at a clock reading of their own, saving a round trip
and keeping the type chips consistent with the rows they describe.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 30, 2026 16:09 Inactive
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

We should add this to the missing endpoints as well as you highlighted in slack.

Audited the entities-table endpoint from #9094 end to end. Findings, with the one gap fixed in e927367:

Gap found and fixed. query_entities_table_impl pinned its page at policy_components.timestamp(), but then hydrated the type chips through the EntityTypeStore::get_closed_multi_entity_types trait method, which resolves live_only() against a current_timestamp() of its own (postgres-store/src/store/postgres/ontology/entity_type.rs:1427). So the chips were read at a later instant than the rows they describe, at the cost of an extra round trip inside the read-only transaction. It now calls get_closed_multi_entity_types_impl with the page's instant (postgres-store/src/store/postgres/knowledge/entity/table.rs:646-661), which is what the generic query_entities path already does (knowledge/entity/mod.rs:689-698).

Clean, verified by reading:

  • REST handler query_entities_table (api/src/rest/entity/query/mod.rs:252-279) — deserialises, resolves the limit, hands off. No clock read, no cursor minting.
  • EntityTableCursor encode/decode (store/src/entity/table.rs:229-330) — carries the two instants from the first page; no TTL, no host clock, no re-resolution.
  • query_entities_table_impl — one clock reading for the whole request; scope_summary, table_count and filter_knowledge_edges all take the same &QueryTemporalAxes. get_entity_type_resolve_definitions has no temporal axes at all.
  • Every Timestamp::now() in table.rs is inside #[cfg(test)] mod tests (:1138, :1235, :1280, :1323).

The three sites left out of the earlier pass are all test-onlyapi/src/rest/utoipa_typedef/subgraph/edges.rs:267,284 (under #[cfg(test)] at :241), validation/src/lib.rs:109 (:44), postgres-store/src/store/postgres/traversal_context.rs:464 (:432). Nothing to convert.

Repo-wide sweep: no production host-clock reads remain under libs/@local/graph/ or apps/hash-graph/. Timestamp::now() / OffsetDateTime::now_utc() survive only in tests, in provenance fetched_at (api/src/rest/{data,property,entity}_type.rs, type-fetcher/src/fetcher_server.rs:238), in migration bookkeeping (migrations/src/postgres.rs:179,204), and in Timestamp::now itself. There is also no remaining production QueryTemporalAxesUnresolved::resolve().

Left for you to decide: the same trait method still resolves its own instant for its two other callers — api/src/rest/entity_type.rs:690 and type-fetcher/src/store.rs:1489. Both are standalone requests with no surrounding operation to share an instant with, so a reading of their own is correct there and I left them alone. Say the word if you want them routed through components anyway.

If you meant a different endpoint by "missing endpoints", name it and I'll take it in this PR.


Generated by Claude Code

Comment thread libs/@local/graph/postgres-store/src/store/postgres/mod.rs Outdated
The archive statement matched any edition whose transaction time contained
the supplied reading. Since that reading is now captured while building the
policy components rather than by the update statement itself, a concurrent
archive holding an older reading could match an edition another archive had
already closed and rewind its upper bound and provenance.

Restrict the update to the edition which is still open. The containment
check stays so a reading taken before the edition opened cannot produce an
inverted range.

Also describe when the plain actor lookup is appropriate rather than listing
its call sites.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 30, 2026 16:19 Inactive
Comment thread libs/@local/graph/postgres-store/src/store/postgres/mod.rs

@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 43e58c6. Configure here.

Comment thread libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/table.rs Outdated
The chips were resolved at a clock reading taken during the request. On a
continuation page the rows come from the cursor's pinned instants, which are
older, so a type edition archived or superseded in between was visible to the
row query but absent from the chip query. The lookup then missed an entry it
had already resolved from a row and hit the expect in
`get_closed_multi_entity_types_impl`.

Pass the page's own temporal axes, which are the cursor's instants on a
continuation page and the operation's reading on the first one.
@vercel
vercel Bot temporarily deployed to Preview – petrinaut July 30, 2026 16:28 Inactive
@TimDiekmann
TimDiekmann marked this pull request as draft July 30, 2026 16:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Development

Successfully merging this pull request may close these issues.

3 participants