fix(models): slow models controller step and inaccurate controller health check - #953
Conversation
…ller The models controller resolved the Model Entity behind every served model one at a time and attempted a passthrough VirtualModel creation for each, so a single pass cost two round trips per served model per provider. With several providers exposing large catalogues the pass took long enough to exceed its own poll interval, and the loop's liveness window with it. Add ModelEntityCache, which reads the entities once per reconciliation phase and accumulates the intended changes. Each entity is written at most once and only when something actually differs, and the desired state is assembled in memory so contributions from several providers cannot overwrite one another. The cache is refreshed at the start of each phase that writes entities and flushed at the end of it, keeping the deployment and provider phases from deciding against state the other has already changed. Read the VirtualModels once per pass as well. The existence set covers every VirtualModel, so names held by user-managed ones are left alone, and orphan cleanup consumes the same snapshot instead of listing them again. Also give control loops a way to report incremental progress. A step that works through a large batch legitimately outruns three poll intervals, which the liveness check previously read as a stall. Controllers now report progress as each unit of work completes, so a slow-but-advancing pass stays healthy while a genuinely stuck one is still detected. Loop health changes are logged on transition, naming the loop and the reason. Signed-off-by: Ben McCown <bmccown@nvidia.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Enterprise Run ID: 📒 Files selected for processing (12)
🚧 Files skipped from review as they are similar to previous changes (7)
📝 WalkthroughWalkthroughChangesThe PR adds shared heartbeat tracking and health reasons, transition-only health logging, a batched Heartbeat and health reporting
Model reconciliation
Sequence Diagram(s)sequenceDiagram
participant ModelsController
participant ModelEntityCache
participant ModelDeploymentReconciler
participant ModelProviderReconciler
participant ModelsAPI
ModelsController->>ModelEntityCache: refresh phase snapshot
ModelsController->>ModelDeploymentReconciler: reconcile deployments
ModelDeploymentReconciler->>ModelEntityCache: stage provider unlink
ModelsController->>ModelEntityCache: flush staged changes
ModelsController->>ModelProviderReconciler: reconcile providers
ModelProviderReconciler->>ModelEntityCache: stage entity mutations
ModelsController->>ModelEntityCache: flush staged changes
ModelEntityCache->>ModelsAPI: create or update entities
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
services/core/models/src/nmp/core/models/sidecars/adapters/main.py (1)
129-143: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winEmit for temporary-directory cleanup too.
Line 134 skips Line 143. Many staging directories can be removed without a heartbeat, causing a false liveness failure. Put the emission in a per-iteration
finally.Proposed fix
for name in set(os.listdir(self.nim_peft_source)) - dirs_to_keep: - # Staging temp dirs (".{dir}.tmp") were never loaded into vLLM; - # just reap them. - if name.startswith("."): - shutil.rmtree(f"{self.nim_peft_source}/{name}") - continue - # Unload from vLLM before deleting on disk so a removed/disabled - # adapter stops being served (no-op for NIM). Only delete the - # directory once the unload is confirmed (or vLLM has no endpoint): - # if vLLM is currently unreachable, keep the dir so the unload is - # retried next cycle rather than orphaning a still-loaded adapter - # in vLLM until it restarts (the dir is the only state driving GC). - if self._unload_vllm_adapter(name): - shutil.rmtree(f"{self.nim_peft_source}/{name}") - self.emit_heartbeat() + try: + if name.startswith("."): + shutil.rmtree(f"{self.nim_peft_source}/{name}") + continue + if self._unload_vllm_adapter(name): + shutil.rmtree(f"{self.nim_peft_source}/{name}") + finally: + self.emit_heartbeat()🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/core/models/src/nmp/core/models/sidecars/adapters/main.py` around lines 129 - 143, Update the cleanup loop in the sidecar adapter management method so every directory iteration emits a heartbeat, including staging directories removed by the name.startswith(".") branch and iterations where cleanup raises. Wrap each iteration’s cleanup logic in a per-iteration finally block and keep emit_heartbeat() there, removing the current branch-dependent emission.
🧹 Nitpick comments (4)
services/core/models/src/nmp/core/models/controllers/models_controller.py (1)
236-247: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winCache miss now means "absent" with no live fallback.
An entity created after the phase's
refresh()(concurrent registration, or a sibling service) reads as missing for the whole phase, soctx.model_entityisNoneand backends that need it to compile serving objects skip the deployment until the next tick. Consider a one-shotmodels.retrievefallback on miss — it costs a round trip only for genuinely-unknown names, which is the rare case this batching was meant to avoid.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/core/models/src/nmp/core/models/controllers/models_controller.py` around lines 236 - 247, Update the cache-miss branch in the model retrieval flow around _entity_cache.get so it performs a one-shot await self._models_sdk.models.retrieve fallback before returning None. Preserve the cached entity path and existing debug logging, and return None only when both the cache lookup and live retrieval fail.services/core/models/tests/unit/controllers/test_provider_reconciler.py (1)
988-1008: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest name promises more than it checks.
test_entity_cache_load_failure_prevents_any_entity_writesnever invokes the reconciler — it only callsrefresh/flushdirectly, so it proves nothing about reconciliation aborting. Drive it throughreconcile_model_providers(or rename to match what it asserts).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/core/models/tests/unit/controllers/test_provider_reconciler.py` around lines 988 - 1008, Update test_entity_cache_load_failure_prevents_any_entity_writes to invoke reconciler.reconcile_model_providers after configuring the entity-cache load failure, and assert the APIStatusError propagates while models.create and models.update remain uncalled. Keep the test focused on proving reconciliation aborts before any entity writes, rather than directly calling refresh and flush.services/core/models/tests/unit/controllers/test_entity_cache.py (1)
181-190: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTest asserts both writes were attempted, but not what happens to the failed one.
Add coverage for the state after a failed write — whether the staged change survives for retry. This is the behaviour flagged on
flush()inentity_cache.py.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/core/models/tests/unit/controllers/test_entity_cache.py` around lines 181 - 190, Extend test_one_failing_entity_does_not_stop_the_others to assert the failed entity’s staged provider link remains available after flush for a later retry, while the successful entity’s staged change is cleared. Use the cache’s existing state-inspection or retry path rather than only checking models.update.await_count, and preserve verification that both writes are attempted.services/core/models/tests/unit/controllers/test_deployment_reconciler.py (1)
22-49: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCache-seeding test scaffolding is triplicated.
_AsyncPaginator,seed_entity_cache, and the entity stand-in factory are re-implemented in three modules with subtly different behaviour (only two definemodel_copy), so cache-overlay semantics can drift between suites.
services/core/models/tests/unit/controllers/test_deployment_reconciler.py#L22-L49: delete the local_AsyncPaginator,seed_entity_cache, and_entityand import them from a sharedconftest.py/helper module.services/core/models/tests/unit/controllers/test_provider_reconciler.py#L145-L148: drop the localseed_entity_cachein favour of the shared one.services/core/models/tests/unit/controllers/test_entity_cache.py#L14-L36: move_AsyncPaginatorand_entityinto the shared module as the single definition, keeping themodel_copystub.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/core/models/tests/unit/controllers/test_deployment_reconciler.py` around lines 22 - 49, Centralize the cache-seeding test helpers in services/core/models/tests/unit/controllers/conftest.py or the existing shared helper: move _AsyncPaginator and _entity from test_entity_cache.py, preserving the _entity model_copy stub, and define the shared seed_entity_cache there. In services/core/models/tests/unit/controllers/test_deployment_reconciler.py, remove the local definitions and import the shared helpers; in services/core/models/tests/unit/controllers/test_provider_reconciler.py, remove its local seed_entity_cache and use the shared helper.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/nmp_common/src/nmp/common/controller/controller_manager.py`:
- Around line 141-146: Update the exception handling around the health check in
the controller manager so repeated readiness-probe failures are not logged
unconditionally. Route exception details through _log_health_transition() and
emit the error, including traceback when needed, only when the loop transitions
to unhealthy; preserve health_status and all_healthy updates.
In `@packages/nmp_common/src/nmp/common/controller/controller.py`:
- Line 55: Update the _heartbeat annotation to reference the already-defined
Heartbeat type directly, removing the surrounding string wrapper while
preserving the optional type.
In
`@services/core/models/src/nmp/core/models/controllers/deployment_reconciler.py`:
- Around line 802-808: Update the unlink flow around _entity_cache.get so a
missing cache snapshot is refreshed or otherwise loaded before deciding the
model entity is absent. Distinguish an uninitialized cache from a genuinely
nonexistent entity, then stage_provider_unlink for provider_id when the
refreshed entity contains it; retain the current debug-and-continue behavior
only for confirmed absence.
In `@services/core/models/src/nmp/core/models/controllers/entity_cache.py`:
- Around line 155-177: Update EntityCache.flush so an entity whose _create or
_update operation raises is retained in _pending for a later retry instead of
being lost when the batch is swapped out. Preserve per-entity exception
isolation so refresh() and other pending entities continue processing, and
ensure successful entries remain cleared while failed provider-unlink changes
can be retried.
In `@services/core/models/src/nmp/core/models/controllers/provider_reconciler.py`:
- Around line 1017-1019: The debug message in the existing_vm_names branch
should not claim that a passthrough VirtualModel exists. Update the logger.debug
call in the surrounding reconciler method to describe that creation is being
skipped because the workspace/model name is already used by another user-managed
VirtualModel, while preserving the existing early return.
- Around line 332-339: Guard the _load_virtual_models call in
reconcile_model_providers so listing failures do not abort the provider pass. On
failure, mark the VirtualModel snapshot unavailable and skip both VirtualModel
creation and orphan cleanup, while continuing provider status updates and Model
Entity linking. Preserve normal creation and cleanup behavior when the listing
succeeds.
---
Outside diff comments:
In `@services/core/models/src/nmp/core/models/sidecars/adapters/main.py`:
- Around line 129-143: Update the cleanup loop in the sidecar adapter management
method so every directory iteration emits a heartbeat, including staging
directories removed by the name.startswith(".") branch and iterations where
cleanup raises. Wrap each iteration’s cleanup logic in a per-iteration finally
block and keep emit_heartbeat() there, removing the current branch-dependent
emission.
---
Nitpick comments:
In `@services/core/models/src/nmp/core/models/controllers/models_controller.py`:
- Around line 236-247: Update the cache-miss branch in the model retrieval flow
around _entity_cache.get so it performs a one-shot await
self._models_sdk.models.retrieve fallback before returning None. Preserve the
cached entity path and existing debug logging, and return None only when both
the cache lookup and live retrieval fail.
In `@services/core/models/tests/unit/controllers/test_deployment_reconciler.py`:
- Around line 22-49: Centralize the cache-seeding test helpers in
services/core/models/tests/unit/controllers/conftest.py or the existing shared
helper: move _AsyncPaginator and _entity from test_entity_cache.py, preserving
the _entity model_copy stub, and define the shared seed_entity_cache there. In
services/core/models/tests/unit/controllers/test_deployment_reconciler.py,
remove the local definitions and import the shared helpers; in
services/core/models/tests/unit/controllers/test_provider_reconciler.py, remove
its local seed_entity_cache and use the shared helper.
In `@services/core/models/tests/unit/controllers/test_entity_cache.py`:
- Around line 181-190: Extend test_one_failing_entity_does_not_stop_the_others
to assert the failed entity’s staged provider link remains available after flush
for a later retry, while the successful entity’s staged change is cleared. Use
the cache’s existing state-inspection or retry path rather than only checking
models.update.await_count, and preserve verification that both writes are
attempted.
In `@services/core/models/tests/unit/controllers/test_provider_reconciler.py`:
- Around line 988-1008: Update
test_entity_cache_load_failure_prevents_any_entity_writes to invoke
reconciler.reconcile_model_providers after configuring the entity-cache load
failure, and assert the APIStatusError propagates while models.create and
models.update remain uncalled. Keep the test focused on proving reconciliation
aborts before any entity writes, rather than directly calling refresh and flush.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: e4a62d6e-d337-4e7c-93ca-a215ba8ce03a
📒 Files selected for processing (17)
packages/nmp_common/src/nmp/common/controller/__init__.pypackages/nmp_common/src/nmp/common/controller/controller.pypackages/nmp_common/src/nmp/common/controller/controller_manager.pypackages/nmp_common/tests/controller/test_controller.pypackages/nmp_common/tests/controller/test_controller_manager.pyservices/core/entities/src/nmp/core/entities/controllers/workspace_cleanup.pyservices/core/jobs/src/nmp/core/jobs/controllers/reconciler.pyservices/core/jobs/src/nmp/core/jobs/controllers/scheduler.pyservices/core/models/src/nmp/core/models/controllers/deployment_reconciler.pyservices/core/models/src/nmp/core/models/controllers/entity_cache.pyservices/core/models/src/nmp/core/models/controllers/models_controller.pyservices/core/models/src/nmp/core/models/controllers/provider_reconciler.pyservices/core/models/src/nmp/core/models/sidecars/adapters/main.pyservices/core/models/tests/unit/controllers/test_deployment_reconciler.pyservices/core/models/tests/unit/controllers/test_entity_cache.pyservices/core/models/tests/unit/controllers/test_models_controller_unit.pyservices/core/models/tests/unit/controllers/test_provider_reconciler.py
|
mckornfield
left a comment
There was a problem hiding this comment.
tried to call out some high level things, mostly q, yay readiness/liveness fixes
Reading and writing Model Entities are both proportional to the number of entities in play, and a pass that links a large batch spends most of its time inside those two loops. Neither reported progress, so a pass could work steadily for many seconds while appearing to have stalled, which is exactly what the liveness window is meant to distinguish. Report progress per entity read during a refresh and per entity applied during a flush, including when an individual entity could not be written, and per VirtualModel read while paginating. Reporting after a failed entity is deliberate: moving on to the next one is progress. Signed-off-by: Ben McCown <bmccown@nvidia.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
services/core/models/src/nmp/core/models/controllers/entity_cache.py (1)
203-213: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winPreserve
create_kwargswhen adopting a concurrent create.After
ConflictError,_create()retrieves the existing entity and calls_update(), but_update()only appliesfield_updatesand provider changes. Initial attributes supplied throughstage_create(..., **create_kwargs)are therefore silently lost.Merge the non-reserved create fields into the conflict update and add a conflict-path test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/core/models/src/nmp/core/models/controllers/entity_cache.py` around lines 203 - 213, The ConflictError branch in _create() must preserve non-reserved attributes from create_kwargs when adopting the concurrently created entity. Merge those create fields with the staged field updates before calling _update(), excluding fields handled separately such as provider changes or other reserved create parameters, and add a test covering stage_create(..., **create_kwargs) through the conflict path.services/core/models/src/nmp/core/models/controllers/models_controller.py (1)
465-478: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winFlush deployment mutations on every exit.
If
reconcile_deployments()orreconcile_orphans()raises or is cancelled, execution skips the normal flush._pendingthen remains populated, and the next step’srefresh()raisesUnflushedMutationsError, preventing recovery.Wrap the deployment reconciliation phase in
try/finallyand flush there while preserving the phase boundary.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@services/core/models/src/nmp/core/models/controllers/models_controller.py` around lines 465 - 478, The deployment reconciliation phase in the controller’s main flow must flush pending entity mutations on every exit. Wrap the calls to _deployment_reconciler.reconcile_deployments and reconcile_orphans in a try/finally block, placing the existing _entity_cache.flush() in the finally so it runs on success, exceptions, and cancellation while preserving the subsequent stop-signal and ERROR GC phase boundary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@services/core/models/src/nmp/core/models/controllers/entity_cache.py`:
- Around line 203-213: The ConflictError branch in _create() must preserve
non-reserved attributes from create_kwargs when adopting the concurrently
created entity. Merge those create fields with the staged field updates before
calling _update(), excluding fields handled separately such as provider changes
or other reserved create parameters, and add a test covering stage_create(...,
**create_kwargs) through the conflict path.
In `@services/core/models/src/nmp/core/models/controllers/models_controller.py`:
- Around line 465-478: The deployment reconciliation phase in the controller’s
main flow must flush pending entity mutations on every exit. Wrap the calls to
_deployment_reconciler.reconcile_deployments and reconcile_orphans in a
try/finally block, placing the existing _entity_cache.flush() in the finally so
it runs on success, exceptions, and cancellation while preserving the subsequent
stop-signal and ERROR GC phase boundary.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: c0f8d4bc-d587-4d2d-8cc7-beb3ffdc1061
📒 Files selected for processing (6)
services/core/models/src/nmp/core/models/controllers/entity_cache.pyservices/core/models/src/nmp/core/models/controllers/models_controller.pyservices/core/models/src/nmp/core/models/controllers/provider_reconciler.pyservices/core/models/tests/unit/controllers/test_deployment_reconciler.pyservices/core/models/tests/unit/controllers/test_entity_cache.pyservices/core/models/tests/unit/controllers/test_provider_reconciler.py
🚧 Files skipped from review as they are similar to previous changes (4)
- services/core/models/tests/unit/controllers/test_entity_cache.py
- services/core/models/tests/unit/controllers/test_deployment_reconciler.py
- services/core/models/tests/unit/controllers/test_provider_reconciler.py
- services/core/models/src/nmp/core/models/controllers/provider_reconciler.py
…orting Apply staged Model Entity changes on every exit from a reconciliation phase. A phase that returned early or raised skipped the flush, stranding its changes, and every later refresh then refused to run because work was still staged -- leaving the controller permanently unready. Keep a Model Entity change that fails to write so a later flush retries it. Unlinking a provider is derived from that provider, which is deleted immediately afterwards, so a dropped failure left a dangling reference that nothing would regenerate. Refresh now distinguishes a change no flush has attempted, which is still rejected, from one that was attempted and failed, which is replayed. Staged changes are differences rather than absolute state, so replaying them against a newer snapshot stays correct. Degrade rather than abort when the VirtualModel listing fails: skip VirtualModel creation and orphan cleanup for the pass and let discovery, provider status, and entity linking continue. Report progress for every iteration of the adapter directory sweep, including the staging directories handled by an early continue. Skip unlinking when the entity cache holds no snapshot, since a lookup miss is otherwise indistinguishable from the entity not existing. Say that a passthrough VirtualModel is being skipped because its name is taken rather than that it already exists; the name may belong to a user-managed one. Drive the liveness tests from a controlled clock instead of real threads and sleeps, use a concrete annotation for the heartbeat attribute, trim the class docstrings, name the entity-cache failure test after what it asserts, and share the cache test scaffolding through conftest. Signed-off-by: Ben McCown <bmccown@nvidia.com>
…alth check (#953) * fix(models): batch Model Entity reads and writes in the models controller The models controller resolved the Model Entity behind every served model one at a time and attempted a passthrough VirtualModel creation for each, so a single pass cost two round trips per served model per provider. With several providers exposing large catalogues the pass took long enough to exceed its own poll interval, and the loop's liveness window with it. Add ModelEntityCache, which reads the entities once per reconciliation phase and accumulates the intended changes. Each entity is written at most once and only when something actually differs, and the desired state is assembled in memory so contributions from several providers cannot overwrite one another. The cache is refreshed at the start of each phase that writes entities and flushed at the end of it, keeping the deployment and provider phases from deciding against state the other has already changed. Read the VirtualModels once per pass as well. The existence set covers every VirtualModel, so names held by user-managed ones are left alone, and orphan cleanup consumes the same snapshot instead of listing them again. Also give control loops a way to report incremental progress. A step that works through a large batch legitimately outruns three poll intervals, which the liveness check previously read as a stall. Controllers now report progress as each unit of work completes, so a slow-but-advancing pass stays healthy while a genuinely stuck one is still detected. Loop health changes are logged on transition, naming the loop and the reason. Signed-off-by: Ben McCown <bmccown@nvidia.com> * fix(models): report progress while reading and writing Model Entities Reading and writing Model Entities are both proportional to the number of entities in play, and a pass that links a large batch spends most of its time inside those two loops. Neither reported progress, so a pass could work steadily for many seconds while appearing to have stalled, which is exactly what the liveness window is meant to distinguish. Report progress per entity read during a refresh and per entity applied during a flush, including when an individual entity could not be written, and per VirtualModel read while paginating. Reporting after a failed entity is deliberate: moving on to the next one is progress. Signed-off-by: Ben McCown <bmccown@nvidia.com> * fix(models): address review feedback on entity cache and liveness reporting Apply staged Model Entity changes on every exit from a reconciliation phase. A phase that returned early or raised skipped the flush, stranding its changes, and every later refresh then refused to run because work was still staged -- leaving the controller permanently unready. Keep a Model Entity change that fails to write so a later flush retries it. Unlinking a provider is derived from that provider, which is deleted immediately afterwards, so a dropped failure left a dangling reference that nothing would regenerate. Refresh now distinguishes a change no flush has attempted, which is still rejected, from one that was attempted and failed, which is replayed. Staged changes are differences rather than absolute state, so replaying them against a newer snapshot stays correct. Degrade rather than abort when the VirtualModel listing fails: skip VirtualModel creation and orphan cleanup for the pass and let discovery, provider status, and entity linking continue. Report progress for every iteration of the adapter directory sweep, including the staging directories handled by an early continue. Skip unlinking when the entity cache holds no snapshot, since a lookup miss is otherwise indistinguishable from the entity not existing. Say that a passthrough VirtualModel is being skipped because its name is taken rather than that it already exists; the name may belong to a user-managed one. Drive the liveness tests from a controlled clock instead of real threads and sleeps, use a concrete annotation for the heartbeat attribute, trim the class docstrings, name the entity-cache failure test after what it asserts, and share the cache test scaffolding through conftest. Signed-off-by: Ben McCown <bmccown@nvidia.com> --------- Signed-off-by: Ben McCown <bmccown@nvidia.com>
Main dropped the legacy docker and k8s-nim-operator backends (#705) and replaced the reconcilers' per-entity Model Entity reads and writes with ModelEntityCache (#953, #951). The branch's changes to the deleted backends were import rewrites, so they go with the files. Everywhere the cache supersedes a direct read/write, main's semantics win and the calls it makes are routed through the typed client. ModelEntityCache arrived on main built on the umbrella SDK, which left models_controller holding a dangling self._models_sdk after the merge. It is migrated to AsyncModelsClient here, since leaving it on the SDK would make the branch's premise only half true. _load_virtual_models still reached for self._models_sdk. Its bare except Exception reported the AttributeError as a VirtualModel listing failure and skipped orphan cleanup, so the tests stayed green while the cleanup silently never ran. VirtualModel work stays on the umbrella SDK: it is an inference-gateway resource, not a Models one. The SDK exception types are aliased to say so. Signed-off-by: Max Dubrinsky <mdubrinsky@nvidia.com>
Problem
How control loops decide whether they are healthy
Background work in the platform runs as control loops. Each loop is a thread that repeats the same cycle forever: run one
step(), sleep for its poll interval, run the nextstep(). The models controller's interval defaults to 5 seconds.The framework needs some way to tell whether a loop is still alive, so it records a timestamp each time a step begins — a heartbeat. Readiness compares that timestamp against a deadline of
3 x interval, which for the models controller is 15 seconds. If the loop has not produced a heartbeat within 15 seconds it is reported unhealthy,/health/readyreturns 503, and Kubernetes takes the pod out of service.That works as long as a step reliably finishes well inside 15 seconds. The heartbeat was only sent once per step, so the deadline was really a bet that no step would ever take longer than three intervals.
What went wrong
Models controller steps were taking roughly 54 seconds. Since the only heartbeat came at the start of a step, the loop looked alive for the first 15 seconds of each cycle and stalled for the remaining ~44. Readiness therefore alternated between passing and failing on a roughly 59-second cycle, and the controller pod spent most of its time marked unhealthy and pulled out of the Service — even though it was working the whole time, just slowly.
There are two separate defects behind that, and this PR addresses both:
Fix 1: report progress more than once per step
A step that has 400 items to get through legitimately takes longer than three poll intervals. What matters for liveness is not "did the step finish" but "is the loop still getting somewhere". So controllers now report progress as each unit of work completes, rather than only when a step begins.
How it is put together:
Heartbeatholds a single timestamp meaning "last observed progress".HeartbeatMixingives a controller anemit_heartbeat()method to call as it finishes each item.TrackLastExecutionTimeowns theHeartbeatand hands it to the wrapped controller when it wraps it. Enteringstep()still counts as progress, so a controller that reports nothing behaves exactly as it did before and no existing call site changed.emit_heartbeatmethod rather than theHeartbeatitself, so they report progress without knowing anything about how liveness is measured.Loopis untouched. It already read this timestamp; the timestamp is simply updated more often now.The important property is that beats only ever follow completed work — never a timer, never around a call that might block. A single call that hangs forever still stops the clock and is still caught. Only slow-but-advancing work is now treated as healthy.
Wired into the jobs scheduler and reconciler, workspace cleanup, the models controller, and the adapters sidecar.
Separately, when a loop does go unhealthy it now says so in the log. Previously the only record was at DEBUG, so a pod failing readiness produced no visible explanation.
Looprecords why the check failed andControllerManagerlogs the change with the loop name and the reason, on transition only, so it is not repeated on every probe.Fix 2: make the models controller step faster
Where the time went
Provider reconciliation walks every model a provider serves and, for each one, makes sure two things exist: a Model Entity for it, and a passthrough VirtualModel pointing at that entity. It was doing this one model at a time:
GETto read the Model Entity, andPOSTattempting to create the VirtualModel, relying on a 409 conflict to mean "already there".In a development workspace with 4 providers each serving 102 models, that is 408 served models and 816 sequential API calls per step, every 5 seconds. At the measured ~60 ms per call that is most of a minute. In the steady state nothing needed changing, so essentially all of it was re-confirming state that was already correct.
Reading the same data in bulk instead is dramatically cheaper. Measured against that workspace:
page_size=200)What changed
ModelEntityCache(new) reads the Model Entities once per reconciliation phase and collects up the intended changes instead of writing them as it goes:refresh()refuses to re-read while changes are still staged, which makes the required ordering a failure rather than a conventionBoth deployment and provider reconciliation read and write these entities, and the deployment phase can remove a provider link that the provider phase would otherwise put back. So the cache is refreshed at the start of each phase that writes entities and flushed at the end of that phase, meaning neither phase ever decides based on state the other has already changed.
VirtualModels are read once per pass. Orphan cleanup was already listing all of them on every step, so reusing that read costs nothing and removes all 408 create attempts. The existence set covers every VirtualModel rather than only auto-provisioned ones, so a name already held by a user-managed VirtualModel is still left alone. Cleanup consumes the same snapshot and still runs after every provider, because it depends on the served models resolved during the pass.
Result
Per-step API calls no longer scale with the number of served models. In the steady state the pass drops from ~826 calls to roughly a dozen, and from ~54 seconds to a couple of seconds — comfortably inside the 15-second liveness deadline, with Fix 1 covering the cold-start case where there genuinely is a lot to create.
Notes for reviewers
update_statusnow lands before its entity links are written, since the flush moved to end-of-phase. Publishingserved_modelsindependently of entity initialization is existing behaviour.Testing
ModelEntityCache: overlay reads, refresh-while-staged rejection, single write for an entity linked by two providers, no write when converged, two providers creating one entity collapsing to a single create, conflict adoption, per-entity failure isolation, and a phase-ordering test that a removed link is not reinstated by the next phase.served_modelsunset so cleanup falls back to persisted values.ruff check,ruff format --check, andty checkare clean against baseline.Summary by CodeRabbit
New Features
Bug Fixes
Improvements