Skip to content

feat(cohorts): sweep membership rows a completed reconcile did not assert - #89797

Merged
trunk-io[bot] merged 9 commits into
matheus-vb/cohort-membership-sweep-schemafrom
matheus-vb/cohort-membership-mark-and-sweep
Aug 28, 2026
Merged

trunk-io[bot] merged 9 commits into
matheus-vb/cohort-membership-sweep-schemafrom
matheus-vb/cohort-membership-mark-and-sweep

Conversation

@matheus-vb

@matheus-vb matheus-vb commented Aug 27, 2026 •

Copy link
Copy Markdown
Member

Problem

Someone who stops matching an edited cohort still evaluates as a member, and nothing ever corrects it.

The membership consumer only upserts, so a person who no longer matches gets no live transition and their row survives.

Builds on the schema PR below this one in the stack.

Closes #88014.

Changes

  • A completed reconcile run now deletes the cohort rows it did not assert, so membership stops drifting after an edit.
  • The consumer stamps every row with the producer version, and a replayed older write no longer overwrites a newer one. A change without a usable version still applies, keeping the row's existing stamp.
  • A second consumer reads the reconcile marker topic and folds each run's 64 partition markers into a bitmap.
  • The sweep waits until consumer progress passes the watermarks captured at marker completeness. It never deletes rows whose messages are still queued.
  • Progress comes from consumed batches and from the group's committed offsets. Batches alone miss quiet partitions, which would block the sweep forever.
  • Progress rows are keyed by broker cluster and topic, so offsets from a feed the consumer left cannot open the gate.
  • A run is swept only once its snapshot minimum is known. Marker stamps alone can sit above rows the run asserted, so sweeping on them would delete live members.
  • The first sweep also deletes rows written before this feature. They carry the column default -infinity, which sorts below every producer stamp, so no separate cleanup is needed.
  • With the flag on, rows, snapshot marks and progress share one transaction, so progress can never claim a write that rolled back.
  • COHORT_MEMBERSHIP_SWEEP_ENABLED gates the marker consumer, the sweep, and every new write, and defaults to false. With the flag off the consumer keeps the exact pre-sweep write shape, so the migrations gate the flag flip, not the deploy.
  • Startup fails when the flag is set but the marker topic has no partitions. The cohort pipeline and its topics are US-scoped, so the fix is unsetting the flag, not creating the topic.
  • The marker consumer starts at the latest offset. Runs already on the topic at first enable never complete and are abandoned; the next reconcile heals them.
  • Sweep claims carry a fencing token, and the delete loop pages with a keyset cursor.
  • The v1 Kafka consumer now honors an auto.offset.reset override instead of pinning every consumer to earliest. This matches v2, which already honored it.
  • The only caller passing an override is the hogflow subscription matcher, which asks for latest so a fresh group skips the person topic backlog.
  • CDP runs v2 in production, so the matcher's behavior does not change in any deployed environment. The fix reaches it only on the v1 path, which local dev and CI still use.
  • Committed offsets still win once they exist, so the override governs first bootstrap and offset loss only.
  • Mechanical: KafkaConsumerInterface gains three read methods. The v2 consumer implements all three; v1 already had two and only adds committedOffsets.

Nothing is user visible until the flag flips in charts, which happens after the processor writes membership to the live topic.

How did you test this code?

New sweeper tests cover the marker bitmap under redelivery, the progress gate, and stale claim recovery. They also cover the delete predicate scoping by team and cohort, and the two refusals that stop a run from deleting a whole cohort.

New consumer tests cover version persistence across all three origins and the last writer wins guard on replay, including a versionless change against a versioned row. They also cover snapshot minimum tracking across batches, progress that never moves backwards, partial partition metadata refusal, committed offset filtering, the flag-off write shape, and a marker batch failure staying contained.

Each assertion in the original suites was checked by breaking the implementation and confirming the intended test failed. Reverting the gate, the version guard, the snapshot minimum, and both refusals each failed exactly one group. The review-round tests were not break-checked.

nodejs/src/cdp/dev/cohort-sweep-e2e.test.ts drives the whole chain against real Kafka and Postgres on a dev stack. It lives under dev/ so CI skips it, and it passed against both consumer implementations.

Not run: CI, and any deployed environment.

👉 Stay up-to-date with PostHog coding conventions for a smoother review.

Automatic notifications

  • Publish to changelog?

Docs update

None.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Authored with Claude Code (Opus) from a design document for issue #88014. Review fixes applied with Claude Code (Fable) across two review rounds.

Skills invoked: /implement-plan, /writing-tests, /writing-pr-descriptions, and code-review-v1 for an adversarial pass.

Two designs from the document did not survive contact with a running stack. The gate originally read progress only from consumed batches, which deadlocked on any partition holding retained messages with no new traffic. Committed offsets replaced it. The threshold originally fell back to the marker minimum through LEAST. That value sits above the rows a run asserted, so a run with no snapshot marks would have deleted a whole cohort. Such a run is now refused.

The end to end harness found the first of those, and the review found the second. A second review round moved the version writes behind the flag, dropped NULL rows from the sweep predicate, and hardened the marker path against transient failures.

@pr-assigner-resolver-posthog

Copy link
Copy Markdown

👀 Auto-assigned reviewers

These soft owners were skipped because they only have minor changes here. Nothing blocks merge, so self-assign if you'd like a look:

  • @PostHog/team-ingestion (owners.yaml)

Soft owners come from each directory's owners.yaml and each product's product.yaml (resolved nearest-file-wins). The locator after each owner is the file that decided it. Generated files and lockfiles are ignored when deciding ownership.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

@trunk-io

trunk-io Bot commented Aug 27, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

View Full Report ↗︎ ⋅ Docs

@matheus-vb
matheus-vb force-pushed the matheus-vb/cohort-membership-mark-and-sweep branch from 3ed6c3a to 6cb8d43 Compare August 27, 2026 12:38
@matheus-vb matheus-vb changed the title feat(cohorts): sweep membership rows a completed feat(cohorts): sweep membership rows a completed reconcile did not assert Aug 27, 2026
@matheus-vb
matheus-vb force-pushed the matheus-vb/cohort-membership-mark-and-sweep branch from 7358e7a to 356bf1f Compare August 27, 2026 13:44
@github-project-automation github-project-automation Bot moved this from In Review to Approved in Feature Flags Aug 27, 2026
@posthog

posthog Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

🦔 PostHog Review reviewed this pull request

Found 0 must fix, 6 should fix, 2 consider.

Published 8 findings (view the review).

@posthog

posthog Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

PostHog Review 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.

PostHog Review

Found 6 should fix, 2 consider.

Comment on lines +487 to +499
WITH candidates AS (
SELECT id FROM cohort_membership
WHERE team_id = $1 AND cohort_id = $2
AND version < $3::timestamp
AND (version, id) > ($5::timestamp, $6::bigint)
ORDER BY version, id
LIMIT $4
FOR UPDATE SKIP LOCKED
)
DELETE FROM cohort_membership m
USING candidates c
WHERE m.id = c.id AND m.version < $3::timestamp
RETURNING to_char(m.version, 'YYYY-MM-DD HH24:MI:SS.US') AS version, m.id

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.

A current versionless update can be swept as stale

consider bug

Why we think it's a valid issue
  • Checked: whether a versionless change can reach the upsert, what it leaves in the version column, and whether the sweep predicate can then match that row.
  • Found: the mechanism is real. upsertMembershipRows at cdp-cohort-membership.consumer.ts:365-368 sets version = COALESCE(EXCLUDED.version, cohort_membership.version) and admits the write through EXCLUDED.version IS NULL, so a versionless entered flips in_cohort and refreshes last_updated while the stale stamp survives. The sweep at sweeper.service.ts:489-490 tests only team_id, cohort_id, and version < $3::timestamp — it never reads in_cohort or last_updated — so that row is deleted even though it holds a current membership the run did not assert. The branch is live behavior the author tests, not dead code: cdp-cohort-membership.consumer.test.ts:341 asserts "a versionless change applies without moving the version".
  • Found: this PR makes a stated invariant wrong. The comment at cdp-cohort-membership.consumer.ts:505-508 says an off-format stamp degrades to no version and "loses only the ordering: the membership transition itself still applies". With the sweep in place that is no longer true — the transition applies and is then silently deleted. The off-format valve exists precisely because the author judged format drift worth building for.
  • Found: no trigger exists today, which is what caps the severity. The topic has one producer: CohortMembershipChange.last_updated is a non-optional String at rust/cohort-stream-processor/src/producer/mod.rs:53, with no skip_serializing_if (unlike origin and run_id two fields below), and every emitting path threads a stamp in — map_transition at :118, plus cascade_path.rs:149, stage2_path.rs:110, and person_seed_path.rs:511. Python only declares the topic and consumes it into ClickHouse; cohort-seeder never builds this struct.
  • Found: the downgrade route is closed too. PRODUCER_VERSION_FORMAT at sweeper.service.ts:23 matches exactly what format_last_updated emits with %Y-%m-%d %H:%M:%S%.6f (rust/cohort-stream-processor/src/producer/mod.rs:156-161), so _parseAndValidateBatch never strips a stamp from a current producer.
  • Impact: confirmed silent data loss if a producer ever omits or malforms the stamp — a current member's row is deleted, and flag evaluation reads them as a non-member until the next reconcile or the next versioned write. offFormatVersions would rise at the same time, so the cause is visible, but the deletion itself is not.
  • Priority: lowered from must_fix. The defect needs an upstream producer regression before it can fire, and the Rust type rules out the primary route, so nothing reaches a user from this PR as it stands. It stays on record because the trigger is a valve this PR relies on, the failure mode is deletion rather than the benign degradation the code comment promises, and the fix is small.
Issue description

The sweep classifies a row only by its preserved version. The consumer intentionally applies a versionless change without changing that field. A current versionless entered event can therefore leave an old version below this run's threshold. This DELETE removes the current membership.

Suggested fix

Record versionless writes in a separate field that the sweep can test. Another safe option makes these rows non-sweepable until a versioned write replaces them. Add a race test where a versionless entered change arrives after the snapshot and before deletion.

Prompt to fix with AI (copy-paste)
## Context
@nodejs/src/cdp/services/cohort-membership/sweeper.service.ts#L487-499

<issue_description>
The sweep classifies a row only by its preserved `version`. The consumer intentionally applies a versionless change without changing that field. A current versionless `entered` event can therefore leave an old version below this run's threshold. This DELETE removes the current membership.
</issue_description>

<issue_validation>
- **Checked:** whether a versionless change can reach the upsert, what it leaves in the `version` column, and whether the sweep predicate can then match that row.
- **Found:** the mechanism is real. `upsertMembershipRows` at `cdp-cohort-membership.consumer.ts:365-368` sets `version = COALESCE(EXCLUDED.version, cohort_membership.version)` and admits the write through `EXCLUDED.version IS NULL`, so a versionless `entered` flips `in_cohort` and refreshes `last_updated` while the stale stamp survives. The sweep at `sweeper.service.ts:489-490` tests only `team_id`, `cohort_id`, and `version < $3::timestamp` — it never reads `in_cohort` or `last_updated` — so that row is deleted even though it holds a current membership the run did not assert. The branch is live behavior the author tests, not dead code: `cdp-cohort-membership.consumer.test.ts:341` asserts "a versionless change applies without moving the version".
- **Found:** this PR makes a stated invariant wrong. The comment at `cdp-cohort-membership.consumer.ts:505-508` says an off-format stamp degrades to no version and "loses only the ordering: the membership transition itself still applies". With the sweep in place that is no longer true — the transition applies and is then silently deleted. The off-format valve exists precisely because the author judged format drift worth building for.
- **Found:** no trigger exists today, which is what caps the severity. The topic has one producer: `CohortMembershipChange.last_updated` is a non-optional `String` at `rust/cohort-stream-processor/src/producer/mod.rs:53`, with no `skip_serializing_if` (unlike `origin` and `run_id` two fields below), and every emitting path threads a stamp in — `map_transition` at `:118`, plus `cascade_path.rs:149`, `stage2_path.rs:110`, and `person_seed_path.rs:511`. Python only declares the topic and consumes it into ClickHouse; `cohort-seeder` never builds this struct.
- **Found:** the downgrade route is closed too. `PRODUCER_VERSION_FORMAT` at `sweeper.service.ts:23` matches exactly what `format_last_updated` emits with `%Y-%m-%d %H:%M:%S%.6f` (`rust/cohort-stream-processor/src/producer/mod.rs:156-161`), so `_parseAndValidateBatch` never strips a stamp from a current producer.
- **Impact:** confirmed silent data loss if a producer ever omits or malforms the stamp — a current member's row is deleted, and flag evaluation reads them as a non-member until the next reconcile or the next versioned write. `offFormatVersions` would rise at the same time, so the cause is visible, but the deletion itself is not.
- **Priority:** lowered from `must_fix`. The defect needs an upstream producer regression before it can fire, and the Rust type rules out the primary route, so nothing reaches a user from this PR as it stands. It stays on record because the trigger is a valve this PR relies on, the failure mode is deletion rather than the benign degradation the code comment promises, and the fix is small.
</issue_validation>

## Task
Investigate the issue and solve it

<potential_solution>
Record versionless writes in a separate field that the sweep can test. Another safe option makes these rows non-sweepable until a versioned write replaces them. Add a race test where a versionless `entered` change arrives after the snapshot and before deletion.
</potential_solution>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Leaving this as is. No producer today can emit a versionless or malformed stamp, so this race cannot fire, and the parser comment now states that a degraded row stays sweepable until a versioned write lands. A real fix needs a new column marking rows touched by an unordered write, and we would rather not grow the schema for a case the producer contract rules out.

@posthog posthog Bot removed the reviewhog label Aug 27, 2026

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

Two blocking issues inline, both about what happens around the flag rather than inside the sweep.

@matheus-vb
matheus-vb force-pushed the matheus-vb/cohort-membership-mark-and-sweep branch 2 times, most recently from d7b5bed to f5c2925 Compare August 27, 2026 17:57
@matheus-vb
matheus-vb force-pushed the matheus-vb/cohort-membership-mark-and-sweep branch from 4a5ef56 to b9f830e Compare August 27, 2026 19:56
@matheus-vb
matheus-vb requested a review from haacked August 27, 2026 20:10
@posthog-project-board-bot posthog-project-board-bot Bot moved this from Approved to In Review in Feature Flags Aug 27, 2026

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

Everything from the last round landed. Two blocking inline, and one of them is just a stale bullet in the description.

@matheus-vb
matheus-vb force-pushed the matheus-vb/cohort-membership-mark-and-sweep branch from b9f830e to 0a1d96b Compare August 28, 2026 01:21
@matheus-vb
matheus-vb requested a review from haacked August 28, 2026 01:39
…sert

A person who stops matching an edited cohort gets no live transition, so
their in_cohort = true row survives forever. A reconcile run replays full
membership and certifies itself with per-partition markers; the sweep
deletes what the run did not re-assert.

Rows are stamped with the producer version, and the oldest version a run
asserted bounds the sweep threshold. Rows, marks, and consumer progress
commit in one transaction. Deletes wait until the snapshot minimum is
known and progress passes the marker-completion watermarks; progress also
refreshes from committed offsets so quiet partitions cannot stall the
gate.

