Skip to content

fix(segment-membership): tombstone traitless identities in clickhouse - #8758

Open
noy-solvin wants to merge 1 commit into
Flagsmith:mainfrom
noy-solvin:fix__8499__tombstone-traitless-identities-clickhouse__flagsmith_751fd68c577b-v2
Open

noy-solvin wants to merge 1 commit into
Flagsmith:mainfrom
noy-solvin:fix__8499__tombstone-traitless-identities-clickhouse__flagsmith_751fd68c577b-v2

Conversation

@noy-solvin

Copy link
Copy Markdown

Thanks for submitting a PR! Please check the boxes below:

  • I have read the Contributing Guide.

  • I have added information to docs/ if required so people know about the feature.

  • I have filled in the "Changes" section below.

  • I have filled in the "How did you test this code" section below.

Changes

Closes #8499

Problem

Organisations seeded prior to PR #8455 have rows in ClickHouse IDENTITIES for identities carrying no traits. Those rows remain live (is_deleted = false) and are never superseded because:

  • Current seeding pipelines skip traitless identities via _is_empty_identity, preventing seed reconciliation or admin force re-seeding from writing superseding rows.

  • Upstream CDC streams drop records whose old and new images are both traitless, preventing deletion events from propagating to ClickHouse.

This results in ghost members that inflate SegmentMembershipCount and appear in segment member listings for segments satisfiable by traitless identities (such as percentage splits, conditions on $.identity.key, or is_not_set). Dashboard deletions trigger recounts that cannot clear the underlying ClickHouse rows.

Solution

  • Added new Django management command tombstone_traitless_identities under api/segment_membership/management/commands/tombstone_traitless_identities.py with necessary package initializers.

  • Implemented CLI arguments --organisation-id, --environment-id (mutually exclusive), --dry-run, and --skip-recount.

  • Added entity existence validation for organisation and environment inputs raising CommandError.

  • Resolved target environments scoped either by specific organisation/environment or defaulting to all seeded organisations (SegmentMembershipSeed.objects.filter(seeded_at__isnull=False)).

  • Sized target records using ClickHouse query _SIZING_SQL filtering by environment_id, is_deleted = false, and empty(JSONAllPaths(traits)) on IDENTITIES FINAL.

  • Inserted tombstones via _TOMBSTONE_SQL: INSERT INTO IDENTITIES (environment_id, identifier, identity_key, traits, inserted_at, source, is_deleted) SELECT environment_id, identifier, identity_key, traits, inserted_at + INTERVAL 1 SECOND, 'cleanup', true FROM IDENTITIES FINAL WHERE environment_id = %(env_key)s AND is_deleted = false AND empty(JSONAllPaths(traits)).

  • Stamped inserted_at + INTERVAL 1 SECOND from the superseded row to preserve CDC precedence against in-flight events while avoiding physical row deletions (ALTER TABLE ... DELETE) that would resurrect stale historical traits.

  • Deduplicated affected projects and triggered enqueue_membership_refresh(project) unless --dry-run or --skip-recount is specified.

  • Added per-environment error handling with structlog logging and raised a cumulative CommandError if any environment encountered a failure.

🟣 Confidence: Medium-High

Engineering Dimension Status / Score Technical Telemetry
🎯 Intent Clarity 🟢 High Issue specification clearly defined the ClickHouse traitless identity symptoms, CDC timestamp constraints, and repair strategy.
🔍 RCA Confidence 🟢 High Root cause isolated to historical seeding pre-dating PR #8455 creating traitless ClickHouse rows that cannot be superseded by current seeding or edge-api CDC streams.
🧪 TDD Relevance 🟣 Medium-High 11 unit tests verify all CLI options, queries, and failure modes under mocked ClickHouse and Celery boundaries without live database cluster execution.
🛠️ Execution Safety 🟣 Medium-High Implementation verified via 11 passing unit tests and 5,369 regression tests with 0 regressions, safely capped at Medium-High due to unobserved live ClickHouse cluster interactions in sandbox.
🗺️ Code Blast Radius 🟢 Low Footprint is highly contained within an ad-hoc Django management command and unit tests without runtime request path or schema mutations.
🧠 Fact & Logic Grounding 🟢 High Audit confirmed full grounding with zero unverified assertions against the ClickHouse schema and codebase services.

High scores achieved across Intent Clarity, RCA Confidence, Code Blast Radius, and Fact & Logic Grounding due to precise issue specifications, isolated command scope, and verified ReplacingMergeTree tombstone mechanics. TDD Relevance and Execution Safety are calibrated to Medium-High solely because live ClickHouse database daemon execution cannot be observed within the isolated sandbox environment.

How did you test this code?

  • Authored comprehensive unit test suite in api/tests/unit/segment_membership/test_unit_segment_membership_management.py with 11 tests reproducing the missing command baseline failure and verifying full functionality:

    • test_tombstone_traitless_identities__both_org_and_env_provided__raises_command_error: PASSED

    • test_tombstone_traitless_identities__non_existent_organisation_id__raises_command_error: PASSED

    • test_tombstone_traitless_identities__non_existent_environment_id__raises_command_error: PASSED

    • test_tombstone_traitless_identities__clickhouse_disabled__prints_warning_and_skips: PASSED

    • test_tombstone_traitless_identities__dry_run__queries_count_and_skips_insert_and_recount: PASSED

    • test_tombstone_traitless_identities__execution_mode_with_traitless__executes_tombstone_and_enqueues_recount: PASSED

    • test_tombstone_traitless_identities__zero_traitless_count__does_not_execute_insert_nor_enqueue_recount: PASSED

    • test_tombstone_traitless_identities__skip_recount_flag__executes_tombstone_without_enqueueing_recount: PASSED

    • test_tombstone_traitless_identities__organisation_id_provided__processes_organisation_environments: PASSED

    • test_tombstone_traitless_identities__default_scope__processes_only_seeded_organisations: PASSED

    • test_tombstone_traitless_identities__partial_failure__continues_processing_and_raises_command_error: PASSED

  • Ran full regression test suite of 5,369 tests across the repository with 0 regressions detected.

  • Architectural review confirmed CDC precedence preservation, query scoping, and safe error handling.

  • security regression scan confirmed the new code has no security issue

  • Validated code standards and formatting using api-lint, api-format, and flagsmith-lint-tests.


Full transparency: this fix was generated using Solvin, an AI coding agent my team is building. Reviewed and tested manually before submitting. I'd love your feedback. The fix was fully tested manually by me prior to submitting this PR.

@vercel

vercel Bot commented Oct 13, 2026

Copy link
Copy Markdown

@solvin-ai-teammate is attempting to deploy a commit to the Flagsmith Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

📝 Walkthrough

Adds a Django management command to find active ClickHouse identities with no trait paths and insert cleanup tombstones. The command can target an organisation or environment, or select environments for seeded organisations. It supports dry-run and skip-recount options, reports processing totals, and continues across environment failures before raising an error. Unit tests cover selection, ClickHouse availability, tombstone insertion, refresh queuing, and partial failures.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Severity of issue fixed: Medium

Merge Risk: 🟡 Moderate · up to bf59d

The repair can leave ghost members visible in the members endpoint until ClickHouse merges the rows. Correct that read path before merging; also fix the dry-run total and test isolation.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

⚠️ This pull request shows signs of AI-generated slop (ai_padded_prose). It has been flagged by CodeRabbit slop detection and should be reviewed carefully.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the api Issue related to the REST API label Oct 13, 2026
@noy-solvin
noy-solvin marked this pull request as ready for review October 22, 2026 07:35
@noy-solvin
noy-solvin requested a review from a team as a code owner October 22, 2026 07:35
@noy-solvin
noy-solvin requested review from khvn26 and removed request for a team October 22, 2026 07:35
@coderabbitai coderabbitai Bot added the slop label Oct 22, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 8522f6df-a3b0-42a1-8017-c232c4abc85d
📥 Commits

Reviewing files that changed from the base of the PR and between 6f41b05 and bf59dc6.

📒 Files selected for processing: 4
  • api/segment_membership/management/__init__.py
  • api/segment_membership/management/commands/__init__.py
  • api/segment_membership/management/commands/tombstone_traitless_identities.py
  • api/tests/unit/segment_membership/test_unit_segment_membership_management.py

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment on lines +28 to +39

# Bug #8499: Query to insert tombstones superseding traitless rows with inserted_at + 1s.
_TOMBSTONE_SQL = """
INSERT INTO IDENTITIES
(environment_id, identifier, identity_key, traits, inserted_at, source, is_deleted)
SELECT environment_id, identifier, identity_key, traits,
inserted_at + INTERVAL 1 SECOND, 'cleanup', true
FROM IDENTITIES FINAL
WHERE environment_id = %(env_key)s
AND is_deleted = false
AND empty(JSONAllPaths(traits))
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

set -o pipefail
printf '%s\n' '--- reader and relevant symbols ---'
rg -n -F --glob '*.py' -e 'LIMIT 1 BY' -e 'is_deleted = false' -e 'get_segment_members' -e 'membership' api/segment_membership api/tests | head -240
printf '%s\n' '--- service reader block ---'
nl -ba api/segment_membership/services.py | sed -n '190,285p'
printf '%s\n' '--- all direct references to likely reader symbols ---'
rg -n -F --glob '*.py' -e 'get_segment_members(' -e 'get_segment_membership(' -e 'query_segment_members' -e 'paginate' api | head -240
printf '%s\n' '--- command and management tests ---'
nl -ba api/segment_membership/management/commands/tombstone_traitless_identities.py | sed -n '1,180p'
rg -n -F --glob '*.py' -e 'tombstone_traitless_identities' -e 'identity_key' api/tests/unit/segment_membership api/tests | head -240
printf '%s\n' '--- read-time filter references ---'
rg -n -i --glob '*.py' --glob '*.md' --glob '*.rst' -e 'read.time filter' -e 'read_time_filter' -e 'traitless' -e 'ghost segment' api | head -240

Repository: Flagsmith/flagsmith

Length of output: 41987


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- complete reader ---'
nl -ba api/segment_membership/services.py | sed -n '180,285p'
printf '%s\n' '--- reader caller and endpoint ---'
rg -n -F --glob '*.py' -e 'get_segment_members_page' -e 'SegmentMembership' -e 'segment-members' -e 'segment_members' api/segments api/segment_membership | head -240
printf '%s\n' '--- candidate endpoint files and blocks ---'
rg -l -F --glob '*.py' -e 'get_segment_members_page' -e 'get_segment_members' api | sort
printf '%s\n' '--- endpoint test ---'
nl -ba api/tests/integration/segment_membership/test_segment_members_endpoint.py | sed -n '1,250p'
printf '%s\n' '--- types and response mapping ---'
nl -ba api/segment_membership/types.py | sed -n '1,180p'
rg -n -F --glob '*.py' -e 'SegmentMember' -e 'next_cursor' api/segment_membership api/segments | head -200
printf '%s\n' '--- focused service tests ---'
nl -ba api/tests/unit/segment_membership/test_unit_segment_membership_services.py | sed -n '180,550p'

Repository: Flagsmith/flagsmith

Length of output: 39764


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- endpoint implementation ---'
nl -ba api/segments/views.py | sed -n '180,255p'
printf '%s\n' '--- response serializers ---'
nl -ba api/segments/serializers.py | sed -n '380,445p'
printf '%s\n' '--- count query and task flow ---'
nl -ba api/segment_membership/services.py | sed -n '100,185p'
nl -ba api/segment_membership/tasks.py | sed -n '205,275p'
printf '%s\n' '--- fixture identity and segment setup ---'
nl -ba api/tests/conftest.py | sed -n '1460,1525p'
nl -ba api/tests/integration/segment_membership/test_segment_members_endpoint.py | sed -n '105,145p'
printf '%s\n' '--- segment translation for empty/all rules ---'
rg -n -F --glob '*.py' -e 'def translate_segment' -e 'SegmentRule.ALL_RULE' -e 'type == SegmentRule.ALL_RULE' api | head -160

Repository: Flagsmith/flagsmith

Length of output: 28269


🏁 Script executed:

set -o pipefail
printf '%s\n' '--- service imports and segment mapping ---'
nl -ba api/segment_membership/services.py | sed -n '1,45p'
rg -n -F --glob '*.py' -e 'def map_segment_to_engine' -e 'def translate_segment' -e 'class SegmentRule' -e 'ALL_RULE' api | head -200
printf '%s\n' '--- segment model and mapper declarations ---'
rg -l -F --glob '*.py' -e 'class SegmentRule' -e 'def map_segment_to_engine' -e 'def translate_segment' api | sort

Repository: Flagsmith/flagsmith

Length of output: 14806


Make the paginated membership read honour cleanup tombstones.

When the command inserts a cleanup row, ClickHouse may still contain both physical rows. get_segment_members_page filters is_deleted = false before LIMIT 1 BY, so it can select the superseded traitless row until the table merges. The members endpoint returns this row directly. Recounting uses FINAL, but it only updates stored counts and does not affect this endpoint.

Suggested fix
-        "FROM IDENTITIES AS i "
+        "FROM IDENTITIES AS i FINAL "
...
-        "FROM IDENTITIES AS m "
+        "FROM IDENTITIES AS m FINAL "

Comment on lines +107 to +109
if tombstoned_count > 0:
tombstoned_total += tombstoned_count
modified_projects.add(environment.project)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

sed -n '74,143p' api/segment_membership/management/commands/tombstone_traitless_identities.py
sed -n '177,205p' api/segment_membership/management/commands/tombstone_traitless_identities.py
sed -n '160,205p' api/tests/unit/segment_membership/test_unit_segment_membership_management.py

Repository: Flagsmith/flagsmith

Length of output: 5499


Report the aggregate dry-run count.

In dry-run mode, _process_environment logs each environment’s candidate count but returns 0. The final summary therefore reports 0 identities tombstoned, even when multiple environments contain candidates. This prevents operators from sizing the repair.

Return traitless_count in both modes, add it to the aggregate total, and add projects to modified_projects only for non-dry runs. Use would be tombstoned in the dry-run summary.

♻️ Suggested fix
-                if tombstoned_count > 0:
-                    tombstoned_total += tombstoned_count
-                    modified_projects.add(environment.project)
+                tombstoned_total += tombstoned_count
+                if tombstoned_count > 0 and not dry_run:
+                    modified_projects.add(environment.project)
-            if traitless_count > 0:
-                if dry_run:
-                    self.stdout.write(
-                        f"[DRY RUN] Environment {environment.id} ({env_key}): "
-                        f"{traitless_count} traitless identities found."
-                    )
-                else:
-                    self.stdout.write(
-                        f"Environment {environment.id} ({env_key}): "
-                        f"Tombstoning {traitless_count} traitless identities."
-                    )
-                    cursor.execute(_TOMBSTONE_SQL, {"env_key": env_key})
-                    return traitless_count
-        return 0
+            if traitless_count == 0:
+                return 0
+            if dry_run:
+                self.stdout.write(
+                    f"[DRY RUN] Environment {environment.id} ({env_key}): "
+                    f"{traitless_count} traitless identities found."
+                )
+                return traitless_count
+            self.stdout.write(
+                f"Environment {environment.id} ({env_key}): "
+                f"Tombstoning {traitless_count} traitless identities."
+            )
+            cursor.execute(_TOMBSTONE_SQL, {"env_key": env_key})
+            return traitless_count

Comment on lines +25 to +31
mock = mocker.patch("segment_membership.services.open_clickhouse_cursor")
mock.return_value.__enter__.return_value = mock_cursor
cmd_module = sys.modules.get(
"segment_membership.management.commands.tombstone_traitless_identities"
)
if cmd_module and hasattr(cmd_module, "open_clickhouse_cursor"):
mocker.patch.object(cmd_module, "open_clickhouse_cursor", mock)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Patch the names in the command module directly. The current fixtures can leak mocks into the module.

The fixtures patch segment_membership.services.*. They patch the command module only if sys.modules already holds it. call_command imports the command module lazily.

In the first test of a process, the module is not yet imported when the fixture runs. The first call_command then executes from segment_membership.services import ... while the services attribute is patched. The command module binds the mock as its own attribute. The mock stays there after the test, because mocker only restores the services attribute.

Later tests, and any future test that calls this command without the fixtures, can then see a stale mock. The if cmd_module and hasattr(...) guard also hides a failed patch.

Patch the command module path directly. mocker.patch imports the module and restores the correct attribute. The sys import then becomes unused.

🧪 Proposed fix
-    mock = mocker.patch("segment_membership.services.open_clickhouse_cursor")
-    mock.return_value.__enter__.return_value = mock_cursor
-    cmd_module = sys.modules.get(
-        "segment_membership.management.commands.tombstone_traitless_identities"
-    )
-    if cmd_module and hasattr(cmd_module, "open_clickhouse_cursor"):
-        mocker.patch.object(cmd_module, "open_clickhouse_cursor", mock)
-    return mock
+    mock = mocker.patch(
+        "segment_membership.management.commands."
+        "tombstone_traitless_identities.open_clickhouse_cursor"
+    )
+    mock.return_value.__enter__.return_value = mock_cursor
+    return mock
-    mock = mocker.patch("segment_membership.services.enqueue_membership_refresh")
-    cmd_module = sys.modules.get(
-        "segment_membership.management.commands.tombstone_traitless_identities"
-    )
-    if cmd_module and hasattr(cmd_module, "enqueue_membership_refresh"):
-        mocker.patch.object(cmd_module, "enqueue_membership_refresh", mock)
-    return mock
+    return mocker.patch(
+        "segment_membership.management.commands."
+        "tombstone_traitless_identities.enqueue_membership_refresh"
+    )

Also applies to: 37-42

Source: Learnings

This branch has not been deployed

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

Labels

api Issue related to the REST API slop

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tombstone traitless identities in ClickHouse left behind by earlier Segment Membership seeding runs

2 participants