Replace SDIFFSTORE cohort diff with streamed client-side diff#38
Replace SDIFFSTORE cohort diff with streamed client-side diff#38adrianliu0815 wants to merge 1 commit into
Conversation
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>
kyeh-amp
left a comment
There was a problem hiding this comment.
Thanks for the excellent writeup and fix — the diagnosis is consistent with the code, and the approach (never issue an O(cohort-size) Redis command on the refresh path) is the right shape. Approving with a few non-blocking suggestions.
Independently verified: :core:test passes on this branch including the new partition test, and the cluster analysis checks out — RedisKey.CohortMembers/CohortTemporary share the hash tag {projects:<pid>:cohort:<cid>} (RedisKey.kt), which is what SDIFFSTORE requires in cluster mode and exactly why the whole diff lands on one shard while UserCohortMemberships reads (no hash tag) spread across shards and stall when routed to it.
Correctness
Traced the algorithm, no bugs found:
- Partition cover is complete and disjoint.
partitionOf(deterministicString.hashCode, correct negative handling) is applied identically to both scans, so per partition: old∩p loaded into a HashSet, new∩p drained from it — scan leftovers are exactly the adds, HashSet leftovers exactly the removals. The ceiling math is right, including thefinalSize >= 1guarantee at the call site. - Immutability assumption holds. Both keys are versioned by
lastModified, the new set is fully written beforecomplete(), and concurrent loads are excluded byCohortLoadingLock. Scanning from the primary to avoid replica-lag misclassification on the just-written set is a good catch. Even a pathological SSCAN duplicate degrades to an idempotentSADDfor a both-versions member — membership state stays correct, only the logged count inflates. - Failure semantics are no worse than before. A mid-diff failure leaves partial
SADD/SREMs, but the description isn't published, so the next sync recomputes the identical diff and converges idempotently — same non-transactional window the old path had duringprocessMembershipUpdates. Strictly better in one respect: the old path could crash betweensdiffstoreanddeland leak TTL-lessCohortTemporarykeys; this path writes no temp keys at all. - Behavior parity preserved: the
finallyblock still expires the old members/blob keys, the diff log line is unchanged, and the new constructor param defaults on aninternalclass — no API surface change.
Suggestions (non-blocking)
- Yes to exposing
diffPartitionMaxMembersviaRedisConfiguration— taking you up on that offer. A 2M-memberHashSet<String>is roughly 200–400MB of heap depending on ID length; not out of line with what the proxy already materializes elsewhere (getCohortMembers), but heap headroom varies by deployment. - Partition count trusts description sizes rather than actual cardinality. They should match
SCARDin practice (published size is the real final count), but an O(1)SCARDon both keys would make the memory bound self-sufficient against a stale/incorrect description. Cheap hardening, optional. - Round-trip cost: a 10M cohort ⇒ 5 partitions × 2 sets × 10K SSCAN pages ≈ 100K sequential round trips per refresh (order of a minute at sub-ms RTT). Fine for a background loop and fully decoupled from serving, but a larger SSCAN
COUNTfor this path would cut round trips ~5x if refresh duration ever matters. - The memory bound is probabilistic — hash partitioning bounds the expected partition size; a skewed ID distribution could exceed it. Fine for real user IDs; at most worth a word in the doc comment.
- Follow-up cleanup (we'll take it):
Redis.sdiffstore(interface + both connection impls + test fake) andRedisKey.CohortTemporaryare now dead in main code. Allinternal, safe to remove in a separate PR.
Tests
The new test genuinely exercises the partitioned path (diffPartitionMaxMembers = 3 ⇒ 4 partitions over 10 members) and the existing update test covers the single-partition path. One caveat: tests run against InMemoryRedis, whose sscanChunked is a plain chunked iteration, so real SSCAN cursor semantics aren't exercised — equally true of the old path, and your staging validation against a real cluster covers that gap in practice. We'll plan to canary this before cutting a release.
|
Thanks for this — the diagnosis is right, and we were able to reproduce the shard-stall correlation math you described. Before putting it on a release path we needed a few things layered on top: an opt-in flag (default off, SDIFFSTORE stays the default), env-exposed partition/chunk knobs (at 40M members the hardcoded 2M/1000 defaults mean ~1.6M sequential SSCAN round trips per refresh), loading-lock renewal for diffs that outlast the 900s lock TTL, and SCARD-based scan-truncation guards (a cluster failover mid-SSCAN can silently skip members, which on this code path becomes permanent false removals). Rather than round-trip all of that through review comments, we've opened #40, which contains this PR's commits unchanged (you stay the author), your #39 cherry-picked on top, and the gating/hardening — see its description for the full list and rollout guidance for your 10-40M cohort scale. If #40 looks good to you, we'd close this PR in its favor — but please poke holes in it first; you have the production deployment this was built for. |
…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
RedisCohortStoragecomputes the added/removed members between two cohort versions with two server-sideSDIFFSTOREcalls over the full old/new member sets. A set difference is O(N) and Redis executes it as a single atomic command on a single-threaded shard, so for multi-million member cohorts the diff blocks that shard — and its replicas, which re-execute the replicated command — for the entire duration. Every concurrent command routed to that shard queues behind it, including theSMEMBERSreads that serve/sdk/v2/membershipsrequests.Observed in a production deployment running ~10M-member cohorts (Redis Cluster, 6 shards):
SDIFFSTOREp50 ~88ms, but p75 ~2s / p95 ~8s / p99 ~9.9s during periods when the large cohorts republish (hourly warehouse-import recomputes → ~2 diffs per version pickup).SMEMBERS(normally p99 ~1.4ms) showed multi-second tail stalls that correlate 1:1 with slowSDIFFSTOREexecutions — over 3 days at 10-minute resolution, every SMEMBERS stall bin had a concurrent slow SDIFFSTORE and every SDIFFSTORE >5s bin produced SMEMBERS stalls.Default.REDIS_COMMAND_TIMEOUT_MILLIS), so the refresh throws, the version never publishes, and the next sync cycle re-downloads and re-diffs — a retry loop that re-runs the ~10s shard block while the cohort stays stale. A client-side timeout cannot cancel the server-side execution, so tightening timeouts cannot mitigate this.Change
Compute the diff client-side from streamed data instead, so the largest single Redis command issued during a cohort update is one
SSCANpage regardless of cohort size:SSCANthe existing member set inREDIS_SCAN_CHUNK_SIZE(1000) chunks into memory, thenSSCANthe new set: members absent from the existing set are additions (flushed incrementally via the existing pipelinedSADDpath); members present in both are drained, so whatever remains afterward is exactly the set of removals (flushed via pipelinedSREM).ceil(size / diffPartitionMaxMembers)partitions (default 2M ⇒ at most ~2M member strings held at once, ~5 partitions for a 10M cohort) and diffed one partition at a time, at the cost of one extraSSCANpass over both sets per additional partition.complete(), the existing set is the previously published version); they are scanned from the primary connection to avoid misclassification from replica lag on the just-written new set.CohortTemporaryadded/removed staging keys are no longer written.Trade-offs: refresh wall-clock for very large cohorts increases (multiple sequential SSCAN passes instead of one blocking command) and the member data now transits to the proxy during diffing — but the refresh is a background loop, and in exchange serving latency is fully decoupled from cohort size and the diff can no longer exceed the command timeout.
Testing
diffPartitionMaxMembers = 3forces the partitioned path).CohortStorageTestupdate/delete/membership tests pass unchanged on the new path (:core:testgreen, ktlint clean).Happy to adjust the approach (e.g., gate behind a configuration flag, different partition sizing, or exposing
diffPartitionMaxMembersviaRedisConfiguration) if you'd prefer.🤖 Generated with Claude Code
Note
Medium Risk
Changes core cohort publish semantics and Redis load patterns for every version update; incorrect diff logic would corrupt user cohort memberships, though behavior is covered by existing and new tests.
Overview
Cohort version updates no longer run two full-set
SDIFFSTOREcalls (and temporary added/removed keys).RedisCohortStoragenow diffs old vs new member sets by streaming both cohort keys withSSCAN, computing adds/removals in the proxy, and applying them through the existing pipelinedSADD/SREMmembership paths.Large cohorts are handled in hash partitions (default cap ~2M members per partition via
diffPartitionMaxMembers) so only one partition of the prior version is held in memory at a time, at the cost of extraSSCANpasses. Diff scans use the primary Redis connection so replica lag does not misclassify members on the just-written new set.A new test forces a small partition size to verify adds and removals across the multi-partition code path.
Reviewed by Cursor Bugbot for commit b733a44. Bugbot is set up for automated code reviews on this repo. Configure here.