Conversation
pjfanning
marked this pull request as draft
July 31, 2026 21:32
…very conflict Kept AtomicReference and ConcurrentHashMap imports needed for the watch cache (PR apache#917). Dropped unused SSL imports (KeyStore, SecureRandom, KeyManager, KeyManagerFactory, SSLContext, TrustManager) that were refactored to PemManagersProvider on main.
Motivation:
Review of the watch mode added in this branch turned up several problems.
The pod cache and the watch resource version were single fields on the discovery
instance, while `startedWatches` was keyed by namespace and label selector. One
instance can be asked about more than one service name, and each service name
maps to its own label selector, so two watches wrote into one cache: a lookup for
service A could return service B's pods, and the two streams overwrote each
other's resume position.
The watch stream decoded each network chunk with `_.utf8String` before splitting
on newlines. A chunk boundary can fall in the middle of a multi-byte UTF-8
character, which corrupts it, and the resulting parse failure was swallowed as a
warning, silently dropping the event. The hand-rolled line buffer also grew
without bound if no newline arrived, and re-split the whole buffer per chunk.
The watch resumed from the last item's `resourceVersion` rather than the list's
own. These are not the same value - in the existing `pods.json` fixture the list
is at 16042 while the last item is at 7406832 - and resuming from an item's
version can silently skip every event between it and the end of the list. The
`.orElse(Some("0"))` fallback is also not a valid resume point.
Reconnecting only ever restarted the stream, so when the resume point was
discarded (410 Gone, or an ERROR event) the cache was never rebuilt and pods
deleted while disconnected stayed in it forever. ERROR events were logged as
"unexpected" and otherwise ignored.
`lookupWatch` ignored `resolveTimeout`, which `lookupList` honours, so a hung
initial list hung the caller. Pods with no `metadata.name` were all cached under
the key "unknown", overwriting each other. `api-poll-mode` was an unvalidated
string, so a typo such as "Watch" silently fell back to list mode. Nothing shut
the streams down, and the reconnect loop rescheduled itself indefinitely.
Modification:
Move the per-selector state into a `WatchState` held in a `ConcurrentHashMap`
keyed by namespace and label selector, so each selector has its own cache,
resume position and kill switch.
Frame the stream with `Framing.delimiter` over the raw bytes and decode each
frame, bounded by a new `watch-max-frame-length` setting. Add `ListMeta` to
`PodList` and resume from the list's `resourceVersion`, falling back to no
version rather than "0". Rebuild from a full list whenever there is no resume
point, and treat an ERROR event as a signal to do so. Apply `resolveTimeout` to
`lookupWatch`. Drop pods with no name instead of collapsing them onto one key.
Validate `api-poll-mode` and `watch-max-frame-length` in `Settings`. Register a
`PhaseServiceUnbind` task that stops the streams and prevents further reconnects.
Move the Watch Mode docs section out from between "This configuration complements
the following Deployment specification:" and the Deployment YAML it refers to.
Result:
Two service names looked up through one discovery instance no longer see each
other's pods. Watch events survive chunk boundaries, the watch resumes from a
valid point or re-lists, lookups honour their timeout, and the streams stop on
shutdown.
Tests:
- sbt "discovery-kubernetes-api/test" - 27 succeeded, 0 failed
- New KubernetesApiWatchSpec covers the cache transitions, resource-version
tracking, ERROR handling, unnamed pods, per-selector isolation, and framing a
multi-byte character split across two chunks
- JsonFormatSpec now asserts the list resourceVersion (16042), which differs from
every item's
- New SettingsSpec cases for api-poll-mode and watch-max-frame-length validation
- sbt "discovery-kubernetes-api/mimaReportBinaryIssues" - success
- sbt "discovery-kubernetes-api/scalafmtCheck" "discovery-kubernetes-api/Test/scalafmtCheck" - clean
- Not run against a live Kubernetes cluster - no cluster available
References:
Refs apache#917
Motivation: The watch mode has unit coverage for its cache, framing and settings, but nothing had run it against a real Kubernetes API server. The existing `integration-test/kubernetes-api` suite only exercises list mode, so a watch that never connects, or one that silently degrades, would not be caught. Modification: Add `pekko-cluster-watch.yml`, which deploys the same demo app with `-Dpekko.discovery.kubernetes-api.api-poll-mode=watch` in `JAVA_OPTS`. Its RBAC role is the same as the list-mode one, which already grants `watch` alongside `list`, so no extra permission is needed. Add `test-watch.sh`, which reuses `integration-test/scripts/kubernetes-test.sh` to form the cluster and assert 3 MemberUp events, then greps the pod logs for "Watch stream started for label selector". That second assertion matters because discovery falls back to list mode for any api-poll-mode it does not recognise, so without it a broken watch could still pass by quietly listing instead. Run it from a second job in the Kubernetes API workflow. A separate job rather than a matrix, so that the existing job keeps its name and any branch protection that refers to it. Result: Watch mode is exercised end to end against a real API server: initial list, watch stream, and cluster formation from the resulting cache. Tests: - sbt "+headerCheckAll" - success - Verified the property reaches the setting rather than assuming the JAVA_OPTS path works: sbt -Dpekko.discovery.kubernetes-api.api-poll-mode=watch "discovery-kubernetes-api/testOnly *SettingsSpec" makes "default api-poll-mode to list" fail with "[watch]" was not equal to "[list]" - bash -n on test-watch.sh, and both YAML files parse - The integration test itself has not been run locally - it needs minikube, so this is a best-effort first pass to be validated by CI References: Refs apache#917
Motivation: test-watch.sh was given the Pekko header that mentions Akka, copied from the test.sh it was based on. Measured against that file it does not warrant it: of its 21 substantive lines only 5 are shared with test.sh, and those five are `set -exu`, three variable assignments whose values are all Pekko-specific, and the call to integration-test/scripts/kubernetes-test.sh. The other 16 lines, and the reason the file exists, are new. AGENTS.md reserves the Akka header for files copied from Akka-derived sources. pekko-cluster-watch.yml had no header at all. Modification: Put the standard Apache header on both, matching the form already used by the Pekko-original YAML in .github (sync-nightlies/action.yml, stage-release-candidate.yml). Result: Both new files carry the header that reflects their provenance. Tests: - sbt "+headerCheckAll" - success, though note it does not cover .sh or .yml (project/CopyrightHeader.scala maps only scala, java, conf and template), so this was a manual check - bash -n on test-watch.sh, and pekko-cluster-watch.yml still parses as three documents (Deployment, Role, RoleBinding) References: Refs apache#917
pjfanning
marked this pull request as ready for review
August 25, 2026 17:54
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.
Motivation
kubernetes-apidiscovery performs a full pod listGETon every lookup. In a large cluster with frequent lookups that puts avoidable load on the API server, and the cost grows with the number of pods rather than with the number of changes.The Kubernetes Watch API is the intended alternative: list once, then receive incremental
ADDED/MODIFIED/DELETEDevents over a long-lived stream.Modification
Add
api-poll-mode, defaulting to"list"so existing behaviour is unchanged. With"watch", the first lookup performs an initial list to seed a cache and fix the point the watch resumes from, then opens a watch stream; later lookups read the cache without touching the API server.Supporting changes:
PodListgainsListMeta, so the watch can resume from the list'sresourceVersion. An individual pod's version is not a valid resume point — in the existingpods.jsonfixture the list is at16042while the last item is at7406832, and resuming from the latter would silently skip every event in between.podCache,resourceVersion, kill switch) is held per label selector in aConcurrentHashMap. One discovery instance can be asked about several service names, each mapping to its own selector, so sharing the cache would let one service's lookup return another service's pods.Framing.delimiterover the raw bytes, bounded bywatch-max-frame-length. Decoding each network chunk to aStringbefore splitting corrupts any multi-byte UTF-8 character straddling a chunk boundary, and the resulting parse failure is easy to swallow as a dropped event.410 Gone, or anERRORevent), so pods deleted while disconnected do not linger in the cache. Resuming the stream alone would leave them there indefinitely.lookuphonoursresolveTimeoutin watch mode, as it already did in list mode.metadata.nameare dropped rather than sharing a cache key.api-poll-modeandwatch-max-frame-lengthare validated inSettings, so a typo such as"Watch"fails at startup instead of silently falling back to list mode.service-unbindphase of Coordinated Shutdown, and reconnects stop once shutdown has begun.Docs cover watch mode, the new settings, and the RBAC requirement (
watchalongsidelist).Result
Discovery can track pods from a single streaming connection instead of polling, with list mode untouched as the default.
Tests
sbt "discovery-kubernetes-api/test"— 27 succeeded, 0 failed.KubernetesApiWatchSpeccovers the cache transitions, resource-version tracking,ERRORhandling, unnamed pods, per-selector isolation, and framing a multi-byte character split across two chunks.JsonFormatSpecasserts the listresourceVersion, which differs from every item's.SettingsSpeccovers the new settings and their validation.Integration test against a real API server.
test-watch.shdeploys the demo withapi-poll-mode = watchand reuses the shared Kubernetes script, then asserts the cluster formed via the watch stream rather than by quietly falling back to list mode. It runs as a second job in the Kubernetes API workflow — a separate job rather than a matrix, so the existing job keeps its name for branch protection. Passing in CI:sbt "discovery-kubernetes-api/mimaReportBinaryIssues"— success;PodListandMetadatagained fields, with excludes inwatch-pod-changes.excludes.sbt "+headerCheckAll"and scalafmt checks — clean.References
None - new feature