Skip to content

Prevent orphaned cohort version sets when a refresh fails mid-flight#39

Open
adrianliu0815 wants to merge 1 commit into
amplitude:mainfrom
adrianliu0815:version-set-ttl
Open

Prevent orphaned cohort version sets when a refresh fails mid-flight#39
adrianliu0815 wants to merge 1 commit into
amplitude:mainfrom
adrianliu0815:version-set-ttl

Conversation

@adrianliu0815

@adrianliu0815 adrianliu0815 commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Problem

A cohort version's member set (...:cohort:{id}:User:{lastModified}) is written with no TTL, and only receives one when a successor refresh completes successfully (the EXPIRE at the end of complete()). Any refresh that dies mid-flight — Redis command timeout, pod restart, CohortTooLargeException — strands the full member set in Redis forever.

For multi-million-member cohorts this leaks ~0.5 GB per failed refresh, and the cohort hash tag pins every leaked copy to the same cluster slot. In one production cluster we found 58 orphaned version sets (~25 GB) for a single 9.8M-member cohort, accumulated over two months of intermittent refresh failures (CLUSTER GETKEYSINSLOT census). The effect is self-compounding: large-cohort refreshes are the slowest and most timeout-prone, so the leak concentrates on exactly the shards already carrying the largest sets.

There is a second, related defect: the EXPIRE of the previous version ran in a finally block, so a refresh that failed mid-membership-update expired the old — still published — version set. Reads of the live cohort then break (Cohort description found, but members missing) until the next successful refresh.

Fix

  1. Pending version sets self-clean. Apply a 24h TTL to the new version's member set as soon as ingestion starts writing it (addMembers), and PERSIST it at promotion (just before the description is published in complete()). An aborted refresh now leaves nothing behind once the TTL fires. On the retry path Amplitude returns the same lastModified, so the retry re-uses (and re-arms) the same key — no interference.
  2. Temp diff keys get the same backstop TTL immediately after the two SDIFFSTOREs, in case the process dies before the finally DELs run.
  3. Retire the previous version only after successful promotion, instead of in the finally block. A failed refresh now leaves the published version untouched; the success path expires it exactly as before.

Redis.persist() is added to the interface and both connection implementations (plain + cluster), mirroring expire().

Behavior notes

  • Success-path behavior is unchanged: previous version set + blob still get the same ttl (cohort sync interval) after a successful update; temp keys still deleted in finally.
  • PERSIST/EXPIRE on the pending set are O(1) and once-per-refresh — no measurable overhead.
  • 24h for the pending TTL is deliberately far above any plausible download+diff duration (the loading lock itself is 900s).

Testing

  • ./gradlew check green.
  • New tests: pending version set carries a TTL during ingestion and loses it on promotion; an unpromoted (failed) refresh leaves the published version un-expired and readable while the stranded pending set keeps its self-clean TTL.

Relationship to #38

Independent and complementary: #38 removes the blocking SDIFFSTORE (the main cause of failed refreshes on giant cohorts); this PR makes any remaining failure mode unable to leak storage or expire the live version.

🤖 Generated with Claude Code


Note

Medium Risk
Changes cohort refresh promotion and Redis key lifecycle on a critical read path; behavior on success is intended to match prior semantics, but mis-timed TTL/PERSIST could affect live cohort availability.

Overview
Fixes Redis leaks and broken reads when a cohort refresh aborts mid-flight (timeouts, restarts, etc.).

In-flight versions self-clean: a 24h TTL is set on the new version’s member set as soon as ingestion writes (addMembers), and on added/removed diff temp keys after SDIFFSTORE. On successful promotion, PERSIST clears the TTL on the new member set before the description is published.

Live cohort stays readable on failure: expiring the previous version’s member set and blob is moved out of the finally block and runs only after successful promotion—so a failed refresh no longer expires the still-published version.

Adds Redis.persist() (interface, cluster, and standalone) plus tests for pending TTL, promotion, and aborted refresh behavior.

Reviewed by Cursor Bugbot for commit 26f1232. Bugbot is set up for automated code reviews on this repo. Configure here.

A cohort version's member set is written to Redis with no TTL, and only
receives one when a *successor* refresh completes successfully. Any
refresh that dies mid-flight (Redis command timeout, pod restart,
CohortTooLargeException) therefore strands the full member set in Redis
forever. For multi-million-member cohorts this leaks ~0.5 GB per failed
refresh, all pinned to the same cluster slot by the cohort hash tag. In
one production cluster we found 58 orphaned version sets (~25 GB) for a
single 9.8M-member cohort, accumulated over two months of intermittent
refresh failures.

Fix, in three parts:

- Pending version sets self-clean: apply a 24h TTL to the new version's
  member set as soon as ingestion starts writing it, and PERSIST it when
  the version is promoted (description published). An aborted refresh now
  leaves nothing behind once the TTL fires.
- Temp diff keys get the same backstop TTL right after SDIFFSTORE, in
  case the process dies before the finally-block DELs run.
- Retire the previous version only after successful promotion. Previously
  the EXPIRE of the old (still published) version set ran in a finally
  block, so a refresh that failed mid-update expired the *live* version,
  breaking cohort reads until the next successful refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.

Reviewed by Cursor Bugbot for commit 26f1232. Configure here.

// Promote this version: clear the pending TTL so the now-current member set
// does not expire, then publish the cohort description (only after successful
// blob store).
redis.persist(newCohortKey)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Persist clears TTL before publish

Medium Severity

complete() calls redis.persist(newCohortKey) before the cohort description is written with hset. If that refresh aborts after persist but before hset, the pending member set loses the 24h PENDING_VERSION_TTL from ingestion while the live description still points at the previous version, so the partial set can remain in Redis indefinitely instead of self-cleaning.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 26f1232. Configure here.

vaibhav-jain-exp added a commit that referenced this pull request Jul 21, 2026
Cursor bugbot on PR #39 flagged that persist(newCohortKey) ran before
the description hset: a refresh dying between the two left a persisted
(no-TTL) member set with the old description still published -- the
pending TTL's self-clean guarantee was defeated exactly one command
before it mattered. (In practice the next retry re-arms the TTL on the
same key, so the leak only stuck when the upstream version was
superseded first -- but the window was real.)

Simply swapping the order would trade that leak for something worse: a
crash after hset but before persist would leave the LIVE version with a
24h fuse and no code path to clear it (the next sync short-circuits on
lastModified), breaking reads a day later.

So this does both halves:
- complete() now publishes first and persists immediately after, so a
  refresh that dies anywhere before publish always leaves a set that
  self-cleans via its pending TTL.
- CohortLoader clears any TTL found on the published version's member
  set on every not-modified sync cycle (new O(1)
  CohortStorage.ensureCurrentVersionPersisted), repairing the
  one-command publish->persist crash window within one sync interval,
  instead of never. This also defuses the stray-fuse race where a
  lock-breaching concurrent writer re-arms the TTL on an
  already-promoted set.

New tests: a fault-injected hset failure proves the pending TTL
survives a crash at publish; ensureCurrentVersionPersisted clears a
stray TTL on the published version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vaibhav-jain-exp

vaibhav-jain-exp commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Thanks — this is a real problem well diagnosed (we've seen the orphaned version sets too), and the lifecycle design is sound: arming the TTL at first write, PERSIST at promotion, and retiring the previous version only after publish is exactly the right shape. The prev.lastModified != description.lastModified guard on the retire block also quietly fixes a latent bug where an unchanged-version re-complete could expire the live members set. Happy to merge this with one change:

Requested change: arm each temp diff key's backstop TTL immediately after its own SDIFFSTORE rather than after both. As written, addedKey has no TTL for the entire duration of the second SDIFFSTORE — itself a multi-second blocking command on large cohorts and the likeliest point for the process to die — which strands a potentially full-cohort-sized key, i.e. exactly the leak this PR fixes. The reorder is safe: SDIFFSTORE clears its destination's TTL, and addedKey isn't a source of the second SDIFFSTORE. Ready-made commit you can cherry-pick: f5e7acf on pr39-backstop-per-sdiffstore.

On the Cursor bugbot comment (persist-before-hset): the window is real, but please don't fix it by swapping the order alone — a crash after hset but before persist would then leave the live, published version with a 24h fuse and no code path that ever clears it (the next sync short-circuits on lastModified), which trades a rare self-healing leak for a rare unrecoverable read breakage. The full fix needs both halves: publish-then-persist plus a repair that re-PERSISTs the published version's members key on the not-modified sync path. We've implemented that (with a fault-injection test for the crash-at-publish window) in #40, which incorporates this PR — so feel free to leave bugbot's finding to #40 rather than grow this PR's scope.

@vaibhav-jain-exp

vaibhav-jain-exp commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

We've applied the backstop-ordering tweak on our side: #41 carries this PR's commit unchanged plus that one fix, targeting main. Once #41 merges, GitHub will mark this PR as merged automatically — nothing needed from you. Thanks again!

vaibhav-jain-exp added a commit that referenced this pull request Jul 21, 2026
…top-ordering fix) (#41)

* Prevent orphaned cohort version sets on failed refreshes

A cohort version's member set is written to Redis with no TTL, and only
receives one when a *successor* refresh completes successfully. Any
refresh that dies mid-flight (Redis command timeout, pod restart,
CohortTooLargeException) therefore strands the full member set in Redis
forever. For multi-million-member cohorts this leaks ~0.5 GB per failed
refresh, all pinned to the same cluster slot by the cohort hash tag. In
one production cluster we found 58 orphaned version sets (~25 GB) for a
single 9.8M-member cohort, accumulated over two months of intermittent
refresh failures.

Fix, in three parts:

- Pending version sets self-clean: apply a 24h TTL to the new version's
  member set as soon as ingestion starts writing it, and PERSIST it when
  the version is promoted (description published). An aborted refresh now
  leaves nothing behind once the TTL fires.
- Temp diff keys get the same backstop TTL right after SDIFFSTORE, in
  case the process dies before the finally-block DELs run.
- Retire the previous version only after successful promotion. Previously
  the EXPIRE of the old (still published) version set ran in a finally
  block, so a refresh that failed mid-update expired the *live* version,
  breaking cohort reads until the next successful refresh.

* Arm each temp diff key's backstop TTL immediately after its SDIFFSTORE

The backstop TTLs were applied only after both SDIFFSTOREs completed,
leaving addedKey unprotected for the full duration of the second
SDIFFSTORE -- itself a multi-second blocking command on large cohorts
and the likeliest point for the process to die or the command to time
out. A crash there stranded a potentially full-cohort-sized,
timestamp-named addedKey with no TTL, the exact leak this change set
prevents.

Safe reordering: SDIFFSTORE clears any TTL on its destination key, and
addedKey is not a source of the second SDIFFSTORE, so arming its TTL
between the two commands cannot be clobbered or affect the diff.

---------

Co-authored-by: Adrian Liu <adrianliu@squareup.com>
vaibhav-jain-exp added a commit that referenced this pull request Jul 21, 2026
Content-neutral merge: this branch already contained #39's changes
(cherry-picked) and the backstop-ordering fix, so every conflict
resolved to the branch side; the tree is identical to the pre-merge
commit. Verified: full test suite and ktlint green, zero content diff
vs 7632c55.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
vaibhav-jain-exp added a commit that referenced this pull request Jul 22, 2026
…s (supersedes #38, includes #39) (#40)

* Replace SDIFFSTORE cohort diff with streamed client-side diff

A set difference over the full old/new member sets executes as a single
blocking command on one single-threaded shard - multi-second for
multi-million member cohorts - stalling every concurrent command on that
shard for its whole duration (replicas included, since they re-execute
the replicated command). At 10M members the diff reliably exceeds the
default 10s command timeout, failing the refresh outright.

Stream both sets via SSCAN in bounded chunks instead and diff
client-side, hash-partitioned to cap proxy memory, so the largest single
Redis command issued during a cohort update is one SSCAN page regardless
of cohort size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Prevent orphaned cohort version sets on failed refreshes

A cohort version's member set is written to Redis with no TTL, and only
receives one when a *successor* refresh completes successfully. Any
refresh that dies mid-flight (Redis command timeout, pod restart,
CohortTooLargeException) therefore strands the full member set in Redis
forever. For multi-million-member cohorts this leaks ~0.5 GB per failed
refresh, all pinned to the same cluster slot by the cohort hash tag. In
one production cluster we found 58 orphaned version sets (~25 GB) for a
single 9.8M-member cohort, accumulated over two months of intermittent
refresh failures.

Fix, in three parts:

- Pending version sets self-clean: apply a 24h TTL to the new version's
  member set as soon as ingestion starts writing it, and PERSIST it when
  the version is promoted (description published). An aborted refresh now
  leaves nothing behind once the TTL fires.
- Temp diff keys get the same backstop TTL right after SDIFFSTORE, in
  case the process dies before the finally-block DELs run.
- Retire the previous version only after successful promotion. Previously
  the EXPIRE of the old (still published) version set ran in a finally
  block, so a refresh that failed mid-update expired the *live* version,
  breaking cohort reads until the next successful refresh.

* Gate streamed cohort diff behind a flag; harden it for very large cohorts

The streamed client-side diff is now opt-in via
AMPLITUDE_COHORT_STREAMED_DIFF_ENABLED (default false); the server-side
SDIFFSTORE diff remains the default path with a command stream identical
to before, plus the pending-version backstop TTLs from the previous
commit armed immediately after each SDIFFSTORE (not after both, so
addedKey is not left unprotected through the second, multi-second
blocking command). Two knobs are exposed (and validated > 0 at startup)
for tuning at 10M+ member scale, where the 2M default partition size
costs one full SSCAN pass over both member sets per partition:
AMPLITUDE_COHORT_DIFF_PARTITION_MAX_MEMBERS and
AMPLITUDE_COHORT_DIFF_SCAN_CHUNK_SIZE.

Hardening for the streamed path, whose diffs can run for minutes on
multi-million member cohorts:

- Renew the cohort-loading lock every 512 scan pages/flushes so a diff
  outlasting the lock TTL (900s) doesn't let a second instance start a
  concurrent load of the same cohort; abort the refresh (retried next
  sync cycle) if renewal fails rather than continue unlocked. New
  Redis.renewLock() uses an atomic compare-and-expire mirroring
  releaseLock().
- Cross-check each scan's raw returned count against SCARD and abort
  the refresh on a shortfall: SSCAN silently skips members when its
  cursor is invalidated mid-scan (cluster failover/slot migration), and
  a truncated scan here becomes false removals of unchanged users that
  no later diff repairs. A distinct-count check across partitions
  additionally catches duplicate-masked skips on the existing set.
- If the existing members key is missing at diff time, apply all new
  members as additions with no removals -- SDIFFSTORE semantics for
  that state -- under the same scan guard, and log an error instead of
  silently publishing.

Also fixed in passing: lock values in RedisConnection and
RedisClusterConnection were built with ${'$'} escapes, producing the
same literal string on every instance and making releaseLock's
compare-and-delete vacuous across pods.

* docs: applyMembershipDiff is the opt-in alternative, not a replacement

The kdoc predated the flag gating and still described the streamed diff
as having replaced SDIFFSTORE; with the flag off (the default) the
SDIFFSTORE path is what runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Publish before persist, and self-heal a stray TTL on the live version

Cursor bugbot on PR #39 flagged that persist(newCohortKey) ran before
the description hset: a refresh dying between the two left a persisted
(no-TTL) member set with the old description still published -- the
pending TTL's self-clean guarantee was defeated exactly one command
before it mattered. (In practice the next retry re-arms the TTL on the
same key, so the leak only stuck when the upstream version was
superseded first -- but the window was real.)

Simply swapping the order would trade that leak for something worse: a
crash after hset but before persist would leave the LIVE version with a
24h fuse and no code path to clear it (the next sync short-circuits on
lastModified), breaking reads a day later.

So this does both halves:
- complete() now publishes first and persists immediately after, so a
  refresh that dies anywhere before publish always leaves a set that
  self-cleans via its pending TTL.
- CohortLoader clears any TTL found on the published version's member
  set on every not-modified sync cycle (new O(1)
  CohortStorage.ensureCurrentVersionPersisted), repairing the
  one-command publish->persist crash window within one sync interval,
  instead of never. This also defuses the stray-fuse race where a
  lock-breaching concurrent writer re-arms the TTL on an
  already-promoted set.

New tests: a fault-injected hset failure proves the pending TTL
survives a crash at publish; ensureCurrentVersionPersisted clears a
stray TTL on the published version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Derive diff partition count from SCARD, not description sizes

Cursor bugbot on #40: partition count used the description-reported
sizes (prev.size / finalSize) while the scan guards two lines above
already fetch the authoritative SCARD cardinalities. A crashed ingest
can leave a version key holding more members than its published size;
partitioning by the smaller number lets a single in-memory partition
exceed diffPartitionMaxMembers -- the bound partitioning exists to
enforce.

Partition count now comes from max(existingCard, newCard). The newSize
parameter became unused and is removed. New test publishes a version
whose description size understates the key's cardinality and verifies
diff behavior (removals applied, retained/added memberships correct,
residue members untouched -- matching SDIFFSTORE semantics).

---------

Co-authored-by: Adrian Liu <adrianliu@squareup.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants