Skip to content

Replace SDIFFSTORE cohort diff with streamed client-side diff#38

Open
adrianliu0815 wants to merge 1 commit into
amplitude:mainfrom
adrianliu0815:streamed-cohort-diff
Open

Replace SDIFFSTORE cohort diff with streamed client-side diff#38
adrianliu0815 wants to merge 1 commit into
amplitude:mainfrom
adrianliu0815:streamed-cohort-diff

Conversation

@adrianliu0815

@adrianliu0815 adrianliu0815 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Problem

RedisCohortStorage computes the added/removed members between two cohort versions with two server-side SDIFFSTORE calls 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 the SMEMBERS reads that serve /sdk/v2/memberships requests.

Observed in a production deployment running ~10M-member cohorts (Redis Cluster, 6 shards):

  • SDIFFSTORE p50 ~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).
  • Request-path SMEMBERS (normally p99 ~1.4ms) showed multi-second tail stalls that correlate 1:1 with slow SDIFFSTORE executions — over 3 days at 10-minute resolution, every SMEMBERS stall bin had a concurrent slow SDIFFSTORE and every SDIFFSTORE >5s bin produced SMEMBERS stalls.
  • At ~10M members the diff reliably crosses the default 10s Lettuce command timeout (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 SSCAN page regardless of cohort size:

  • SSCAN the existing member set in REDIS_SCAN_CHUNK_SIZE (1000) chunks into memory, then SSCAN the new set: members absent from the existing set are additions (flushed incrementally via the existing pipelined SADD path); members present in both are drained, so whatever remains afterward is exactly the set of removals (flushed via pipelined SREM).
  • To bound proxy memory, members are hash-partitioned into 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 extra SSCAN pass over both sets per additional partition.
  • Both keys are immutable at diff time (the new set is fully written before 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.
  • The CohortTemporary added/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

  • New test exercising a version update with adds and removals across multiple hash partitions (diffPartitionMaxMembers = 3 forces the partitioned path).
  • Existing CohortStorageTest update/delete/membership tests pass unchanged on the new path (:core:test green, ktlint clean).

Happy to adjust the approach (e.g., gate behind a configuration flag, different partition sizing, or exposing diffPartitionMaxMembers via RedisConfiguration) 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 SDIFFSTORE calls (and temporary added/removed keys). RedisCohortStorage now diffs old vs new member sets by streaming both cohort keys with SSCAN, computing adds/removals in the proxy, and applying them through the existing pipelined SADD / SREM membership 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 extra SSCAN passes. 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.

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 kyeh-amp 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.

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 (deterministic String.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 the finalSize >= 1 guarantee at the call site.
  • Immutability assumption holds. Both keys are versioned by lastModified, the new set is fully written before complete(), and concurrent loads are excluded by CohortLoadingLock. 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 idempotent SADD for 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 during processMembershipUpdates. Strictly better in one respect: the old path could crash between sdiffstore and del and leak TTL-less CohortTemporary keys; this path writes no temp keys at all.
  • Behavior parity preserved: the finally block still expires the old members/blob keys, the diff log line is unchanged, and the new constructor param defaults on an internal class — no API surface change.

Suggestions (non-blocking)

  1. Yes to exposing diffPartitionMaxMembers via RedisConfiguration — taking you up on that offer. A 2M-member HashSet<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.
  2. Partition count trusts description sizes rather than actual cardinality. They should match SCARD in practice (published size is the real final count), but an O(1) SCARD on both keys would make the memory bound self-sufficient against a stale/incorrect description. Cheap hardening, optional.
  3. 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 COUNT for this path would cut round trips ~5x if refresh duration ever matters.
  4. 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.
  5. Follow-up cleanup (we'll take it): Redis.sdiffstore (interface + both connection impls + test fake) and RedisKey.CohortTemporary are now dead in main code. All internal, 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.

@vaibhav-jain-exp

vaibhav-jain-exp commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

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.

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.

3 participants