Skip to content

feat(personhog): add version head, version floor and tombstone rpcs - #111233

Draft
eli-r-ph wants to merge 2 commits into
masterfrom
eli/personhog-version-floors
Draft

eli-r-ph wants to merge 2 commits into
masterfrom
eli/personhog-version-floors

Conversation

@eli-r-ph

@eli-r-ph eli-r-ph commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Problem

The ClickHouse person cleanup jobs cannot read or fix Postgres versions through personhog, so a revived person or distinct id can land below a ClickHouse tombstone and stay hidden.

  • The jobs need the stored version of each row, tombstones included.
  • They need a way to lift a tombstone to a floor so a later revival lands above it.
  • They need a way to tombstone distinct id rows whose person row is gone.

Changes

No user-visible change. Nothing calls these RPCs yet.

  • GetPersonVersionHeads and GetDistinctIdVersionHeads read stored versions from a replica, tombstones included. Missing keys are left out.
  • EnsurePersonVersionFloors and EnsureDistinctIdVersionFloors raise each tombstone below min_version to min_version on the primary.
  • A missing key gets a tombstone at the floor. A missing distinct id owner gets a version 0 person tombstone.
  • A live row is left unchanged and comes back LIVE with its current version.
  • The caller floors live keys to M+1 and republishes, so raising a live row to M was a redundant write that ties the ClickHouse tombstone at M until the republish.
  • TombstoneDistinctIds tombstones only orphaned distinct id rows: live rows whose person row does not exist. It sets is_deleted and adds 1 to the version.
  • A live row whose person row exists comes back NOT_ORPHANED, unchanged. A person tombstone counts as an existing person row.
  • The orphan check runs under the FOR UPDATE lock on the distinct id rows. Person ids are never reused, and the lock blocks repointing, so the check cannot go stale.
  • Each write takes at most 250 keys in one transaction, with a 2s lock timeout. A lost insert race fails the request with FAILED_PRECONDITION.
  • The Python helpers in posthog/models/person/util.py batch by the replica cap and retry a lost race up to 3 times.
  • tombstone_distinct_ids_and_publish publishes the person_distinct_id2 tombstone at exactly the version Postgres returned. It publishes nothing for ABSENT or NOT_ORPHANED.
  • Mechanical: the router KNOWN_METHODS, the Python client, the fake client, the proto re-exports, the Python and Node stubs, and the test mocks.

Note

TombstoneDistinctIds is not wired into persondistinctids_without_person_cleanup yet. A follow-up PR does that.

Deploy order

  1. personhog-replica, which serves the RPCs.
  2. personhog-router, which forwards them through KNOWN_METHODS.
  3. Python callers.

Stale comments left alone

These comments predate this PR and are outside its scope:

  • The split section of rust/personhog-replica/src/storage/postgres/person.rs: "No ON CONFLICT … unique index, not a unique constraint", and "deletes lock PDI rows before person rows".
  • nodejs/src/common/persons/repositories/postgres-person-repository.ts: the missing-uuid-index comment.
  • The personhog client README lacked the older tombstone and floor RPCs before this change.

How did you test this code?

All automated, run locally. No manual testing.

Test rationale:

  • Replica storage tests cover each outcome per RPC against Postgres, including orphans created with the FK check off. The closest existing tests cover SetPersonVersionFloor, which has no batch or orphan semantics.
  • A storage case keeps a live mapping whose owner exists, and expects NOT_ORPHANED with the row unchanged, next to the orphan case.
  • Ensure cases with a live row below and above the floor expect the version unchanged.
  • Race tests hold a row lock or a concurrent insert, to catch a lock timeout or a lost race that is reported as success.
  • Service tests catch a wrong proto mapping of outcomes and optional person_uuid.
  • Fake client tests keep the fake in line with server semantics, so caller tests stay honest.
  • Python helper tests catch a missed batch split, retry on the wrong status code, and a wrong published version or a publish for NOT_ORPHANED.

Not run: the full backend suite. Only the personhog and person test paths ran.

👉 Stay up-to-date with PostHog coding conventions for a smoother review.

Release status

  • No feature flag controls this change
  • This change is behind a feature flag and is not available to users
  • This change makes a previously flagged feature available to everyone

Automatic notifications

  • Publish to changelog?

Docs update

None. The personhog client README lists the new RPCs.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Cursor, Claude Opus 5.5

  • Skills: /adding-personhog-rpc, /reviewing-personhog-protocol, /writing-tests, /writing-dataclasses, /writing-code-comments, /writing-pr-descriptions.
  • CodeRabbit: the local pass did not run because the CLI was signed out and setup was declined. The bot reviews the draft.
  • Review changes: TombstoneDistinctIds became orphan-only and gained NOT_ORPHANED = 4. The ensure RPCs stopped raising live rows.
  • An already tombstoned row stays ALREADY_TOMBSTONED whatever its owner, so a repeat call still republishes after a failed delivery.
  • Public artifact: all test data is invented. No session material is in the diff.

Add five personhog RPCs for the ClickHouse person cleanup jobs:

- GetPersonVersionHeads and GetDistinctIdVersionHeads read the stored
  version of each row, tombstones included, from a replica.
- EnsurePersonVersionFloors and EnsureDistinctIdVersionFloors raise each
  tombstone to a minimum version on the primary, inserting a tombstone
  where no row exists, so a later revival by ingestion lands above the
  floor. A missing distinct id owner gets a version 0 person tombstone.
  A live row is left unchanged and comes back as LIVE with its current
  version.
- TombstoneDistinctIds tombstones orphaned distinct id rows (is_deleted,
  version + 1), live rows whose person row does not exist, and reports
  the version each row holds. A live row whose person row exists comes
  back as NOT_ORPHANED, unchanged.

Each write takes at most 250 keys in one transaction, locks rows in id
order with a 2s lock timeout, and fails whole with FAILED_PRECONDITION
when it loses an insert race.

The Python client, fake client and proto re-exports cover the new RPCs.
The helpers in posthog/models/person/util.py batch by the replica cap,
retry a lost race a bounded number of times, and
tombstone_distinct_ids_and_publish publishes the person_distinct_id2
tombstone at exactly the version Postgres returned. Node stubs and the
test SERVICE_DEFAULTS are regenerated to match.
@trunk-io

trunk-io Bot commented Oct 3, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

✅ Duplication (Python) — clean

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

⚠️ Duplication (TypeScript) — 1 new duplicated block (worst 727 tokens)

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

First copy Second copy Lines Tokens
nodejs/src/common/personhog/client.test.ts:97 nodejs/src/common/personhog/persons.test.ts:32 68 727
⚠️ Comment density — 4% of added code lines are comments (126 of 3456)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
rust/personhog-replica/src/storage/postgres/person.rs 36 728
rust/personhog-replica/tests/storage_tests.rs 32 956
rust/personhog-replica/src/storage/traits/person.rs 16 45
posthog/models/person/util.py 11 249
rust/personhog-replica/src/service/mod.rs 9 247
rust/personhog-replica/src/storage/types/person.rs 8 70
posthog/personhog_client/fake_client.py 5 169
rust/personhog-replica/src/service/tests/mod.rs 4 203

This check does not block merging. It updates on every push and clears when the share drops.

@greptile-apps

greptile-apps Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[High risk] Adds new RPC methods to the person service API contract.

The PR should not merge until person-floor requests reject duplicate logical UUIDs, which can produce incorrect reported versions.

Reviews (1) · Last reviewed commit: "feat(personhog): add version head, versi..."

Comment thread rust/personhog-replica/src/service/mod.rs
Comment thread rust/personhog-replica/src/storage/postgres/person.rs
@hosthog

hosthog Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

HostHog preview — posthog-desktop-web

Latest build (16aa45b): https://e331532c13604409aed4ab7d6d589bb2.hosthog.dev

Earlier builds of this PR, still serving:

Employee-gated; every push gets a fresh URL whose content never changes. All previews stop serving when the PR closes.

@coderabbitai

coderabbitai Bot commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

🧰 Additional context used
📚 Code guidelines (10)
.cursor/rules/rust.mdc — auto-discovered
.agents/security.md — configured
rust/personhog-replica/AGENTS.md — auto-discovered
.agents/skills/adding-personhog-rpc/SKILL.md — configured
.agents/skills/reviewing-personhog-protocol/SKILL.md — configured
.agents/skills/sending-notifications/SKILL.md — configured
.agents/skills/writing-tests/SKILL.md — configured
docs/internal/person-data-access.md — configured
.claude/commands/conventions.md — configured
.agents/skills/writing-code-comments/SKILL.md — configured

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: PostHog/posthog/.coderabbit.yaml
  • Review profile: QUIET
  • Plan: Enterprise
  • Run ID: 3a665005-dfaa-4987-bdb7-4d83e8047726
📥 Commits

Reviewing files that changed from the base of the PR and between 11754a4 and 16aa45b.

📒 Files selected for processing (5)
  • posthog/personhog_client/test_fake_client.py
  • rust/personhog-replica/src/service/mod.rs
  • rust/personhog-replica/src/service/tests/mod.rs
  • rust/personhog-replica/src/storage/postgres/person.rs
  • rust/personhog-replica/tests/storage_tests.rs

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 9 remain after this review.


📝 Walkthrough

Walkthrough

PersonHog adds RPCs to retrieve person and distinct-ID version heads, ensure version floors, and tombstone orphaned distinct IDs. The Rust service validates requests and maps storage outcomes to protocol results. PostgreSQL storage implements the reads and writes with row locking and race handling. Python clients add routed helpers, a fake client, batching, retries for FAILED_PRECONDITION, and optional ClickHouse tombstone publication. Tests cover validation, outcomes, retries, delivery failures, and storage races.

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 16aa4

The new reconciliation helpers respect the server’s batch limit. No actionable merge-blocking issue is established.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to 16aa4

The repair operations have bounded transactions and explicit retry outcomes. However, accepted terminal version values can leave newly created tombstones without a valid revival version, and the restrictions on who may invoke these operations remain unconfirmed.

Retained concerns

  • Medium · reliability · inferred: The new floor contract can reserve a previously absent person or distinct-ID key with a tombstone at 9223372036854775807. Validation rejects negative floors but preserves no successor headroom. A conforming revival cannot produce a higher signed 64-bit version; the inspected person-revival SQL increments the stored tombstone version and would overflow. This can strand reserved keys until explicit repair or removal and can abort a transaction containing their revival. Existing floor operations already expose the unrestricted-increase weakness for existing rows; the PR adds creation of this state for absent keys.
Security review details

Security Blast Radius

  • inferred — A caller admitted by the service boundary can select tenant IDs and persistent person or distinct-ID keys. Each write request is limited to 250 keys, but repeated requests can affect multiple batches and selected teams. The exposed outcomes are stored-version changes and deletion-state changes, with optional downstream publication; end-user or internet reachability was not proven.

Security Findings and Attack Paths

  • inferred — A mistaken or malicious admitted caller can supply a terminal floor for an absent key, causing the new API to persist a tombstone without representable successor headroom. This is a conditional durable availability path, not a verified externally reachable vulnerability. Existing unrestricted floor setters are counterevidence against treating terminal-version manipulation of existing rows as newly introduced.

Trust Boundaries and Controls

  • observed — The inspected router and replica server registrations show transport, metrics, compression, and load-shedding controls, but no authentication or tenant-authorization interceptor. The new operations therefore depend on the existing service trust boundary. Network policy, service credentials, and deployment-level authentication were not available, so the caller-authorization proof gap remains unresolved rather than becoming a confirmed bypass.

Resilience and Maintainability Implications

  • inferred — Local transaction locks and pre-commit race checks contain partial database writes. Cross-system deletion convergence still requires replay after interruption because commit precedes publication. Orphan classification also relies on numeric person IDs not being reused while mapping locks prevent repointing. The scoped source supports local containment but does not establish global identity non-reuse, durable replay ownership, or complete downstream replacement ordering.

Hardening Proposals

  • proposed — Define a supported version range that preserves successor headroom, and specify recovery for already stored terminal versions. Before wiring cleanup callers, establish their tenant authority and durable ownership of publication retries, including interruption and revival races.
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description explains the problem, user impact, RPC behavior, deployment order, test coverage, release status, and agent involvement. It is mostly complete, though it omits a session link and does …
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (1)
posthog/models/person/util.py-1140-1146 (1)

1140-1146: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Batch the write helpers by the replica's fixed 250-key cap, not by PERSONHOG_BATCH_SIZE.

  • The replica rejects write batches larger than MAX_LOCKED_WRITE_BATCH_SIZE = 250 with INVALID_ARGUMENT.
  • PERSONHOG_BATCH_SIZE comes from settings.PERSONHOG_BATCH_SIZE, which is configurable.
  • If an operator raises that setting above 250, every ensure_*_version_floors and tombstone_distinct_ids_in_postgres call with more than 250 keys fails.
  • _retry_lost_race does not retry INVALID_ARGUMENT, so these calls fail without recovery.
  • The helpers' docstrings and the PR description say they batch by the replica cap.

Add a module constant capped at 250 and use it in the three write loops.

Proposed fix
 VERSION_FLOOR_ATTEMPTS = 3
+# The replica's per-request cap for the locked version-floor and tombstone writes.
+VERSION_WRITE_BATCH_SIZE = min(PERSONHOG_BATCH_SIZE, 250)
-        for i in range(0, len(floors), PERSONHOG_BATCH_SIZE):
+        for i in range(0, len(floors), VERSION_WRITE_BATCH_SIZE):
             request = EnsurePersonVersionFloorsRequest(
                 team_id=team_id,
                 floors=[
                     PersonVersionFloorProto(person_uuid=str(f.uuid), min_version=f.min_version)
-                    for f in floors[i : i + PERSONHOG_BATCH_SIZE]
+                    for f in floors[i : i + VERSION_WRITE_BATCH_SIZE]
                 ],

Apply the same change in ensure_distinct_id_version_floors and tombstone_distinct_ids_in_postgres.

Also applies to: 1171-1179, 1206-1210


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Repository: PostHog/posthog/.coderabbit.yaml
  • Review profile: QUIET
  • Plan: Enterprise
  • Run ID: 4ba11d2d-7ebc-47cb-ac8f-eb068597763e
📥 Commits

Reviewing files that changed from the base of the PR and between e7ba672 and 11754a4.

⛔ Files ignored due to path filters (2)
  • nodejs/src/common/generated/personhog/personhog/service/v1/service_pb.ts is excluded by !**/generated/**
  • nodejs/src/common/generated/personhog/personhog/types/v1/person_pb.ts is excluded by !**/generated/**
📒 Files selected for processing (39)
  • nodejs/src/common/personhog/client.test.ts
  • nodejs/src/common/personhog/persons.test.ts
  • packages/personhog-proto/personhog/service/v1/service_pb2.py
  • packages/personhog-proto/personhog/service/v1/service_pb2_grpc.py
  • packages/personhog-proto/personhog/types/v1/person_pb2.py
  • packages/personhog-proto/personhog/types/v1/person_pb2.pyi
  • posthog/models/person/test/test_util_personhog.py
  • posthog/models/person/util.py
  • posthog/personhog_client/README.md
  • posthog/personhog_client/client.py
  • posthog/personhog_client/fake_client.py
  • posthog/personhog_client/proto/__init__.py
  • posthog/personhog_client/test_fake_client.py
  • proto/personhog/replica/v1/replica.proto
  • proto/personhog/service/v1/service.proto
  • proto/personhog/types/v1/person.proto
  • rust/personhog-replica/.sqlx/query-04c101b2f3e2a3facee9d23f4c490e9015479caf677106cb775e383df4547fb5.json
  • rust/personhog-replica/.sqlx/query-2c35d517f08845a7529e9da5541bf265652dd7d332fee9769eac84d60e1b9535.json
  • rust/personhog-replica/.sqlx/query-601660f4382614a149274fcfbc929bab32cd8a07aa7af8d4db579287fef40217.json
  • rust/personhog-replica/.sqlx/query-75a975d984eea7dc6b1715b4e5c1670c9bd1b99fc096ab6752e4cd4474d00bec.json
  • rust/personhog-replica/.sqlx/query-9a71b118fb8cef86d6fbf17da8a2056c7e3a9e22ab61f2ffc4e32ef6c9c53bdf.json
  • rust/personhog-replica/.sqlx/query-9b70b04382c00916b39e98d05fa2b9d0d4e9f7ef6cdce20a439eb3dc6e246282.json
  • rust/personhog-replica/.sqlx/query-a11441daea9b9c1d19d08ffcbb4abbe065aeab8968d44d5cea5df28f13377201.json
  • rust/personhog-replica/.sqlx/query-aebd2a9bd2d9292a6778dfee92a71dc6bac93c7da61b434543b2ef7e3498a33b.json
  • rust/personhog-replica/.sqlx/query-ba431aa18128ea6b1d9b51f3b634ce2e78928cafaeda736e55f2d6e36bd2ef89.json
  • rust/personhog-replica/.sqlx/query-e2d052c562fe45f3eeba03324a03a93fc294ad8a1bb7ad4a649539e3e656759e.json
  • rust/personhog-replica/.sqlx/query-e8c981d389a199ecd1184e4375f5ced60d7d292fdebdfef14f63b02882910f84.json
  • rust/personhog-replica/src/service/mod.rs
  • rust/personhog-replica/src/service/tests/mocks.rs
  • rust/personhog-replica/src/service/tests/mod.rs
  • rust/personhog-replica/src/storage/mod.rs
  • rust/personhog-replica/src/storage/postgres/person.rs
  • rust/personhog-replica/src/storage/traits/person.rs
  • rust/personhog-replica/src/storage/types/person.rs
  • rust/personhog-replica/tests/service_tests.rs
  • rust/personhog-replica/tests/storage_tests.rs
  • rust/personhog-router/src/proxy.rs
  • rust/personhog-router/tests/common/mod.rs
  • rust/property-defs-rs/tests/group_type_resolver.rs

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 11 remain after this review.

@trunk-io

trunk-io Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

View Full Report ↗︎ ⋅ Docs

@eli-r-ph
eli-r-ph added this pull request to stack #111235 October 3, 2026 06:16
EnsurePersonVersionFloors now refuses two spellings of one UUID, which
passed the string check but locked the same row.

EnsureDistinctIdVersionFloors now fails FAILED_PRECONDITION when a
concurrent writer inserts a distinct id after the unlocked read, so the
owner tombstone prepared for it rolls back instead of committing unused.
Callers already retry that code.

The fake client tests return proto messages instead of wide tuples.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant