This document analyses the com.sixt.service.envoy-control-plane codebase for concurrency issues in the ServiceDirectory, focusing on the pod scraping path. The TOCTOU gap in ExtractFromPod causes Collect call amplification that directly explains the slow-startup-without-Linkerd observation.
Key correction from earlier version: The original analysis claimed that pod addresses could be lost due to the TOCTOU race. This was disproven by tracing the code and confirmed by tests — updateRecordFromPod (line 388-406) always adds the missing address and triggers notification. The actual impact is resource waste and startup latency, not data loss.
File: internal/directory/service_directory.go:146-165
sd.mu.RLock()
record := sd.serviceInfoCache[getPodOwnerID(pod)] // line 147: read
sd.mu.RUnlock() // line 148: unlock
// ... gap: no lock held ...
if record == nil {
created, err = sd.createRecordFromPod(...) // line 154: acquires write lock internally
}
sd.mu.Lock() // line 163: re-acquire
updated := created || sd.updateRecordFromPod(...) // line 164
sd.mu.Unlock()For two pods (A, B) with the same owner arriving concurrently:
Pod A goroutine: Pod B goroutine:
───────────────── ─────────────────
147: record = cache[owner] → nil 147: record = cache[owner] → nil
148: RUnlock 148: RUnlock
154: createRecordFromPod(podA) 154: createRecordFromPod(podB)
338: Collect(podA_IP:port) [slow] 338: Collect(podB_IP:port) [slow]
... finishes first ... ... finishes second ...
358: Lock()
372: cache[owner] == nil → create it
376: cache[owner] = {Addr: podA_IP}
383: return created=true 358: Lock() [was blocked]
372: cache[owner] != nil → EXISTS
373: return created=false
163: Lock() 163: Lock()
164: created=true || ... 164: created=false || updateRecordFromPod(podB)
→ short-circuit, updated=true 395: record = cache[owner] → EXISTS
165: Unlock() 401: podB_IP not in Addresses → ADD IT
403: return true → updated=true
167: notify ✓ 165: Unlock()
167: notify ✓
Addresses are NOT lost. The updateRecordFromPod at line 164 always runs for the losing goroutine (since created=false, the || does not short-circuit). It looks up the record (which now exists), finds Pod B's IP is missing, adds it, and returns true. Notification fires.
The real issue is that ALL N goroutines independently call Collector.Collect (line 338) — a gRPC reflection network call — even though only 1 result is needed per owner. All pods in a ReplicaSet return identical metadata.
This is the root cause of slow startup without Linkerd:
- Informer fires
OnAdd(isInInitialList=true)for every pod during initial list - Each
OnAddspawns a goroutine →ExtractFromPod(pod, isInitial=true)→IsInInit.Add(1) - Due to the TOCTOU gap, ALL N pods for the same owner call
createRecordFromPod→Collect - Each
Collecthas a retry policy: 5 attempts, 5s→60s exponential backoff,waitForReady: true - Without Linkerd: raw TCP, no connection reuse → slow connects, UNAVAILABLE retries
- With Linkerd: connection pooling + L7 retries → Collect returns fast
- Each goroutine holds
IsInInitelevated →/readystays unhealthy until ALL complete
With 100 services × 3 replicas = 300 pods, that's 300 Collect calls instead of 100.
All pods in a ReplicaSet run the same container image. Collector.Collect returns identical results from any pod in the same RS. The redundant calls are pure waste.
File: internal/directory/service_directory.go:471-499
Note: This is a real bug but is independent of the slow-startup problem (which was observed after disabling REST on STAGE — meaning this code path was not exercised).
sd.mu.RLock()
record := sd.serviceInfoCache[ownerID] // line 472
sd.mu.RUnlock() // line 473
if record == nil {
updated, err = sd.createRecordFromPod(...) // line 477: NETWORK CALL in gap
}
sd.mu.Lock() // line 486
// ... updateRESTServiceInfo ... // line 494
sd.mu.Unlock()When two HTTPRoute events for the same owner arrive concurrently:
- Both see
record == nil, both callcreateRecordFromPod(redundant Collect) - Both then call
updateRESTServiceInfosequentially under the write lock UpdateRESTMethods(withrouteNum=0) replaces the method list from its own matches only- The second writer overwrites the first writer's REST paths
REST endpoints ARE lost in this path (unlike the pod path above). This is confirmed by TestExtractFromHTTPRoute_ConcurrentSameOwner_TOCTOU which fails reliably.
| Test | Result | What it proves |
|---|---|---|
RedundantCollect |
PASS + warning: 10 calls | N×Collect amplification per owner |
InitBlocking |
PASS: peak=N | IsInInit blocked N× → slow startup |
CollectFailureAmplification |
PASS: N failures | Cascading errors when pod not ready |
SubscriberNotification |
PASS | Addresses NOT lost (disproves original claim) |
RaceDetector |
PASS | Shallow-clone pointer safety |
HTTPRoute_TOCTOU |
FAIL | REST endpoint loss (real, separate bug) |
AddRemove |
PASS | Add/remove ordering is safe |
WithPodWatcher |
PASS + warning | Cross-watcher amplification |
Use x/sync/singleflight keyed by ownerID wrapping the Collector.Collect call:
import "golang.org/x/sync/singleflight"
// In ServiceDirectory:
var collectGroup singleflight.Group
// In createRecordFromPod, replace direct Collect call:
result, err, _ := sd.collectGroup.Do(ownerID, func() (interface{}, error) {
return sd.Collector.Collect(ctx, addr)
})This ensures only 1 Collect call per owner regardless of how many pods arrive concurrently. Benefits:
IsInInitduration reduced from N×Collect to 1×Collect- Error amplification eliminated (1 failure, not N)
- Network load reduced by N× during startup
- Startup time matches the with-Linkerd behavior
ServiceDirectory lock-upgrade race
Analysis of the read-then-write locking pattern used in
internal/directory/service_directory.goand the concurrent paths in thewatcher (
internal/watcher/pod_watcher.go,internal/watcher/http_route_watcher.go) that make the race reachable inproduction.
What "lock-upgrade" means here
sync.RWMutexhas no atomic upgrade fromRLocktoLock. The pattern inExtractFromPod(service_directory.go:146–165) is:There are two unprotected windows (A and B) between the read and the write. The
decision "should I create?" is made on data that may already be stale by the
time we act. The code tries to compensate by re-checking inside
createRecordFromPodunder the write lock — but the compensation is incomplete.The same shape appears in
createOrUpdateRecordWithREST(service_directory.go:471–499).
Example 1 — Lost address + no notification
Setup: a Deployment with two pods, A and B, sharing the same
ownerID(
controller.UIDof the ReplicaSet — seegetPodOwnerIDatservice_directory.go:660). Pod B's address is already in the cache. Pod A is
being added for the first time. Independently, pod B is being removed.
Two goroutines run concurrently (informer handlers + spawned worker goroutines —
see "Verifying the concurrency" below):
ExtractFromPod(A)RemovePodInfo(B){ownerID: {addrs: {B}}}RLock→record = {addrs:{B}}→RUnlockrecord != nil→ skipcreateRecordFromPod→created = falseLock→ delete B from addrs →len==0→delete(cache, ownerID)→Unlock{}Lock→updateRecordFromPod(A)→ readscache[ownerID]→ nil → returns false →Unlock{}updated = false || false = false→ no notificationPod A's event is silently dropped. The control plane never learns about it
until the next pod event arrives — which can be a long time on a stable
deployment.
updateRecordFromPod(service_directory.go:395) returns false whenthe record is nil, exactly because it can't distinguish "expected nil" from
"raced out from under us":
Example 2 — Duplicate gRPC reflection, discarded result
Setup: two pods A and B with the same
ownerID, both being added for the firsttime (fresh deployment rollout — informer fires
OnAddfor both in parallel).createRecordFromPodperformssd.Collector.Collect(...)over gRPC(service_directory.go:338) before acquiring the write lock — that's a
remote call.
ExtractFromPod(A)ExtractFromPod(B)RLock→record = nil→RUnlock{}RLock→record = nil→RUnlock{}createRecordFromPod→Collector.Collect(A:grpc)(slow)createRecordFromPod→Collector.Collect(B:grpc)(slow){}Info_AInfo_B{}Lock→cache[ownerID] == nil✓ → store{addrs:{A}, Info:Info_A}→Unlock→ returnscreated=true{ownerID: {addrs:{A}, Info_A}}Lockblock →updateRecordFromPod(A)→ addr A already present → returns false →updated = true || false = true→ notifyLockacquired → re-checkcache[ownerID] != nil→ returnscreated=false, nil→Info_Bdiscardedcreated = false→Lock→updateRecordFromPod(B)→ adds addr B → returns true → notify{ownerID: {addrs:{A,B}, Info_A}}End state: addresses are correct (both A and B), but
Collector.Collectrantwice and
Info_Bwas discarded.Severity caveat: pods sharing one
ownerIDbelong to the same ReplicaSet(a rolling deploy creates a new ReplicaSet with a different UID, so the new
pods get a different
ownerID). Pods of one ReplicaSet are expected to behomogeneous, so
Info_Bis usually identical toInfo_Aand the"first-writer-wins" semantics are not observable — the visible impact is just
the wasted gRPC reflection work, which is multiplied by N at fleet startup
when N pods of one ReplicaSet are added in parallel. The discarded-metadata
angle only matters in the rare case where pod-to-pod Service metadata can
legitimately differ inside a ReplicaSet.
The re-check at service_directory.go:372 is the right defensive move given
the lock-upgrade pattern, but its semantics are "skip silently" — there is no
merge, no retry, no log line.
Example 3 —
createOrUpdateRecordWithRESTsame shapeSame windows at service_directory.go:471–499:
Failure mode: between RUnlock and Lock,
RemovePodInfoclears the record.updateRESTServiceInfothen hits service_directory.go:423–425:…and returns an error to the watcher, which logs and moves on. The HTTPRoute
reconcile is lost until the next event.
Why the "re-check inside createRecordFromPod" mitigation is incomplete
It correctly prevents double-insert of the cache entry. It does not
prevent:
updateRecordFromPodsees nil and silently no-ops. This is the load-bearingbug.
created=truesignal (Example 2) → outer caller seescreated=false. In this specific timeline the loser still adds its addressvia
updateRecordFromPod, which setsupdated=trueand triggers a notify,so no notification is lost — what is lost is
Info_B, the just-collectedmetadata.
Collectover gRPC) when racers collide for the sameownerID.The pattern only papers over the symptom that is easiest to spot (duplicate map
insert).
Verifying the concurrency
The race only matters if the watchers can call
ExtractFromPod/RemovePodInfo/ExtractFromHTTPRouteconcurrently. They can.Where concurrency actually comes from
pod_watcher.go:132—OnAddcallsExtractFromPodinside a fresh goroutine:pod_watcher.go:128— the dedupe:Keyed on
pod.UID. Two different pods of the same Deployment have differentUIDs but share an
ownerID(controller.UIDof the ReplicaSet — seegetPodOwnerIDat service_directory.go:660). So this dedupe does not preventtwo
ExtractFromPodgoroutines from touchingserviceInfoCache[ownerID]atthe same time.
OnDelete(pod_watcher.go:212) runsRemovePodInfosynchronously on theinformer's handler goroutine — but at pod_watcher.go:206 it only waits for the
same pod's in-flight Add:
So
RemovePodInfo(podB)does not wait forExtractFromPod(podA)even whenthey share an ownerID.
The HTTPRoute informer is an entirely separate informer with its own
goroutines (http_route_watcher.go:126), so
ExtractFromHTTPRouterunsconcurrently with both
ExtractFromPodandRemovePodInfo.Which scenarios actually fire
ExtractFromPodfor different pods of the same Deployment (Example 2)pod.UID, dedupe doesn't apply; both spawn goroutines fromOnAddExtractFromPod(A)racingRemovePodInfo(B)same ownerID (Example 1)OnDelete(B)only waits on B's own Add channel; A's goroutine runs in parallelExtractFromHTTPRouteracingExtractFromPodfor same ownerID (Example 3)ExtractFromPodfor the same pod UIDworkInProgress.LoadOrStorereturns early — dedupe holdsThe
sd.mumutex is the only thing keeping the cache consistent — which isexactly why the lock-upgrade gap matters. If the watcher serialized everything
on a single worker goroutine, the RLock/Lock pattern would still be ugly but
harmless. It does not.
Concrete trigger
Easiest reproducer in production: a fresh Deployment rollout. The informer's
initial sync emits an
OnAddfor every Ready pod, each of whichgo's intoExtractFromPod. For a Deployment with N replicas (sharing one ReplicaSet UID= one ownerID), you get N goroutines hammering
serviceInfoCache[ownerID]inparallel — and each one calls
Collector.Collectover gRPC outside the lockbefore deciding whether to create. That is the exact shape of Example 2.
Note:
go test -racewill not catch this. Every access toserviceInfoCachegoes throughsd.mu; both racers take the lock. There is noGo-level data race — the bug is a logical lost-update across two separate
critical sections. To prove it, write a deterministic concurrency test that
injects barriers into the mocked
Collector(or into the directory between theRUnlock and the second Lock) to force the interleaving in Example 1.
So: the race window is real, the trigger is normal startup, and the load-bearing
symptom (Example 1: occasional missed pod additions/notifications when an Add
races a Delete on the same
ownerID) is the kind of intermittent inconsistencythat is very hard to debug after the fact.
Fix — owner-scoped upsert with reservation
The bug comes from making the create-or-update decision under one critical
section and committing it under a different one. The fix is to not split that
decision across two locks. The slow operation (
Collector.Collectover gRPC)must stay outside the lock — holding
sd.muacrossCollectwould blockremovals, snapshots, REST updates, and notifications for up to the 60s context
timeout. So the address bookkeeping and the metadata collection need to be
decoupled.
Design
Separate "this pod exists at this address" (cheap, must be atomic) from "here
is the gRPC reflection metadata for this owner" (slow, best-effort, can be
filled in later).
ownerID.Addresses, setmetadata-pendingif not yet populated, Unlock, notify.
activeServiceMappingentry + a generation counter or
metadata-pending = true. Unlock,notify.
Collector.Collectoutside the lock, using the pod's gRPC port.cache[ownerID]still exists and its generationmatches the one observed at step 3 (no one deleted-and-recreated under us),
populate
Info, clearmetadata-pending. Otherwise discard the collectedmetadata. Unlock, notify if
Infochanged.Why this works for Example 1: pod A's address is inserted under the same lock
that excludes pod B's delete. Whichever order they interleave, the final cache
state is a faithful sequence of the two operations — there is no "decision made
on stale data, committed later" gap. Pod A cannot be silently dropped.
Why this works for Example 3:
createOrUpdateRecordWithRESTdoes its REST-infoupdate under the same atomic upsert. If the record was deleted in the meantime,
the upsert is the authoritative creation; there is no "service record not
found" error path to fall into.
Optimizing the duplicate-Collect case (Example 2)
Layer a per-
ownerIDsingleflight.Groupon top of step 4: parallel goroutinesprocessing different pods of the same
ownerIDshare one in-flightCollect.This is an optimization (eliminates the wasted gRPC reflection at fleet
startup), not a correctness fix.
What I previously proposed and why it was wrong
— broken. To know whether to Collect, the code needs to know whether the
record already exists; an unlocked map read for that is an actual Go data
race, and always-Collect throws away the existing optimization. The RLock in
the current code is doing real work; the fix is to make the decision and
the mutation share one critical section, not to remove the early read.
sd.mu.Lock()acrossCollect" — would fix Example 1 but isunacceptable:
Collectcan take up to 60s and would serialize everydirectory operation behind it.
Re-checking inside
createRecordFromPodprevents the duplicate insert butnot the lost update in Example 1.
Proving the fix
The right way to demonstrate both the bug and a fix is a deterministic
concurrency test:
Collectorthat blocks on a per-call channel released by the test.ExtractFromPod(A)andRemovePodInfo(B)concurrently with the sameownerID, ordered with the channels so the interleaving in Example 1 firesevery run.
serviceInfoCache[ownerID].Addressescontainspod A's IP and a subscriber notification was delivered.
Today this test fails. With the reservation fix it passes — and it documents
the invariant going forward.