ValkeyCacheService: batch getIdentifiables instead of one round trip per key (streak-valkey-9) - #6
Open
frew wants to merge 2 commits into
Open
Conversation
…per key getIdentifiables looped over the keys awaiting each GET before issuing the next, so an N-key batch cost N serial round trips (up to 3N when the keys were cold, since each miss also awaited its SET NX bootstrap and re-read). This is Objectify's entity-load read path, so every batch load serialized against Valkey while holding the calling thread. Fire each phase concurrently instead, the way getAll/putAll/putIfUntouched already do: all GETs, then the SET NX bootstraps for whatever missed, then the re-reads. A batch now costs at most three round trips regardless of size. Keys whose value is still absent after the bootstrap are left out of the returned map rather than mapped to null. EntityMemcache reads the result with get(key) and cannot tell the two apart, but callers sizing the map can: the old code returned an entry for every requested key, which made a returned-size hit/miss counter read 100% hits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M52d6uXfFJ3MGVMDz5fcKG
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M52d6uXfFJ3MGVMDz5fcKG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Sentry STREAK-31N3 — 528k events / 34k users since 2026-07-07, still firing. Every event is an Objectify entity load failing against Valkey and falling back to Datastore.
The Valkey cluster is not the bottleneck. Memorystore
streak-valkeyserves ~175k ops/s at 2.5 µs mean command time (total_usec_count / total_calls_count) with CPU at 20%. The timeouts are client-side, and valkey-glide says so directly:phase=Queued— the GET never left the process. It spent its entire 4s budget in the client's local command queue, with only 9–27 commands actually in flight behind 61 queued.getIdentifiablesis a plausible source of that queue pressure, and it's the one thing here we control:Each key is a blocking round trip taken one at a time. A cold key costs three (GET, SET NX, GET). This is the read path
EntityMemcache.getAllcalls for every entity load, so an N-key batch serialized N (up to 3N) round trips while holding the calling thread — a Jetty request thread, in the web tier.getAll,putAll, andputIfUntouchedright below it already fan out. The class javadoc even claims this method does too:getIdentifiablesjust never got that treatment.What
Three concurrent phases instead of a serial loop:
SET NXsentinel bootstrap for whatever missed, await those.A batch now costs at most three round trips regardless of size, down from N–3N. No change to the wire format, the sentinel/CAS contract, or
CROSSSLOTsafety — every command is still single-key.Insertion order of the returned map is preserved (
rawis aLinkedHashMapseeded inkeysorder and overwritten in place), so a mixed warm/cold batch comes back in the order asked for.Behavior change worth flagging
Keys still absent after the bootstrap are now omitted from the returned map rather than mapped to
null.EntityMemcachereads the result withcasValues.get(key)and cannot distinguish the two, so its behavior is identical. But a caller sizing the map can. The old code didresult.put(key, getIdentifiable(key))for every key, soresult.size() == keys.size()unconditionally — which makes MailFoo'sValkeyObjectifyCachehit/miss counters:metrics.markIdentifiablesHit(result.size.toLong()) metrics.markIdentifiablesMiss((keys.size - result.size).toLong())report 100% hits and 0 misses, always. After this change those counters mean what their KDoc says ("a key is a hit if the backend returned an entry for it"). Expect the identifiables hit-rate metric to drop off 100% when this deploys — that's the metric starting to work, not a regression.
Testing
getIdentifiablesMixesWarmAndColdKeysInOneBatch— a batch spanning a warm key, a cold key, and a key stomped after the snapshot. Asserts returned order, that each key's value decodes correctly, and that all three snapshots remain valid CAS bases with only the stomped one losing. This is the case the serial version handled trivially and the phased version actually has to get right.getIdentifiablesOnEmptyBatchReturnsEmpty— the newkeys.isEmpty()short circuit.ValkeyCacheServiceTestscover the sentinel, CAS winners/losers, TTL, and the multi-threaded CAS race.All 35 Valkey tests pass locally against
valkey/valkey:9.0(Docker Desktop, server API 1.53):Note for anyone else running these on a recent Docker Desktop: the daemon rejects any API version below 1.44 with an HTTP 400 on
/info, and testcontainers' bundled docker-java negotiates an older one, so every Valkey test dies atstartValkeywithCould not find a valid Docker environment.DOCKER_API_VERSIONdoes not help (that's the CLI's variable) — pass the docker-java system property instead:A testcontainers/docker-java bump would fix this properly, but that's out of scope here.
Rollout
This publishes
6.1.4-streak-valkey-9. Merging does not deploy it — someone still needs to run./publish-to-artifact-registry.shand then bump the coordinate in MailFoo'ssettings.gradle.ktsandMODULE.bazel.Stacked on #5 (
relocation-aware-object-input-stream,-valkey-8), which is still open and is what production runs today. GitHub will retarget this tostreak-valkeywhen #5 merges.Does not fix the
ConnectionNotFoundForRoutebursts in the same Sentry issue — those followMOVEDredirects during Memorystore slot rebalances and want--replica-count=1plus a maintenance window on the instance.🤖 Generated with Claude Code
https://claude.ai/code/session_01M52d6uXfFJ3MGVMDz5fcKG