fix(flags): exclude archived flags from the per-team limit - #81616
fix(flags): exclude archived flags from the per-team limit#81616haacked wants to merge 1 commit into
Conversation
Archiving preserves the experiment and survey data linked to a flag, but archived flags still occupied a slot against MAX_FEATURE_FLAGS_PER_TEAM. A team at the cap could only make room by soft-deleting, the destructive option. Archiving now frees a slot, and unarchiving clears the same bar as a create, so archive-create-unarchive cannot push a team past the cap. The check compares counted-set membership before and after the write, so unarchiving a flag that stays soft-deleted costs nothing. Undoing a soft delete stays exempt. A team can sit above the cap, and charging the undo would make deleting a flag one-way for them: the delete succeeds and the undo fails with nothing in the product to recover it. Unarchiving an experiment restores its auto-archived flag through that same path, and waives the cap to do it: the original archive freed a slot the team may have since filled, so enforcing it there would strand the experiment without its flag. The facade exposes that waiver as allow_exceeding_flag_limit. Generated-By: PostHog Code Task-Id: a84a9f24-a9a5-420b-8467-83f3f21a8cf6
|
Merging to
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 |
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 1 should fix, 0 consider. Published 1 finding (view the review). |
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
|
|
||
| count_limit = settings.MAX_FEATURE_FLAGS_PER_TEAM | ||
| flag_count = FeatureFlag.objects.filter(team_id=team_id).count() | ||
| flag_count = FeatureFlag.objects.filter(team_id=team_id, archived=False).count() |
There was a problem hiding this comment.
Archived flags can bypass the resource-safety limit indefinitely
Why we think it's a valid issue
- Checked: The changed
check_flag_limits_for_team(products/feature_flags/backend/api/feature_flag.py:593-594), the serialization path feeding the flags hypercache and local-evaluation response (get_feature_flags/serialize_feature_flags/EvaluationFeatureFlagSerializer), and the settings comment the diff adds. - Found: The new count is
FeatureFlag.objects.filter(team_id=team_id, archived=False).count(), butget_feature_flagsatproducts/feature_flags/backend/models/feature_flag.py:598filters with the default manager (FeatureFlag.objects.filter(**filter_kwargs)) — it excludes only soft-deleted rows, not archived ones.EvaluationFeatureFlagSerializer(feature_flag.py:2659-2661) confirms this serialized set feeds both the Rust-service hypercache and the local-evaluation response. So archived flags are still serialized while no longer counting toward the cap. - Found: Before this change the cap counted archived flags, so total serialized flags per team was bounded at
MAX_FEATURE_FLAGS_PER_TEAM(2000). After it, live flags are bounded but archived flags are unbounded, so the total serialized payload (live + archived) has no ceiling — the diff's own settings comment states this outright. - Impact: The limit's stated purpose is 'to prevent memory issues during flag evaluation/caching', and the codebase already carries scars from this exact failure mode:
FLAGS_CACHE_VERIFICATION_CHUNK_SIZEwas 'Reduced from 1000 to 250 to prevent OOM' because 'Teams with 100+ flags and large filters JSONs can use significant memory' (posthog/settings/feature_flags.py:81-85). The change removes the only bound on total serialized flag count per team, reachable both adversarially (archive-and-recreate loops) and organically (heavy experiment users accumulate one retained archived flag per finished experiment). The per-flag 512KB cap still bounds a single flag's cost but not the count, so the concern is real and directly caused by the diff. - Priority: Downgrading must_fix → should_fix. The regression is genuine and tied to a memory bound that has already produced OOMs, so it clears the keep bar. But it is a deliberate, documented tradeoff by the author, causes no immediate incorrect behavior, and only bites after a large accumulation well above observed production maximums — that is a real resilience issue worth revisiting, not a merge blocker.
Issue description
The limit originally protects flag evaluation and caching from excessive memory use, but the new query excludes archived flags while archived rows remain serialized into the flags hypercache and local-evaluation response. An editor can repeatedly archive 2,000 flags and create replacements, growing those payloads without any bound. The updated settings comment explicitly confirms this behavior, so the safety boundary no longer constrains the resources it was introduced to protect.
Suggested fix
Keep a separate hard limit over every non-deleted flag that enters cache or local-evaluation serialization, while using the new non-archived limit for the live roster. Alternatively, exclude archived flags from those serialized payloads if consumers do not require them. Enforce both limits during creation so archiving frees a live slot without enabling unbounded cache growth.
Prompt to fix with AI (copy-paste)
## Context
@products/feature_flags/backend/api/feature_flag.py#L603
<issue_description>
The limit originally protects flag evaluation and caching from excessive memory use, but the new query excludes archived flags while archived rows remain serialized into the flags hypercache and local-evaluation response. An editor can repeatedly archive 2,000 flags and create replacements, growing those payloads without any bound. The updated settings comment explicitly confirms this behavior, so the safety boundary no longer constrains the resources it was introduced to protect.
</issue_description>
<issue_validation>
- **Checked:** The changed `check_flag_limits_for_team` (`products/feature_flags/backend/api/feature_flag.py:593-594`), the serialization path feeding the flags hypercache and local-evaluation response (`get_feature_flags` / `serialize_feature_flags` / `EvaluationFeatureFlagSerializer`), and the settings comment the diff adds.
- **Found:** The new count is `FeatureFlag.objects.filter(team_id=team_id, archived=False).count()`, but `get_feature_flags` at `products/feature_flags/backend/models/feature_flag.py:598` filters with the default manager (`FeatureFlag.objects.filter(**filter_kwargs)`) — it excludes only soft-deleted rows, not archived ones. `EvaluationFeatureFlagSerializer` (`feature_flag.py:2659-2661`) confirms this serialized set feeds both the Rust-service hypercache and the local-evaluation response. So archived flags are still serialized while no longer counting toward the cap.
- **Found:** Before this change the cap counted archived flags, so total serialized flags per team was bounded at `MAX_FEATURE_FLAGS_PER_TEAM` (2000). After it, live flags are bounded but archived flags are unbounded, so the total serialized payload (live + archived) has no ceiling — the diff's own settings comment states this outright.
- **Impact:** The limit's stated purpose is 'to prevent memory issues during flag evaluation/caching', and the codebase already carries scars from this exact failure mode: `FLAGS_CACHE_VERIFICATION_CHUNK_SIZE` was 'Reduced from 1000 to 250 to prevent OOM' because 'Teams with 100+ flags and large filters JSONs can use significant memory' (`posthog/settings/feature_flags.py:81-85`). The change removes the only bound on total serialized flag count per team, reachable both adversarially (archive-and-recreate loops) and organically (heavy experiment users accumulate one retained archived flag per finished experiment). The per-flag 512KB cap still bounds a single flag's cost but not the count, so the concern is real and directly caused by the diff.
- **Priority:** Downgrading must_fix → should_fix. The regression is genuine and tied to a memory bound that has already produced OOMs, so it clears the keep bar. But it is a deliberate, documented tradeoff by the author, causes no immediate incorrect behavior, and only bites after a large accumulation well above observed production maximums — that is a real resilience issue worth revisiting, not a merge blocker.
</issue_validation>
## Task
Investigate the issue and solve it
<potential_solution>
Keep a separate hard limit over every non-deleted flag that enters cache or local-evaluation serialization, while using the new non-archived limit for the live roster. Alternatively, exclude archived flags from those serialized payloads if consumers do not require them. Enforce both limits during creation so archiving frees a live slot without enabling unbounded cache growth.
</potential_solution>
Problem
A team at the 2,000 feature flag limit could only make room by deleting a flag. Archiving one didn't free a slot, even though archiving is meant to preserve a flag's data for linked experiments and surveys.
Changes
MAX_FEATURE_FLAGS_PER_TEAM, so archiving actually frees a slot.How did you test this code?
Automated tests pin the tricky cases: a flag that's both archived and deleted, the facade's default behavior without the waiver, and an experiment unarchiving over the limit.
Manually verified end to end on a local dev stack with a team filled to the flag cap:
Not checked: how many production teams are already at or over the cap.
Automatic notifications
Docs update
None. No existing docs describe this limit.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Skills invoked: /writing-user-facing-copy, /writing-tests, /improving-drf-endpoints, /simplify, /review-code, /writing-pr-descriptions.
Decisions across the session: