Skip to content

fix(nemo-deployments): race condition in controller#868

Open
ironcommit wants to merge 1 commit into
mainfrom
docker-never-status-race/rsadler
Open

fix(nemo-deployments): race condition in controller#868
ironcommit wants to merge 1 commit into
mainfrom
docker-never-status-race/rsadler

Conversation

@ironcommit

@ironcommit ironcommit commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • Bug Fixes
    • Improved deployment status reporting when Docker containers are removed, including recovery of prior container outcomes from recent Docker die events.
    • For restart policies like Never and OnFailure, the backend now restores exit codes/status when possible, respects backoff limits, and handles malformed event attributes more safely.
  • Tests
    • Added new integration and unit tests validating successful exit-code recovery, missing-event failure behavior, backoff/retry transitions, and warnings for invalid exitCode data.

@ironcommit
ironcommit requested review from a team as code owners July 23, 2026 18:20
@github-actions github-actions Bot added the fix label Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Docker status handling now recovers exit codes from recent Docker die events when Never or OnFailure containers have been removed. Unit and integration tests cover recovery, retries, malformed events, and fallback failure behavior.

Changes

Docker status recovery

Layer / File(s) Summary
Removed-container recovery logic
plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py
read_status queries recent matching die events, parses exit codes, applies OnFailure retry handling, maps recovered statuses, and falls back when recovery fails. Docker imports are moved to module scope.
Recovery validation
plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py, plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py
Tests cover successful recovery, retry and terminal failure states, malformed exit codes, absent events, and real-container integration behavior.

Sequence Diagram(s)

sequenceDiagram
  participant DockerDeploymentBackend
  participant DockerAPI
  participant map_exited_status
  DockerDeploymentBackend->>DockerAPI: Query recent die events
  DockerAPI-->>DockerDeploymentBackend: Return exitCode and restart attributes
  DockerDeploymentBackend->>map_exited_status: Map recovered exitCode
  map_exited_status-->>DockerDeploymentBackend: Return status update
Loading

Suggested labels: test

Suggested reviewers: benmccown, tylersbray

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Title check ⚠️ Warning The title mentions a controller race condition, but the changes fix Docker container status recovery after removal. Rename it to reflect the actual fix, e.g. "fix(nemo-deployments): recover removed Docker container status from events".
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docker-never-status-race/rsadler

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py (1)

124-188: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

No test coverage for restart_policy="OnFailure" removed-container recovery.

The new recovery logic in backend.py triggers for both "Never" and "OnFailure" (Line 290: if restart_policy in ("Never", "OnFailure")), but both test suites only exercise "Never".

  • plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py#L124-L188: add a mocked test with restart_policy="OnFailure" and a non-zero recovered exit code to verify the mapped status (and surface the backoff-bypass behavior flagged in backend.py).
  • plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py#L119-L162: add an OnFailure-policy variant of the removed-container integration test using a failing command.
🤖 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 `@plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py`
around lines 124 - 188, Extend the mocked tests around
test_read_status_recovers_removed_never_container_exit_code and
test_read_status_failed_when_missing_never_has_no_exit_event in
plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py
(lines 124-188) with an OnFailure case using a non-zero recovered Docker
die-event exit code and assertions for its mapped status and exit code. Add the
corresponding OnFailure removed-container integration-test variant using a
failing command in
plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py
(lines 119-162).
🤖 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
`@plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py`:
- Around line 399-418: The _read_removed_container_status method must preserve
OnFailure retry behavior instead of directly using map_exited_status for
non-zero exits. Apply the same restart_count/backoff_limit handling used for
in-place container exits so eligible failures return STARTING and retries
continue, while exhausted retries retain the terminal FAILED status.
- Around line 399-453: The current _latest_removed_container_exit_code method
depends on Docker’s bounded one-hour event replay and can miss recent container
exits. Capture and retain each container’s exit code when NotFound is first
detected during read_status, or maintain an equivalent live per-container
die-event cache, then have _read_removed_container_status use that recorded
value before falling back to missing_container_status; preserve existing status
mapping and recovery metadata.

---

Nitpick comments:
In `@plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py`:
- Around line 124-188: Extend the mocked tests around
test_read_status_recovers_removed_never_container_exit_code and
test_read_status_failed_when_missing_never_has_no_exit_event in
plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py
(lines 124-188) with an OnFailure case using a non-zero recovered Docker
die-event exit code and assertions for its mapped status and exit code. Add the
corresponding OnFailure removed-container integration-test variant using a
failing command in
plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py
(lines 119-162).
🪄 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: 2a4007df-5e5c-4fdd-a755-a09492bea6f1

📥 Commits

Reviewing files that changed from the base of the PR and between d206ae9 and affb68f.

📒 Files selected for processing (3)
  • plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py
  • plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py
  • plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 27157/34869 77.9% 62.1%
Integration Tests 15970/33581 47.6% 20.0%

@ironcommit
ironcommit force-pushed the docker-never-status-race/rsadler branch from affb68f to fd3772a Compare July 24, 2026 19:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 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
`@plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py`:
- Around line 633-682: Update _read_removed_container_status to accept a dep_key
parameter and release the GPU via self._gpu_pool.release_gpu(dep_key) whenever
recovered exit handling reaches a terminal status, including success and
non-retryable failure, while preserving the retry/STARTING path without release.
Pass deployment_key(workspace, name) from the read_status call site.
- Around line 656-671: Update the OnFailure handling in backend.py around the
restart_policy check to read RestartCount from cached inspect state or another
persisted pre-removal source rather than die-event attrs, preserving the
backoff-limit behavior. In test_backend_mocked.py lines 507-543 and 546-581,
update both unit cases to provide/assert the real persisted restart-count signal
instead of placing restartCount in event attributes.
🪄 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: 098f3f89-e0e0-4944-aa9a-0515eea3c3e6

📥 Commits

Reviewing files that changed from the base of the PR and between affb68f and fd3772a.

📒 Files selected for processing (3)
  • plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py
  • plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py
  • plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py

Comment on lines +633 to +682
async def _read_removed_container_status(
self,
*,
container_name: str,
restart_policy: RestartPolicy,
) -> BackendStatusUpdate | None:
attrs = await asyncio.to_thread(self._latest_removed_container_attrs, container_name)
if attrs is None:
return None

exit_code = _int_attr(attrs, "exitCode")
if exit_code is None:
raw_exit_code = attrs.get("exitCode")
if raw_exit_code is not None:
logger.warning(
"Docker die event for removed container %s has invalid exitCode %r",
container_name,
raw_exit_code,
)
return None
logger.warning("Docker die event for removed container %s has no exitCode", container_name)
return None

if restart_policy == "OnFailure" and exit_code != 0:
restart_count = _int_attr(attrs, "RestartCount", "restartCount", default=0)
backoff_limit = _int_attr(attrs, BACKOFF_LIMIT_LABEL, default=6)
if restart_count < backoff_limit:
return BackendStatusUpdate(
status="STARTING",
status_message=(
f"Container exited (code {exit_code}) before it was removed; "
f"retry {restart_count}/{backoff_limit}"
),
exit_code=exit_code,
error_details={
"expected_container_name": container_name,
"recovered_from": "docker_die_event",
},
)

status = map_exited_status(exit_code, restart_policy)
return BackendStatusUpdate(
status=status,
status_message=f"Container exited with code {exit_code} before it was removed",
exit_code=exit_code,
error_details={
"expected_container_name": container_name,
"recovered_from": "docker_die_event",
},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

GPU never released when recovery reaches a terminal state.

The in-place exited path (lines 536-575) explicitly calls self._gpu_pool.release_gpu(dep_key) on every terminal outcome (success, non-retryable failure). _read_removed_container_status mirrors that terminal-state logic (recovered exit code → SUCCEEDED/FAILED) but never releases the GPU. A Never/OnFailure deployment whose container is removed and recovered as terminal will hold its GPU allocation until delete_deployment is eventually called, unlike the in-place path which frees it immediately on detecting the terminal state.

_read_removed_container_status doesn't receive workspace/name, but both are available at the call site in read_status (lines 466-472).

🔧 Proposed fix: release GPU on recovered terminal states
     async def _read_removed_container_status(
         self,
         *,
         container_name: str,
         restart_policy: RestartPolicy,
+        dep_key: str | None = None,
     ) -> BackendStatusUpdate | None:
         attrs = await asyncio.to_thread(self._latest_removed_container_attrs, container_name)
         if attrs is None:
             return None
@@
         if restart_policy == "OnFailure" and exit_code != 0:
             restart_count = _int_attr(attrs, "RestartCount", "restartCount", default=0)
             backoff_limit = _int_attr(attrs, BACKOFF_LIMIT_LABEL, default=6)
             if restart_count < backoff_limit:
                 return BackendStatusUpdate(
                     status="STARTING",
                     ...
                 )
 
+        if dep_key is not None and self._gpu_pool is not None:
+            self._gpu_pool.release_gpu(dep_key)
         status = map_exited_status(exit_code, restart_policy)

And pass dep_key=deployment_key(workspace, name) from the read_status call site.

🤖 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
`@plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py`
around lines 633 - 682, Update _read_removed_container_status to accept a
dep_key parameter and release the GPU via self._gpu_pool.release_gpu(dep_key)
whenever recovered exit handling reaches a terminal status, including success
and non-retryable failure, while preserving the retry/STARTING path without
release. Pass deployment_key(workspace, name) from the read_status call site.

Comment on lines +656 to +671
if restart_policy == "OnFailure" and exit_code != 0:
restart_count = _int_attr(attrs, "RestartCount", "restartCount", default=0)
backoff_limit = _int_attr(attrs, BACKOFF_LIMIT_LABEL, default=6)
if restart_count < backoff_limit:
return BackendStatusUpdate(
status="STARTING",
status_message=(
f"Container exited (code {exit_code}) before it was removed; "
f"retry {restart_count}/{backoff_limit}"
),
exit_code=exit_code,
error_details={
"expected_container_name": container_name,
"recovered_from": "docker_die_event",
},
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and inspect the surrounding code.
ast-grep outline plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py --view expanded || true
sed -n '620,700p' plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py

echo
echo "---- tests ----"
sed -n '480,610p' plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py

echo
echo "---- restart count references ----"
rg -n "RestartCount|restartCount|BACKOFF_LIMIT_LABEL|die event|events.return_value" plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker plugins/nemo-deployments/tests/unit/backends/docker -S

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 12888


🌐 Web query:

Docker die event RestartCount event attributes docker events inspect RestartCount

💡 Result:

In Docker, the RestartCount is not an attribute directly available in the event stream for a die event [1]. Instead, it is a property tracked within the container's state, which can be retrieved by inspecting the container [1][2][3]. Docker Events and RestartCount The die event signifies that a container has stopped [4]. While die events do carry attributes—such as exitCode and execDuration [5]—they do not natively include the current RestartCount [1][5]. As of recent updates (circa 2025), a restarting attribute may be added to die events to indicate that a container is scheduled to restart [6], but this differs from providing the numerical restart count itself. To track restarts via events, you must monitor the stream and maintain a counter externally, or use the docker inspect command to query the current state [1]. Retrieving RestartCount via docker inspect The RestartCount is a field within the container's low-level configuration, which you can access using the docker inspect command [1][3][7]. You can retrieve the current restart count for a container using the --format flag: docker inspect --format '{{.RestartCount}}' <container_id_or_name> You can also include it in a formatted list of multiple containers [1]: docker inspect --format "ID: {{.ID}} | Name: {{.Name}} | Restarts: {{.RestartCount}}" $(docker ps -aq) Note that the RestartCount is a volatile counter managed by the Docker daemon [1]. If the Docker daemon restarts or the host reboots, the RestartCount for existing containers is typically reset to 0 [1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '520,575p' plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py
echo
sed -n '684,750p' plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py
echo
sed -n '450,640p' plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py

Repository: NVIDIA-NeMo/nemo-platform

Length of output: 11889


Read RestartCount from inspect state, not removed-container event data
die events don’t include restartCount, so this path falls back to 0 on a real daemon and keeps OnFailure containers in STARTING instead of failing once the backoff limit is hit. The two unit tests also fake restartCount in event attributes, so they don’t exercise the real payload.

  • plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py#L656-L671: source the count from cached inspect state or another persisted source before removal.
  • plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py#L507-L581: update both cases to use the real signal.
📍 Affects 2 files
  • plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py#L656-L671 (this comment)
  • plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py#L507-L543
  • plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py#L546-L581
🤖 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
`@plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py`
around lines 656 - 671, Update the OnFailure handling in backend.py around the
restart_policy check to read RestartCount from cached inspect state or another
persisted pre-removal source rather than die-event attrs, preserving the
backoff-limit behavior. In test_backend_mocked.py lines 507-543 and 546-581,
update both unit cases to provide/assert the real persisted restart-count signal
instead of placing restartCount in event attributes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant