Repository navigation
feat(flags): sweep flag_evaluations in data deletions #82056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
trunk-io
merged 3 commits into
master
from
posthog-code/flag-evaluations-deletion-coverage
Aug 13, 2026
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| # ClickHouse deletion coverage | ||
|
|
||
| Which ClickHouse tables a data deletion reaches, which rely on their TTL instead, and why. | ||
|
|
||
| Deleting a person's data is not a property of the events table. | ||
| It is a property of every table that stores rows attributable to a person. | ||
| `posthog/models/deletion_targets.py` is the one list of those tables; this document is the reasoning behind it. | ||
|
|
||
| ## The sweeps | ||
|
|
||
| | Sweep | Entry point | Predicate columns | | ||
| | ------------------------ | -------------------------------- | ------------------------------------------ | | ||
| | Person deletion (async) | `deletes_job` → `delete_events` | `team_id`, `person_id`, `timestamp` | | ||
| | Team deletion | `deletes_job` → `delete_events` | `team_id` | | ||
| | Queued uuid drain | `deletes_job` → `delete_events` | `team_id`, `uuid` | | ||
| | Person removal request | `delete_person_events_op` | `team_id`, `person_id`, `timestamp` | | ||
| | Event removal request | `execute_event_deletion` | `team_id`, `timestamp`, `event`, + HogQL | | ||
| | Property removal request | `process_property_removal_shard` | `properties`, `person_properties`, + HogQL | | ||
|
|
||
| The first four use only columns every target declares, so they apply unchanged to any registered table. | ||
| The last two need more, which is what the capability fields on `DeletionTarget` express. | ||
|
|
||
| Team deletion for tables that are replicated rather than sharded runs through a separate per-table loop (`delete_team_data_from`), which dispatches to a single host. | ||
| A sharded table must not be registered there — it would sweep one shard. | ||
| The team arm inside `delete_events` covers sharded tables. | ||
|
|
||
| ## Covered tables | ||
|
|
||
| - `sharded_events` — all sweeps. | ||
| - `sharded_events_json` — all sweeps. Optional: only present after the native-JSON migration. | ||
| - `sharded_flag_evaluations` — person, team, queued-uuid and event removal. Not property removal (below). Optional. | ||
|
|
||
| ## Tables on TTL alone | ||
|
|
||
| Listed in `TTL_ONLY_TABLES`. | ||
| Each is a decision that erasure may lag by the retention window. | ||
|
|
||
| - `sharded_events_recent` — a transient mirror of the last few days of events, 7-day TTL keyed on `inserted_at`. It partitions by day with `ttl_only_drop_parts = 1`, so a part drops only once its newest row expires: the real worst case is about 8 days plus TTL-merge lag, not a flat 7. Short enough to accept as the erasure bound, and a sweep would race the TTL for little benefit. | ||
|
|
||
| Session recordings, the dead letter queue, and logs are likewise TTL-reclaimed. | ||
| That decision predates this document; the older `posthog/models/async_deletion/delete_events.py` records it in a comment, but that module is legacy and is not the source of truth here. | ||
|
|
||
| ## Known gaps | ||
|
|
||
| ### Property removal does not reach `flag_evaluations` | ||
|
|
||
| The events property-removal path rewrites rows in a staging table and resets each affected materialized column with `ALTER TABLE … UPDATE <col> = ''`. | ||
| That works because `materialize()` creates columns as `DEFAULT <expr>`, which is assignable. | ||
|
|
||
| `flag_evaluations` declares its nine typed columns as true ClickHouse `MATERIALIZED`. Measured against ClickHouse 26.6: | ||
|
|
||
| - Assigning to one is rejected: `Cannot UPDATE materialized column 'session_id'`. | ||
| - Updating `properties` is rejected too, because `flag_key` is materialized from it and sits in the sort key: `Updated column 'properties' affects MATERIALIZED column 'flag_key', which is a key column`. | ||
| - `CREATE TABLE tmp AS sharded_flag_evaluations ENGINE = MergeTree()` inherits the sort key and the column kinds, so the staging table hits the same rejection. | ||
|
|
||
| The planned fix is two follow-ups: | ||
|
|
||
| 1. Recreate the table with the typed columns as `DEFAULT <expr>`, the kind `materialize()` mints on events. | ||
| Recreating is only free while the table is empty, so this must land before the producer ships. | ||
| 2. Point the events rewrite machinery (column discovery, staging rewrite, shard walk) at the table; today all of it is scoped to `events`. | ||
|
|
||
| One open question for the schema follow-up: `flag_key` sits in the sort key, and ClickHouse never accepts `UPDATE` on a key column, whatever its kind. | ||
| Whether `UPDATE properties` is accepted once a `DEFAULT` key column depends on it needs measuring. | ||
| If it is still rejected, the fallback is the heavier rewrite: `INSERT … SELECT` the cleaned rows, let the shard recompute the typed columns on write, then lightweight-delete the originals. | ||
|
|
||
| Until the fix exists, `get_property_removal_shards` refuses to start when the table holds rows matching the request, so a request cannot complete while data it named survives. | ||
| The check costs nothing while the table is empty. | ||
|
|
||
| This is also why `flag_evaluations` is deliberately absent from `MATERIALIZATION_VALID_TABLES`. | ||
| Adding it would let `materialize()` mint `DEFAULT`-kind columns on a table whose property-removal path cannot reset them. | ||
|
|
||
| #### If a request arrives before the fix lands | ||
|
|
||
| Today the refusal costs nothing, because the table is empty. | ||
| Once it holds rows, a property removal with `delete_all_events` refuses whenever a single flag-evaluation row carries the named property, and the operator has no way through: `delete_all_events` and `events` are mutually exclusive on the model, so the request cannot be narrowed to exclude `$feature_flag_called`, and the admin Retry button replays the same failure. | ||
| The only exits are waiting out the TTL or shipping the fix above. The table partitions by month with `ttl_only_drop_parts = 1`, so a part drops only once its newest row expires: the real wait is up to about 120 days, not the 90-day TTL. `posthog/models/flag_evaluations/sql.py` says the same thing next to the partition clause. | ||
|
|
||
| Refusing beats silently under-deleting, so the gate is the right default. | ||
| If the fix has not landed by the time real traffic hits, the cheaper stopgaps are letting a request exclude event names so an operator can scope around the table, or recording an explicit, audited acknowledgement on the request so an operator can accept the residue rather than being stuck. | ||
| Doing nothing means the first affected GDPR request becomes an escalation. | ||
|
|
||
| ### Event removal with a HogQL predicate does not reach `flag_evaluations` | ||
|
|
||
| `compile_hogql_predicate` resolves against the events HogQL table and emits events-specific physical columns. There is no HogQL table definition for `flag_evaluations`, so the fragment cannot run against it. Requests without a predicate are swept normally; requests with one are refused if the table holds matching rows. | ||
|
|
||
| ## Producer prerequisite: person_id parity | ||
|
|
||
| Every person sweep is keyed on `person_id`, on `flag_evaluations` as on events. | ||
| That is only correct because the producer populates `person_id` for every row, exactly as the events ingestion pipeline does: the resolved `Person.uuid`, or a deterministic per-`distinct_id` uuid when no person was resolved. | ||
| The shadow-routing producer (`posthog-code/flag-evaluations-shadow-routing`) forks the enriched event downstream of person resolution, so it inherits that value for free. | ||
|
|
||
| If a future producer emitted rows before person resolution, leaving `person_id` unset, a person deletion would strand them. | ||
| The fix would belong to the producer, not the scanner. | ||
| Keeping the fork downstream of person resolution is the contract, tracked on #81002. | ||
|
|
||
| ## Related, and deliberately unchanged | ||
|
|
||
| `_fetch_stats` counts only the events tables. It feeds `AUTO_APPROVE_MAX_EVENTS`, a cost heuristic rather than a completeness claim, so a request auto-approved as small may move somewhat more rows than measured. | ||
|
|
||
| `cleanup_old_events_by_partition` stays events-only. It enforces a multi-year retention floor for a named set of teams, and every other personal-data table already expires sooner under its own TTL. | ||
|
|
||
| ## Adding a table | ||
|
|
||
| Register it in `PERSONAL_DATA_TARGETS`, with capability flags reflecting what its schema can actually take. | ||
| If it is not going to be swept, add it to `TTL_ONLY_TABLES` with the window you are accepting. | ||
|
|
||
| `posthog/clickhouse/test/test_deletion_coverage.py` fails on any storage table that declares `person_properties` and appears in neither list, so the decision has to be made rather than skipped. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| from posthog.test.base import BaseTest, ClickhouseTestMixin | ||
|
|
||
| from django.conf import settings | ||
|
|
||
| from posthog.clickhouse.client import sync_execute | ||
| from posthog.models.deletion_targets import PERSONAL_DATA_TARGETS, TTL_ONLY_TABLES | ||
|
|
||
|
|
||
| class TestDeletionCoverage(ClickhouseTestMixin, BaseTest): | ||
| def test_storage_tables_carrying_person_properties_are_swept_or_exempt(self): | ||
| # A table that stores person_properties stores personal data, so deleting a person has to | ||
| # reach it. flag_evaluations shipped without any scanner pointed at it precisely because | ||
| # nothing forced that decision to be made out loud (#81002). | ||
| # | ||
| # Keyed on person_properties rather than person_id: person_id is on cohortpeople, | ||
| # person_static_cohort, person_distinct_id2 and more, which makes for an exemption list too | ||
| # long to carry any signal. person_properties is the events-mirror signature. | ||
| # | ||
| # tmp_ is excluded because the property-removal job's staging tables are created AS | ||
| # sharded_events, so a leaked one would fail this with a misleading name. | ||
| rows = sync_execute( | ||
| """ | ||
| SELECT t.name | ||
| FROM system.tables t | ||
| INNER JOIN system.columns c ON c.database = t.database AND c.table = t.name | ||
| WHERE t.database = %(database)s | ||
| AND c.name = 'person_properties' | ||
| AND t.engine LIKE '%%MergeTree%%' | ||
| AND t.name NOT LIKE 'tmp\\_%%' | ||
| ORDER BY t.name | ||
| """, | ||
| {"database": settings.CLICKHOUSE_DATABASE}, | ||
| ) | ||
| storage_tables = {row[0] for row in rows} | ||
| assert storage_tables, "expected at least the events tables to declare person_properties" | ||
|
|
||
| accounted_for = {target.data_table for target in PERSONAL_DATA_TARGETS} | TTL_ONLY_TABLES | ||
| unaccounted = sorted(storage_tables - accounted_for) | ||
|
|
||
| assert not unaccounted, ( | ||
| f"{unaccounted} store person_properties but no deletion sweep reaches them. Register each " | ||
| "in PERSONAL_DATA_TARGETS (posthog/models/deletion_targets.py), or add it to " | ||
| "TTL_ONLY_TABLES with the retention window you are accepting as its erasure bound. " | ||
| "See docs/internal/clickhouse-deletion-coverage.md." | ||
| ) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.