Prevent orphaned cohort version sets when a refresh fails mid-flight#39
Prevent orphaned cohort version sets when a refresh fails mid-flight#39adrianliu0815 wants to merge 1 commit into
Conversation
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>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
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) |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 26f1232. Configure here.
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>
|
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 Requested change: arm each temp diff key's backstop TTL immediately after its own SDIFFSTORE rather than after both. As written, 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 |
…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>
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>
…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>


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 (theEXPIREat the end ofcomplete()). 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 GETKEYSINSLOTcensus). 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
EXPIREof the previous version ran in afinallyblock, 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
addMembers), andPERSISTit at promotion (just before the description is published incomplete()). An aborted refresh now leaves nothing behind once the TTL fires. On the retry path Amplitude returns the samelastModified, so the retry re-uses (and re-arms) the same key — no interference.SDIFFSTOREs, in case the process dies before thefinallyDELs run.finallyblock. 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), mirroringexpire().Behavior notes
ttl(cohort sync interval) after a successful update; temp keys still deleted infinally.PERSIST/EXPIREon the pending set are O(1) and once-per-refresh — no measurable overhead.Testing
./gradlew checkgreen.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 afterSDIFFSTORE. On successful promotion,PERSISTclears 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
finallyblock 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.