Denormalize person_id onto conversations Ticket so requester lookups don't expand to distinct_ids
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 39.9k
- Forks
- 3.4k
- Avg merge
- 6h 51m
- Merged PRs (30d)
- 232
Description
Problem
Ticket has no person_id. The only stored link to a person is distinct_id, indexed as (team, distinct_id) and commented "PostHog distinct_id for Person linking only". Persons live in a separate database behind personhog, so there is no join available.
Any "other tickets from this requester" lookup therefore has to expand person → distinct_ids and query WHERE distinct_id IN (...). A merge-heavy requester accumulates every anonymous id ever merged into them, so a single page's lookup can expand into several thousand ids.
#76716 bounds that with two caps, RELATED_OPEN_MAX_DISTINCT_IDS_PER_PERSON and RELATED_OPEN_MAX_TOTAL_DISTINCT_IDS. They keep the query safe but make the counts approximate: ids past the cap are never queried, so a requester's open-ticket count can undercount while the API presents it as exact. That PR also had to add fair budget allocation, because handing the budget out first-come let one merge-heavy requester consume all of it and silently leave every later row on the page with no pill.
Proposal
Add a person_id UUID column to Ticket, indexed on (team, person_id), populated at creation. The lookup becomes WHERE person_id IN (...) — one uuid per requester instead of an unbounded id expansion. Both caps and the allocation logic can then be deleted, and counts become exact by construction.
There is precedent in this exact model: organization_id is already denormalized onto Ticket from the person, with organization_id_source recording provenance, and events.py re-resolves it on later messages.
Open question: merge staleness
distinct_id is merge-safe — ids follow the person to the survivor, so the current lookup stays correct through merges. A stored person_id is not: after a merge, tickets written with the retired id no longer match the survivor.
Worth being precise about why re-resolving on read does not fix this. The list endpoint already re-resolves the person for the rows it loads (get_persons_by_distinct_ids runs per request), so the person attached to the rows on screen is always current, and a refresh does pick up a post-merge uuid. But the lookup matches against the stored column on tickets that are never loaded. Those keep their pre-merge value until something rewrites them, and no amount of refreshing the page touches them.
Options, roughly in order of preference:
- Rewrite on merge, if there is a person-merge hook a product can subscribe to. Exact, and cheap:
filter(team_id=..., person_id=retired_id).update(person_id=survivor_id). - Refresh on write, as
organization_iddoes. Decays staleness but does not eliminate it. - Periodic reconciliation job.
- Keep
distinct_idas a fallback whenperson_idis null or unmatched. Needed regardless, since a meaningful share of tickets resolve to no person at all.
Scope
- Migration and backfill
- Population at creation, plus whichever refresh path we land on above
- Merge handling
- Delete the two caps and the allocation logic in
_attach_related_open_tickets
Refs #76716
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the Ticket model, events.py, and _attach_related_open_tickets, then trace get_persons_by_distinct_ids and the existing organization_id population path. Review the migration and backfill requirements and resolve the merge-staleness strategy before changing creation and refresh behavior. Done means exact requester counts, merge handling, a null-person fallback, and removal of both caps and allocation logic.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- backend, database
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100