Gated by COHORT_MEMBERSHIP_SWEEP_ENABLED, off by default. Version
persistence stays ungated: a mixed fleet writing NULLs would get current
members swept.
@matheus-vb
matheus-vb force-pushed the matheus-vb/cohort-membership-mark-and-sweep branch from 0a1d96b to 17feec1 Compare August 28, 2026 13:21
@trunk-io

trunk-io Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

😎 Stack merged successfully - details.

@trunk-io
trunk-io Bot merged commit 4abbbc6 into master Aug 28, 2026
220 checks passed
@github-project-automation github-project-automation Bot moved this from In Review to Done in Feature Flags Aug 28, 2026
@trunk-io
trunk-io Bot deleted the matheus-vb/cohort-membership-mark-and-sweep branch August 28, 2026 14:53
@deployment-status-posthog

deployment-status-posthog Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-08-28 15:21 UTC Run
prod-us ✅ Deployed 2026-08-28 15:43 UTC Run
prod-eu ✅ Deployed 2026-08-28 15:40 UTC Run

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

Round four, and it all landed. Nothing blocking; everything inline is follow-up now that this is merged.

topic: KAFKA_COHORT_MEMBERSHIP_CHANGED,
})

if (config.COHORT_MEMBERSHIP_SWEEP_ENABLED && !config.COHORT_MEMBERSHIP_VERSION_WRITES_ENABLED) {

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.

suggestion: The PR description says COHORT_MEMBERSHIP_SWEEP_ENABLED gates the version stamps, the snapshot marks, and the progress rows. The merged code actually gates those with COHORT_MEMBERSHIP_VERSION_WRITES_ENABLED instead: the dedupe rule at cdp-cohort-membership.consumer.ts:301, the write shape at :316, and the progress rows at :548. An operator following the outdated description sets COHORT_MEMBERSHIP_SWEEP_ENABLED on its own, but the constructor throws when sweeping is on without version writes (cdp-cohort-membership.consumer.ts:166-173), so the pod crashes at startup (server.ts:376) and membership ingestion stops.

The ordering rule has nowhere else to live besides the PR description and the comment at config.ts:189-192. Fix the second bullet in the PR description to this:

  • Two flags, both defaulting to false. COHORT_MEMBERSHIP_VERSION_WRITES_ENABLED gates the version stamps, the snapshot marks and the progress rows; with it off the consumer keeps the exact pre-sweep write shape, so the migrations gate that flip rather than the deploy. COHORT_MEMBERSHIP_SWEEP_ENABLED gates the marker consumer and the sweep, and a pod refuses to start when it is set without version writes. Turn version writes on first and off last, and sweeping on last and off first: a pod writing without versions leaves a re-asserted row carrying its old stamp, which a sweeping pod reads as deletable.

private async readConsumerProgress(): Promise<Map<number, number>> {
const result = await this.postgres.query<{ partition: number; next_offset: string }>(
PostgresUse.BEHAVIORAL_COHORTS_RW,
'SELECT partition, next_offset FROM cohort_membership_consumer_progress WHERE cluster = $1 AND topic = $2',

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.

suggestion: The test doesn't check that the cluster and topic filters work, so a topic change on the same brokers could open the gate on the old topic's offsets. readConsumerProgress filters progress rows by cluster and topic, but every progress row in the suite gets inserted with the same CLUSTER and TOPIC: insertProgress at sweeper.service.serial.test.ts:128 hardcodes both. Dropping either filter changes no assertion.

The rows it would then pick up are real. collectGarbage only removes progress rows after 30 days, so after the processor's output topic flips from the shadow name to the live one, the old topic's much larger offsets sit in the table for a month while a fresh run captured on the new topic passes the identity check at sweeper.service.ts:462. The gate would see progress past every watermark and delete rows whose re-asserting snapshot messages are still queued.

Add cluster and topic parameters to insertProgress, then seed off-feed rows for partitions 0 and 3 at offsets past the watermarks before any in-feed row exists in should hold the sweep until consumer progress passes every captured watermark (sweeper.service.serial.test.ts:210). If the filter breaks, the test catches it: blocked: 1 becomes swept: 1 rather than racing on Map insertion order.

}

/** Returns the deleted row count, or null when the run was refused rather than swept. */
private async sweep(run: ClaimableRun): Promise<number | null> {

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.

suggestion: A reconcile run whose snapshot minimum collapsed to -infinity reaches abandonThresholdless, whose comment says it is unreachable, and it gets logged as an error with sweepsAbandoned{reason='no_threshold'}.

collectSnapshotMarks folds VERSIONLESS into min_snapshot_version when a reconcile change arrives without a stamp (cdp-cohort-membership.consumer.ts:127), and logs "collapsing the run threshold", so the design expects the run to sweep and delete nothing. The run is claimable because -infinity is not NULL and that is all claimablePredicate requires. But the repo installs a TIMESTAMP parser at common/utils/db/postgres.ts:37-39 that runs the value through Luxon's fromSQL, which cannot parse -infinity and returns null, so LEAST(...) AS threshold at sweeper.service.ts:115 comes back null and sweep() takes the !run.threshold branch at :527.

Nothing is deleted either way, so the data outcome is safe. What is wrong is the reporting: an operator watching sweepsAbandoned{reason='no_threshold'} and a logger.error sees what the comment tells them is impossible, and the run lands in abandoned rather than the swept state the collapse was designed to produce. The same parser was already worked around for the version column, via the COALESCE(to_char(...)) in the delete's RETURNING at sweeper.service.ts:609-611; the threshold projection has the same exposure and no such handling.

Add a sweeper case with min_snapshot_version = '-infinity' that asserts zero deletions and the terminal state the run actually reaches, plus a correction to the abandonThresholdless comment.

return this.rdKafkaConsumer.isConnected() ? this.rdKafkaConsumer.assignments() : []
}

public async queryWatermarkOffsets(topic: string, partition: number, timeout = 10000): Promise<[number, number]> {

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.

suggestion: The two Kafka consumer implementations now carry the same three metadata reads copied line for line, so a fix applied to one leaves the other wrong and nothing catches it. queryWatermarkOffsets, committedOffsets, and getPartitionsForTopic at consumer-v2.ts:212-250 are byte-identical to the versions at consumer-v1.ts:365-403, down to the Not connected throw, the paired captureException and logger.error calls, and the 10000 ms default timeout. The only difference is the parameter name in the meta.topics.find callback. Before this change the only method duplicated verbatim across the two classes was the three-line assignments.

Every method added to the interface at common/kafka/consumer/index.ts:25-26 has to be written out in both classes, and this change adds three at once. The second copy is not short-lived either, because CONSUMER_USE_V2 defaults to false and v1 is the path that actually runs.

None of the three touches anything implementation-specific; each takes the raw RdKafkaConsumer and calls straight through it. Extract them to a shared module beside metrics.ts:

// common/kafka/consumer/metadata.ts
export async function readCommittedOffsets(
    consumer: RdKafkaConsumer,
    timeout: number
): Promise<TopicPartitionOffset[]> {
    if (!consumer.isConnected()) {
        return []
    }
    return await promisifyCallback<TopicPartitionOffset[]>((cb) => consumer.committed(timeout, cb))
}

Each class's method then collapses to return await readCommittedOffsets(this.rdKafkaConsumer, timeout).


expect(await readSweep()).toMatchObject({ status: 'collecting', membership_hwms: null })

// Redelivery must be a no-op, not an increment.

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.

suggestion: The assertion under "Redelivery must be a no-op, not an increment" cannot fail. The line above it has already applied partitions 0 through 62, so redelivering partition 5 touches a bit that is already set, and the assertion that follows only repeats the status: 'collecting' that was asserted four lines earlier.

Both fold sites are invisible to it. If the in-memory fold at sweeper.service.ts:257 added instead of OR-ing, 32 + 32 would land on bit 6, which is already set. If the SQL fold at sweeper.service.ts:278 added, the sum would overflow bigint, the per-entry catch at :289 would swallow the error, and the row would be unchanged. A fold that is not idempotent can carry into a bit no marker covers, which either strands the run forever or completes it without every partition's marker and lets the sweep delete rows for partitions still emitting.

Apply markerMessage(COHORT_PARTITION_COUNT - 1) twice in one batch and assert marker_bits: '-1'. If the fold logic breaks, the test catches it instead of silently passing.

// version writes off there is no SQL guard to agree with, so the last message in
// Kafka order wins and the deploy stays a behavioral no-op until the flag flips.
const versionAware = this.config.COHORT_MEMBERSHIP_VERSION_WRITES_ENABLED
const deduped = new Map<string, CohortMembershipChange>()

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.

suggestion: The batch dedupe rule can only be tested through Postgres, so the four it.each cases covering it each spend a database round trip on what is pure in-memory logic. The pick sits inline in persistCohortMembershipChanges at cdp-cohort-membership.consumer.ts:302-314, while the file's other two batch folds, collectSnapshotMarks and partitionProgressFromMessages, are module-level pure functions of the same shape.

Moving it out matches them and gives the four-way condition a name, a signature, and a home for the six-line comment that currently explains it:

/** … the existing comment moves here … */
function dedupeChanges(
    changes: CohortMembershipChange[],
    versionAware: boolean
): CohortMembershipChange[] {

The call site becomes const deduped = dedupeChanges(changes, versionAware), and the check on the next line can then use the same versionAware local instead of re-reading this.config.COHORT_MEMBERSHIP_VERSION_WRITES_ENABLED at :316.

deps
)
teamId = Number.parseInt(new UUIDT().toString().replaceAll('-', '').slice(-7), 16)
// Progress rows key on (cluster, partition) and nothing truncates between tests, so a

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.

nit: This comment says progress rows key on (cluster, partition), but the primary key is (cluster, topic, partition) (20260826000002_create_sweep_tables.sql:77, and the ON CONFLICT at cdp-cohort-membership.consumer.ts:469). Add topic to the comment so the next person adjusting test isolation reads the real key.

// regardless of this value — so steady-state recovery is committed-offset resume; latest only governs
// the first bootstrap and offset-loss edge cases (covered by the deferred lag alerting follow-up).
const startAtLatest = { ['auto.offset.reset' as keyof RdKafkaConsumerConfig]: 'latest' as never }
const startAtLatest = START_AT_LATEST

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.

nit: const startAtLatest = START_AT_LATEST is a second name for the imported value that changes nothing about it, and the four createKafkaConsumer calls at :179, :186, :193, and :200 still pass the alias, so a reader chasing the argument lands here before reaching the export. Pass START_AT_LATEST at those four sites and delete this line, keeping the comment above it since it explains why this consumer starts at the tip.

(this.consumerConfig['auto.offset.reset' as keyof ConsumerGlobalConfig] as
| 'earliest'
| 'latest'
| undefined) ?? 'earliest'

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.

nit: This hardcodes 'earliest' while v2 reads the same value from DEFAULT_AUTO_OFFSET_RESET (consumer-v2.ts:47), so changing one leaves the two implementations disagreeing about where a consumer starts. Move that constant to index.ts and have both createConsumer bodies read it.

expect(result.rows).toHaveLength(1)
expect(result.rows[0].in_cohort).toBe(false)
})
it.each([

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.

nit: The only equal-stamp case in the dedupe table runs with version writes off, so nothing asserts that a later message with an identical stamp still wins on the version-writes path. Add one more row in the table: flag on, two equal stamps, last in Kafka order wins; changing >= to > at cdp-cohort-membership.consumer.ts:945 would make the earlier message win instead, which the test would catch.

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

Labels

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

Build mark and sweep deletion in the CDP cohort membership consumer

3 participants