Skip to content

fix(inferflow): rebuild ComponentProviderHandler.componentMap per RegisterComponent call#382

Closed
anujGoel124 wants to merge 1 commit into
developfrom
fix/inferflow-rebuild-component-map-on-reload
Closed

fix(inferflow): rebuild ComponentProviderHandler.componentMap per RegisterComponent call#382
anujGoel124 wants to merge 1 commit into
developfrom
fix/inferflow-rebuild-component-map-on-reload

Conversation

@anujGoel124

@anujGoel124 anujGoel124 commented Jun 29, 2026

Copy link
Copy Markdown

Context

ComponentProviderHandler.RegisterComponent is registered as an etcd
config-change callback (see cmd/inferflow/main.go:27
etcd.RegisterWatchPathCallback("", ReloadModelConfigMapAndRegisterComponents)).
Every config event invokes RegisterComponent(updatedConfig).

The previous implementation appended to cp.componentMap on every call
without rebuilding or pruning. Over the pod's lifetime the map accumulated
entries from every model config that had ever been seen — even after those
models were removed or renamed in etcd. The result is a slow-growth memory
leak driven by config-event rate.

Production telemetry that prompted the fix

Container memory on the inferflow deployments shows monotonic per-pod growth
across multiple pod generations (lifetime ≥ 48 h pods, 7-day window):

Deployment Long-lived pods Median per-pod slope Smoking-gun pod (lifetime / start → end)
prd-inferflow-pdp-ad 6 +50 MB/h 68 h, 757 MB → 13,439 MB
prd-inferflow-rv-ct 4 +20 MB/h 166 h, 318 MB → 11,836 MB

These pods aren't in a warm-up ramp — they're well past warm-up steady-state
and still growing.

What this PR fixes

handlers/inferflow/provider.goRegisterComponent now builds a fresh
local map from the supplied *config.ModelConfig and publishes it under the
write lock. The old map becomes GC-able. Same atomic-swap pattern already
used in this codebase for featureSchemaCache
(handlers/config/config_schema.go:32-42).

 func (cp *ComponentProviderHandler) RegisterComponent(request interface{}) {
-    modelConfig, ok := request.(*config.ModelConfig)
-    if ok {
-        cp.mapMutex.Lock()
-        defer cp.mapMutex.Unlock()
-        cp.componentMap[featureInitComponent] = &components.FeatureInitComponent{...}
-        ...
-        for _, k := range fCompMap.Keys() {
-            cp.componentMap[k.(string)] = &components.FeatureComponent{...}  // unbounded ADD
-        }
-        ...
-    }
+    modelConfig, ok := request.(*config.ModelConfig)
+    if !ok {
+        return
+    }
+    newMap := make(map[string]dag.AbstractComponent)
+    newMap[featureInitComponent] = &components.FeatureInitComponent{...}
+    ...
+    for _, k := range fCompMap.Keys() {
+        newMap[k.(string)] = &components.FeatureComponent{...}
+    }
+    ...
+    cp.mapMutex.Lock()
+    cp.componentMap = newMap   // atomic swap; old map GC-able
+    cp.mapMutex.Unlock()
 }

Expected runtime impact — honest scoping

This PR fixes one real correctness bug, but is unlikely to flatten the
slope on its own.
Magnitude estimate vs. observed slope:

  • Each entry in componentMap is roughly a pointer to a Component struct (~200 B).
  • Per call, RegisterComponent adds ~10-50 entries (depending on model count).
  • At ~1 etcd config event per minute, the unbounded growth from this bug
    alone is ~0.06 to 0.60 MB/h per pod.
  • Observed runtime slope is +50 MB/h on prd-inferflow-pdp-ad.

So this PR explains ~1% of the observed slope. The remaining ~99% is
elsewhere — see Likely additional sources below. Validating via Grafana
over 48-72h post-merge is the honest check: if slope drops to a residual
in the 10s-of-MB/h range, this fix is doing its small-but-real part and
the rest needs separate attention.

Tests

  • TestRegisterComponent_RebuildsNotAccumulates — two calls with
    different configs leave only the second config's components in the map.
  • TestRegisterComponent_StableSizeAcrossRepeatedSameCall — 100
    identical calls keep the map size constant.
  • Non-*ModelConfig input is a no-op.
  • Empty ConfigMap keeps only the feature_initializer entry.
  • go test ./... in the inferflow module is green.
  • go vet ./... is clean.

Likely additional sources (not addressed here — flagging for owners)

Audit of the inferflow source identified these as likely contributors to the
remaining ~99% of the slope. Listed in order of suspected magnitude:

1. pkg/inmemorycache/init.go:22 — Cache initialized without visible size cap

func Init(version int) {
    once.Do(func() {
        switch version {
        case 1:
            instance = newV1InMemoryCache(inMemoryCacheV1Name)  // ← no size arg
        ...
    })
}

A constant IN_MEMORY_CACHE_SIZE_IN_BYTES is defined in inmemory_cache.go:6
but I don't see it being passed to newV1InMemoryCache in the init path. If
the underlying cache implementation enforces the size cap via env var
(IN_MEMORY_CACHE_SIZE_IN_BYTES), confirming the deployment sets that env var
would close this hypothesis. If not, this is likely the dominant remaining
source — high-cardinality cache without eviction.

Suggested followup: verify IN_MEMORY_CACHE_SIZE_IN_BYTES is set in
deployment manifests, or wire it through the init path explicitly.

2. handlers/external/featurestore/onfs.go:180-212 — Per-batch allocation without sync.Pool

batches := make([]proto.Query, batchCount)
batchGlobalIndexes := make([][]int, len(batches))
keys := make([]*proto.Keys, featureComponentBuilder.InMemoryMissCount)
batchGlobalIndexesBuffer := make([]int, featureComponentBuilder.InMemoryMissCount)
// ... no sync.Pool
result := make([]*proto.FeatureGroup, len(fgs))

At high feature-store RPS, per-batch slice allocations create heavy GC
pressure. Not a "leak" in the unbounded-growth sense, but contributes to
memory growth via heap retention and GC overhead.

Suggested followup: introduce a sync.Pool of pre-sized buffer slices;
reset and reuse rather than allocate per call.

3. pkg/etcd/v1.go:90-128 — WatchPrefix goroutine lifecycle

The watcher goroutine has no shutdown signal; the V1 struct has no Stop()
method. Single long-lived goroutine living for the pod's lifetime — minor
hygiene, not a runtime leak driver, but worth fixing alongside the cache
work.

4. pkg/etcd/v1.go:136-137V1.dataMap / V1.metaMap partial-eviction

Same accumulator shape as A8, but with partial eviction via the DELETE
handler. Whether it actually leaks depends on the etcd usage pattern: if
model configs are added (PUT) but old keys are never explicitly DELETEd
(only overwritten), dataMap accumulates. Worth confirming the operational
practice for config additions/removals.

Verification plan

Post-merge, watch per-pod memory slope on prd-inferflow-pdp-ad and
prd-inferflow-rv-ct over 48-72h:

  • If slope drops to <5 MB/h → this PR accounts for more than expected;
    excellent.
  • If slope drops to 10-30 MB/h → this PR's small contribution is real;
    remaining sources (likely items 1-2 above) need separate fixes.
  • If slope is unchanged → this PR is correct but tiny; the dominant source
    is item 1 or 2 above. Recommend pprof heap profile on a long-lived pod
    to nail it.

Happy to follow up with separate PRs for items 1-4 if useful.

…isterComponent call

`RegisterComponent` is registered as an etcd config-change callback
(cmd/inferflow/main.go:27 — via ReloadModelConfigMapAndRegisterComponents),
so it runs once per config event for the pod's lifetime. The previous
implementation APPENDED to `cp.componentMap` without ever rebuilding or
pruning. Entries from removed/renamed models leaked, and because etcd
config events arrive throughout the pod's lifetime, the map grew
unboundedly.

The fix is the same atomic-swap pattern already used in this codebase for
`featureSchemaCache` (handlers/config/config_schema.go:32-42): build a fresh
map locally from the supplied ModelConfig, then publish it under the write
lock. The old map becomes GC-able. Readers (GetComponent) are unaffected —
they continue to take the read lock and see either the old or the new map,
never a torn intermediate state.

The function's external contract is unchanged: after `RegisterComponent(cfg)`
returns, `cp.componentMap` reflects exactly the components in `cfg` (plus the
always-present `feature_initializer`). The difference is that prior calls
no longer accumulate.

Added focused tests in provider_test.go:
  - rebuild-not-accumulate: two calls with different configs leave only the
    second config's components in the map
  - stable-size-across-repeated-calls: 100 identical RegisterComponent calls
    keep map size constant (the simplest regression for the leak)
  - non-ModelConfig input is a no-op
  - empty ModelConfig retains only the feature_initializer entry

go test ./... in the inferflow module is green.
@m-agentic-review

Copy link
Copy Markdown

🟢 LOW — safe to fast-track · confidence 0.88

This fixes an internal memory leak by rebuilding the component registry on each config reload instead of endlessly appending, without changing any external API. RegisterComponent in provider.go now builds a fresh map and swaps it under the write lock—the same pattern already used for featureSchemaCache in config_schema.go—while GetComponent and method signatures stay the same. New tests lock in the intended behavior: only the latest config’s components remain. Cleared: no API, config, dependency, or security surface changes.

Top risks

  • inferflow/handlers/inferflow/provider.go#RegisterComponent — full map rebuild drops stale component names; if anything still looked up a removed name it would get nil instead of a leaked entry (tests and PR intent say current-config-only is correct)

Check before approving

  • After deploy, confirm per-pod memory slope on prd-inferflow-pdp-ad and prd-inferflow-rv-ct flattens over 48–72h
  • Verify the etcd callback always passes the complete ModelConfig snapshot so the rebuilt map is not missing live components

8 tool(s) ran · DRS agentic v2 · #382

@m-agentic-review m-agentic-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Code Review — PR #382

Language: Go  |  Files reviewed: 2

Severity Count
BLOCKER 🚨 1
NITPICK 🔧 1
SUGGESTION 💡 1
WARNING ⚠️ 5

}

cp.mapMutex.Lock()
cp.componentMap = newMap

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 BLOCKER — Replacing the entire component map from each callback payload will drop components for other models if the etcd callback supplies only the changed model rather than a full snapshot.

The new code builds newMap only from modelConfig.ConfigMap in the current RegisterComponent call and then assigns it over cp.componentMap. The old append behavior preserved components registered by prior callbacks, so if callbacks are per-key or partial updates, this change removes still-live components that were not present in the latest event payload.

Also flagged on this line:

  • ⚠️ WARNING — A nil or empty *ModelConfig now wipes all existing registered components instead of leaving the previous map intact.

}
}

cp.mapMutex.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING — Serialize the entire rebuild or otherwise guard against concurrent RegisterComponent calls so an older config cannot overwrite a newer one after building outside the lock.

The write lock now only protects the final assignment, so two concurrent callbacks can build maps in parallel and publish in completion order rather than invocation/event order. A slow earlier callback can swap its stale map after a later callback has already published the current config.

Also flagged on this line:

  • ⚠️ WARNING — Delaying the write lock until after the rebuild allows concurrent registrations to publish stale configs out of order.

// Build a fresh map locally; never mutate cp.componentMap while readers
// (GetComponent) may be holding the read lock. The final atomic swap
// publishes the new map; the old map becomes GC-able.
newMap := make(map[string]dag.AbstractComponent)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ WARNING — Building the replacement map outside the write lock lets readers observe the old component map during a reload after other config state may already reference the new components.

With the old code, once RegisterComponent entered the write-locked section, GetComponent calls blocked until the newly registered components were present. The new code intentionally keeps readers unblocked while rebuilding, so requests can continue using the old component map during the rebuild window, which can be inconsistent if the DAG/model config has already been updated elsewhere.

Also flagged on this line:

  • ⚠️ WARNING — Consider serializing or coalescing concurrent RegisterComponent rebuilds so config-event bursts do not build multiple full component maps at once.
  • 💡 SUGGESTION — Pre-size newMap to the expected component count before filling it on each config event.

}
}

// TestRegisterComponent_NilModelConfigStillWritesInitializer covers the edge

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 NITPICK — Rename this test comment to match TestRegisterComponent_EmptyModelConfigKeepsInitializer or change the test to actually pass a typed nil *config.ModelConfig.

The comment mentions a nil ModelConfig, but the test passes a non-nil ModelConfig with a nil ConfigMap, which makes the covered edge case unclear to future maintainers.

@anujGoel124

Copy link
Copy Markdown
Author

Closing this PR after a more rigorous audit.

The componentMap fix here is correct — it's a real accumulator on the
etcd config-change callback path. But the magnitude estimate is small
(~0.06–0.60 MB/h per pod) against an observed runtime slope of
+50 MB/h on prd-inferflow-pdp-ad. Fixing this alone would account for
roughly 1% of the slope. I'd previously flagged followups (cache
without size cap, per-batch alloc, etc.) but on closer inspection:

  • pkg/inmemorycache is bounded — newV1InMemoryCache reads
    IN_MEMORY_CACHE_SIZE_IN_BYTES via viper and feeds it to
    freecache.NewCache(sizeInBytes), which is a fixed-size LRU.
  • handlers/external/featurestore/onfs.go allocates per-batch but
    releases after the request returns — GC pressure, not retained leak.
  • handlers/external/predator/predator.go's predatorClientMap is
    init-once at startup, not re-invoked on etcd changes.

The dominant source of the +50 MB/h slope isn't visible to static
analysis I could perform externally. Likely candidates need a heap
profile to confirm:

  • Native/CGo memory in parquet-go, falconembedding, or freecache
    internals (Go GC can't see this; container RSS can grow while Go
    heap stays flat)
  • gRPC connection / in-flight RPC state in helix-client
  • Goroutine state in long-running RPC streams
  • Metric library label cardinality

Shipping a 1% fix to another team's repo without confidence in the
dominant source isn't a useful contribution — it would create
expectations the merge can't meet. Closing.

If the inferflow team is interested in collaborating on the actual
leak, a heap profile from a long-lived pod (e.g.,
prd-inferflow-pdp-ad-primary-...-5cd5d which grew from 318 MB to
11,836 MB over 166 h) would point at the dominant source. Happy to
reopen with a complete fix once that's available.

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.

1 participant