Skip to content

fix(flags): exclude archived flags from the per-team limit - #81616

Draft
haacked wants to merge 1 commit into
masterfrom
posthog-code/exclude-archived-flags-from-limit
Draft

fix(flags): exclude archived flags from the per-team limit#81616
haacked wants to merge 1 commit into
masterfrom
posthog-code/exclude-archived-flags-from-limit

Conversation

@haacked

@haacked haacked commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

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

  • Archived flags no longer count toward MAX_FEATURE_FLAGS_PER_TEAM, so archiving actually frees a slot.
  • Unarchiving now checks the cap too, closing a path where archiving, recreating, then unarchiving flags could push a team over the limit.
  • Restoring a soft-deleted flag stays exempt from the cap.
  • Without that, a team already over the limit would get a delete that works but can't be undone.
  • Unarchiving an experiment always restores its own flag, even if that puts the team over the limit.
  • The over-limit error message now suggests archiving before deleting.

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:

  • Creating a flag fails, archiving frees a slot, and a replacement can then be created.
  • Unarchiving that flag re-hits the cap, while deleting, restoring, and unarchiving an experiment all succeed even over the cap.

Not checked: how many production teams are already at or over the cap.

Automatic notifications

  • Publish to changelog?

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:

  • Started from excluding archived flags from the count, then added the unarchive check to close the loophole.
  • Chose to always restore an experiment's flag over the cap, instead of silently skipping it.
  • A /review-code pass flagged the one-way-delete problem, so restoring a soft-deleted flag was exempted from the cap too.

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
@haacked haacked added the skip-inkeep-docs Use this label to skip an Inkeep docs PR in posthog.com label Aug 12, 2026
@haacked haacked 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

@haacked haacked added the reviewhog ($$$) Reviews pull requests before humans do label Aug 12, 2026
@posthog

posthog Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

🦔 ReviewHog reviewed this pull request

Found 0 must fix, 1 should fix, 0 consider.

Published 1 finding (view the review).

@posthog

posthog Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏

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

ReviewHog Report

Changes

Issues: 1 issue

Files (4)
  • posthog/settings/feature_flags.py
  • products/experiments/backend/experiment_service.py
  • products/feature_flags/backend/api/feature_flag.py
  • products/feature_flags/backend/facade/api.py


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()

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.

Archived flags can bypass the resource-safety limit indefinitely

should_fix performance

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(), 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 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>

@trunk-io

trunk-io Bot commented Aug 12, 2026

Copy link
Copy Markdown

Static BadgeStatic BadgeStatic Badge

View Full Report ↗︎Docs

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

Labels

feature/feature-flags Feature Tag: Feature flags reviewhog ($$$) Reviews pull requests before humans do skip-inkeep-docs Use this label to skip an Inkeep docs PR in posthog.com team/feature-flags

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant