Skip to content

feat(personhog): route ingestion person writes by store mode - #81624

Open
z0br0wn wants to merge 6 commits into
masterfrom
zbrown/personhog-store-wiring
Open

feat(personhog): route ingestion person writes by store mode#81624
z0br0wn wants to merge 6 commits into
masterfrom
zbrown/personhog-store-wiring

Conversation

@z0br0wn

@z0br0wn z0br0wn commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Problem

The personhog persons store merged with no way to receive traffic: ingestion builds only the Postgres store, so the identity get-or-create, leader fold, and strong-read paths run untested against real event shapes.

Changes

  • PERSONS_STORE_MODE selects the world person writes land in, deployment-wide: pg (default) builds nothing new, personhog routes every verb to the personhog store, shadow runs the Postgres call as the authoritative result and the personhog call after it, counting failures without failing the batch.
  • One routing rule covers every verb, transactions and merges included. There is no per-team sampling and no merge special-casing: verbs the personhog world cannot serve yet reach the store's pending-saga placeholders, which throw loudly in personhog mode and surface as counted shadow errors in shadow mode. Implementing the saga changes no routing code.
  • PersonhogPersonsStore now declares implements PersonsStore, so the compiler enforces member completeness. Its custom batch-bound wrapper was unreachable from the pipeline and is deleted; the generic wrapper serves it like any store.
  • Shadowed writes re-resolve the personhog world's person by distinct id before applying, and skip (counted) when it does not exist there. The two worlds' row-id sequences are independent, so shadowing with the caller's Postgres person would fold onto the wrong row.
  • A bad mode, or a non-pg mode missing PERSONHOG_ADDR / PERSONHOG_IDENTITY_ADDR, fails boot with one error naming the knob.
  • New counters: personhog_store_shadow_errors_total{verb}, personhog_store_shadow_skips_total{verb}.

How did you test this code?

  • Routing tests cover mode routing (transactions included), shadow authority and swallow, shadow re-resolution, placeholder delegation, and boot validation; store tests cover the placeholder and no-op contracts.
  • Red-checked three behaviors: reverting shadow re-resolution fails the wrong-id tests, hardcoding merge routing to Postgres fails the delegation tests, and removing a store member fails compilation via the new implements.
  • Store mocks are a compile-checked jest.Mocked<PersonsStore> factory, so interface drift breaks the build instead of leaving stale mocks.
  • 1,046 tests green across the persons stores, personhog clients, and event-processing steps.
  • Not run: a live shadow soak — that is the next step after deploy, on the dev stack.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Built with Claude Code. Skills invoked: /writing-tests, /writing-code-comments, /writing-pr-descriptions.

The design converged through review rounds directed by Zach. Review found personhog-world row ids reaching Postgres merge mutations; the fix went through several rejected shapes (routing-layer guards, event pinning, per-verb special-casing) before landing on the final principle: the store carries loud placeholders for unimplemented capability, routing treats every verb uniformly, and the per-team allowlist was removed because per-team sampling coupled unrelated worlds inside one Postgres transaction. A reviewer also flagged untyped store mocks, which became the compile-checked factory.

PERSONS_STORE_MODE selects the world person writes land in: pg keeps
today's path untouched, personhog routes allowlisted teams to the
identity and leader RPCs, and shadow runs the personhog verb after the
authoritative Postgres call, counting failures without failing the
batch. Shadowed writes re-resolve the personhog world's own person by
distinct id, because Postgres row ids mean nothing there. Merge
execution, deletes, and lifecycle marks stay on Postgres in every mode
until the merge saga owns them, as does any verb under a Postgres
transaction. A bad mode or a missing endpoint fails boot loudly.
@z0br0wn
z0br0wn requested a review from a team August 12, 2026 02:07
@z0br0wn z0br0wn self-assigned this Aug 12, 2026
@trunk-io

trunk-io Bot commented Aug 12, 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

@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (1)

  1. nodejs/src/servers/ingestion-api-server.ts, line 635-641 (link)

    P1 Shutdown drops PersonHog lanes

    When shutdown begins with a routed batch still holding pending PersonHog updates, cleanup flushes only the underlying Postgres store and then invokes the no-op PersonhogPersonsStore.shutdown. The routing store's per-batch flush is bypassed, so accepted person property writes for PersonHog-routed teams are silently discarded.

    Knowledge Base Used: nodejs/ (plugin-server)

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: nodejs/src/servers/ingestion-api-server.ts
    Line: 635-641
    
    Comment:
    **Shutdown drops PersonHog lanes**
    
    When shutdown begins with a routed batch still holding pending PersonHog updates, cleanup flushes only the underlying Postgres store and then invokes the no-op `PersonhogPersonsStore.shutdown`. The routing store's per-batch flush is bypassed, so accepted person property writes for PersonHog-routed teams are silently discarded.
    
    **Knowledge Base Used:** [nodejs/ (plugin-server)](https://app.greptile.com/posthog-org-19734/-/custom-context/knowledge-base/posthog/posthog/-/docs/plugin-server-nodejs.md)
    
    ---
    
    For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.
Prompt To Fix All With AI
### Issue 1
nodejs/src/ingestion/common/persons/routing-persons-store.ts:152-154
**PersonHog IDs enter Postgres merges**

When an allowlisted team in `personhog` mode processes an identify, alias, or folded merge, routed reads return PersonHog-world person objects and `inTransaction` passes those objects directly to Postgres merge operations. Because the two stores have independent row-ID sequences, the merge can update or delete the wrong rows, match no rows, or fail with a database constraint error.

### Issue 2
nodejs/src/servers/ingestion-api-server.ts:635-641
**Shutdown drops PersonHog lanes**

When shutdown begins with a routed batch still holding pending PersonHog updates, cleanup flushes only the underlying Postgres store and then invokes the no-op `PersonhogPersonsStore.shutdown`. The routing store's per-batch flush is bypassed, so accepted person property writes for PersonHog-routed teams are silently discarded.

### Issue 3
nodejs/src/ingestion/common/persons/routing-persons-store.test.ts:23-48
**Store mocks bypass interface checks**

The new tests cast partial Postgres and PersonHog store mocks to `any`, preventing TypeScript from checking that they implement the required store contracts. Interface changes can therefore leave stale mocks compiling, with newly exercised routing methods failing only at test runtime.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "feat(personhog): route ingestion person ..." | Re-trigger Greptile

Comment thread nodejs/src/ingestion/common/persons/routing-persons-store.ts
Comment thread nodejs/src/ingestion/common/persons/routing-persons-store.test.ts Outdated
Comment thread nodejs/src/ingestion/common/persons/routing-persons-store.ts Outdated
@veria-ai

veria-ai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

PR overview

This pull request routes ingestion-time person writes according to the configured store mode, including support for the Personhog-backed persons store.

Two security issues have been addressed, but one denial-of-service path remains open. A user with a valid project token can submit a merge-producing event that causes Personhog-mode ingestion to return retriable errors, repeatedly blocking the affected worker from advancing.

Open issues (1)

Fixed/addressed: 2 · PR risk: 5/10

@trunk-io

trunk-io Bot commented Aug 12, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

Review found that a personhog-routed team's merge fetched persons from
the personhog world and then mutated Postgres rows keyed on those ids,
whose sequences are independent — wrong-row writes on collision. Two
mechanisms allowed it: the routing store delegated inTransaction to the
Postgres store, whose transaction wrapper wraps itself, so nothing
inside a merge transaction ever routed again; and the personhog store
implemented no interface, so its missing merge members were invisible
and routing silently delegated them to Postgres.

The personhog store now declares PersonsStore, with the compiler
enforcing completeness: merge mutations are the merge saga's loud
placeholders, cohort bookkeeping keeps its deliberate no-ops, and the
vestigial batch-bound class the pipeline never reached is deleted. The
routing store builds the transaction wrapper around itself, so
transactional verbs re-enter routing, and merge verbs route to the
team's world and nowhere else — a routed team's merge fails at the
store placeholder before any Postgres mutation. Store mocks are now
compile-checked against the interface, so drift breaks the build
instead of surviving as stale mocks.
Comment thread nodejs/src/ingestion/common/persons/routing-persons-store.ts Outdated
Two review-driven simplifications. Merge verbs stop being a special
family: they route through the same combinator as every other verb, so
personhog mode reaches the store's pending-saga placeholders, shadow
mode shadows them like anything else (the placeholder throw lands in
the existing swallow-and-count machinery), and implementing the saga
changes zero routing lines.

The per-team allowlist is gone: PERSONS_STORE_MODE applies to the
whole deployment. Per-team sampling coupled unrelated worlds inside
one Postgres transaction; with a deployment-wide mode, inTransaction
routes by mode like any verb — pg and shadow run the real Postgres
transaction, personhog mode answers with the store's placeholder —
and the repository-backed transaction wrapper machinery deletes.
The routing comments still described the removed per-team allowlist,
the merge special-casing, and signature drift the interface work
eliminated.
fetchPersonsForUpdateByDistinctIds still pinned to Postgres with a
stale merge comment, deletePersons kept an empty-array short-circuit
that existed only for the removed per-team guard, and flush stats
reported the Postgres world alone. All three now follow the uniform
routing, and route() drops the team parameter the allowlist removal
left dead.
flush() serializes passes and claims a snapshot of every lane up front,
so ops folded mid-pass ship on the next pass instead of mutating an
entry already in flight. A failed ship restores its entry, so a sibling
batch's unacked ops survive another batch's flush failure. A no-change
lane ships instead of being suppressed when a sibling batch holds ops
for the same person, whose baseline the verdict never saw.

Also: pending-saga placeholders reject instead of throwing
synchronously, the routing store drops its dead transaction rule and
routes personPropertiesSize/prefetchPersons/flush uniformly, and
getFlushStats no longer double-counts batches in shadow mode.
* never delegates this member. Reaching it is a wiring bug.
*/
inTransaction<T>(_description: string, _transaction: (tx: PersonsStoreTransaction) => Promise<T>): Promise<T> {
return Promise.reject(new PersonhogPendingRpcError('inTransaction', 'merge saga'))

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.

Low: Merge events halt personhog-mode ingestion

An attacker with any valid project token can send a merge-producing event such as $merge_dangerously or an $identify joining two distinct IDs. The merge service calls inTransaction, so this rejection bubbles through /ingest as a retriable 500 and marks the server fatal; redelivery of the same event prevents the worker from advancing. Keep authoritative personhog mode unavailable until merges are implemented, or ensure these events are handled without poisoning the batch.

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