Skip to content

fix(nemo-deployments): observe one-shot Docker status at create time - #925

Merged
tylersbray merged 8 commits into
mainfrom
oneshot-observe-at-create/tbray
Jul 29, 2026
Merged

fix(nemo-deployments): observe one-shot Docker status at create time#925
tylersbray merged 8 commits into
mainfrom
oneshot-observe-at-create/tbray

Conversation

@tylersbray

@tylersbray tylersbray commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

  • For restart_policy=Never, wait for the primary container to exit at create time and return SUCCEEDED/FAILED with the exit code (bounded by docker_timeout).
  • For OnFailure, immediately inspect after create; map already-exited containers with the same backoff logic as read_status.
  • Shares exit mapping via _status_from_exited_container so create and read stay aligned; no die-event replay and no e2e sleep workaround.

Addresses the fast-exit flake discussed for e2e/test_nemo_deployments_docker.py (and related to the approach in #868) by observing live container state instead of reconstructing status from Docker events.

Test plan

  • Unit: plugins/nemo-deployments/tests/unit/backends/docker/ (73 passed)
  • Integration against a real Docker daemon: plugins/nemo-deployments/tests/integration/backends/docker/ (3 passed)
  • Optional: e2e test_docker_job_deployment_reaches_succeeded with the existing fast alpine workload (no sleep)

Summary by CodeRabbit

  • New Features
    • One-shot deployments can now return SUCCEEDED/FAILED immediately after container creation when the container has already exited.
    • Added oneshot_observe_timeout_seconds (default 5, min 1) to limit the initial observe window before deferring to later status polling.
  • Bug Fixes
    • Improved Never/OnFailure status mapping using exit code, restart count, and backoff-related retry decisions.
    • More robust handling of init/one-shot termination timeouts or communication issues, including improved cleanup on unexpected monitoring failures.
  • Tests
    • Expanded Docker backend unit/integration and idempotency coverage for the above scenarios.

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

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Docker deployment creation now observes one-shot containers immediately, centralizes exited-container status mapping, handles wait failures and cleanup, and adds configurable observation timeouts with policy-specific tests.

Changes

Docker one-shot status handling

Layer / File(s) Summary
Immediate observation and exited-status mapping
plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py
create_deployment observes one-shot containers after creation, while shared logic maps exit codes, restart policies, backoff, GPU pool release, and cleanup outcomes.
Observation configuration and restart-policy coverage
plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/config.py, plugins/nemo-deployments/tests/unit/backends/docker/test_backend_mocked.py, plugins/nemo-deployments/tests/unit/backends/docker/test_executor_config.py
Adds the configurable one-shot observation timeout and tests terminal, retrying, running, timeout, connection-error, cleanup, and Always behavior.
Integration and recreation assertions
plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py, plugins/nemo-deployments/tests/unit/backends/docker/test_idempotency.py
Integration and idempotency tests assert immediate Never results, delayed completion after observation timeout, and policy-specific recreation behavior.

Sequence Diagram(s)

sequenceDiagram
  participant create_deployment
  participant DockerContainer
  participant _observe_one_shot_primary_after_create
  participant _status_from_exited_container
  create_deployment->>DockerContainer: start primary container
  create_deployment->>_observe_one_shot_primary_after_create: observe restart policy
  _observe_one_shot_primary_after_create->>DockerContainer: wait or reload state
  _observe_one_shot_primary_after_create->>_status_from_exited_container: map exited state
  _status_from_exited_container-->>create_deployment: BackendStatusUpdate
Loading

Possibly related PRs

Suggested labels: test

Suggested reviewers: mikeknep, ironcommit, benmccown

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: observing one-shot Docker container status during create.
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 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch oneshot-observe-at-create/tbray

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.

🧹 Nitpick comments (1)
plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py (1)

607-609: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate exit-code parsing.

_wait_for_exit's StatusCode extraction duplicates _run_and_wait (lines 429-432). Extract a shared _exit_code_from_wait_result(result) helper.

♻️ Proposed refactor
+    `@staticmethod`
+    def _exit_code_from_wait_result(result: dict[str, Any] | int) -> int:
+        return int(result.get("StatusCode", 1)) if isinstance(result, dict) else int(result)
+
     def _run_and_wait() -> int:
         container = self._client.containers.run(**run_kwargs)
         result = container.wait(timeout=self._executor_config.docker_timeout)
-        exit_code = int(result.get("StatusCode", 1)) if isinstance(result, dict) else int(result)
+        exit_code = self._exit_code_from_wait_result(result)
🤖 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 607 - 609, Extract the duplicated wait-result parsing from
_wait_for_exit and _run_and_wait into a shared
_exit_code_from_wait_result(result) helper. Update both callers to use this
helper while preserving the existing StatusCode default of 1 and integer
conversion for non-dictionary results.
🤖 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.

Nitpick comments:
In
`@plugins/nemo-deployments/src/nemo_deployments_plugin/backends/docker/backend.py`:
- Around line 607-609: Extract the duplicated wait-result parsing from
_wait_for_exit and _run_and_wait into a shared
_exit_code_from_wait_result(result) helper. Update both callers to use this
helper while preserving the existing StatusCode default of 1 and integer
conversion for non-dictionary results.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 50ed0d25-22e8-4422-aaf7-58e0d6f0bd36

📥 Commits

Reviewing files that changed from the base of the PR and between ee4dfd8 and 842dace.

📒 Files selected for processing (4)
  • 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
  • plugins/nemo-deployments/tests/unit/backends/docker/test_idempotency.py

@github-actions

github-actions Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 28099/35888 78.3% 62.7%
Integration Tests 16327/34606 47.2% 19.6%

@benmccown benmccown 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.

Docker SDK timeout is 600 seconds by default. If you have a longer running one-shot container (like the model weights puller) this is going to block the reconciler loop and it also might take longer than that 600 second timeout.

@tylersbray

Copy link
Copy Markdown
Contributor Author

@benjamin-mccown Good catch — you're right that using docker_timeout (600s default) for the Never container.wait() would block the serial reconciler on long one-shots like model weight pullers.

Pushed a follow-up: added DockerExecutorConfig.oneshot_observe_timeout_seconds (default 5s, aligned with controller interval_seconds). Never jobs still get a short create-time wait for fast exits (alpine/echo); if they're still running after that bound we return STARTING and finish via existing read_status polling + retention. docker_timeout stays for pulls/init; OnFailure remains inspect-only.

@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: 1

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

73-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate backend-construction boilerplate.

_docker_backend_with_observe_timeout re-implements the same three patch(...) context managers as docker_backend() (lines 35-45), differing only by the extra config key. Consider having docker_backend()/a shared helper accept optional **config_overrides to avoid drift between the two.

♻️ Example consolidation
-def docker_backend() -> DockerDeploymentBackend:
+def _build_docker_backend(**config_overrides: Any) -> DockerDeploymentBackend:
     mock_entities = AsyncMock()
     mock_sdk = MagicMock()
     with (
         patch("nemo_deployments_plugin.backends.docker.backend.AsyncEntitiesResource"),
         patch("nemo_deployments_plugin.backends.docker.backend.NemoEntitiesClient", return_value=mock_entities),
         patch("nemo_deployments_plugin.backends.docker.backend.get_shared_gpu_pool", return_value=None),
     ):
-        backend = DockerDeploymentBackend(mock_sdk, {"pull_images": True})
+        backend = DockerDeploymentBackend(mock_sdk, {"pull_images": True, **config_overrides})
     backend._entities = mock_entities
     return backend
+
+
+def docker_backend() -> DockerDeploymentBackend:
+    return _build_docker_backend()
+
+
+def _docker_backend_with_observe_timeout(*, oneshot_observe_timeout_seconds: int) -> DockerDeploymentBackend:
+    return _build_docker_backend(oneshot_observe_timeout_seconds=oneshot_observe_timeout_seconds)
🤖 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/integration/backends/docker/test_docker_backend.py`
around lines 73 - 93, Consolidate the duplicate backend setup by updating the
existing docker_backend helper, or introducing a shared construction helper, to
accept optional config overrides. Refactor _docker_backend_with_observe_timeout
to reuse that helper while supplying oneshot_observe_timeout_seconds, preserving
the existing patches and default configuration.
🤖 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/tests/integration/backends/docker/test_docker_backend.py`:
- Around line 168-180: The timing assertion around create_deployment in the
observe-wait integration test incorrectly includes image-pull latency. Adjust
the test setup or measurement so create_elapsed isolates the observe-wait
behavior while retaining the status assertions, and avoid enforcing the 3-second
threshold against uncached alpine image pulls.

---

Nitpick comments:
In
`@plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py`:
- Around line 73-93: Consolidate the duplicate backend setup by updating the
existing docker_backend helper, or introducing a shared construction helper, to
accept optional config overrides. Refactor _docker_backend_with_observe_timeout
to reuse that helper while supplying oneshot_observe_timeout_seconds, preserving
the existing patches and default configuration.
🪄 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: 0ca5b071-6ddc-4dcd-9922-fc8150407447

📥 Commits

Reviewing files that changed from the base of the PR and between 0196abc and 1a7e6ac.

📒 Files selected for processing (1)
  • plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py

Comment thread plugins/nemo-deployments/tests/integration/backends/docker/test_docker_backend.py Outdated
@tylersbray
tylersbray force-pushed the oneshot-observe-at-create/tbray branch from 1a7e6ac to 8cdd674 Compare July 29, 2026 01:54
Never jobs wait for exit and return SUCCEEDED/FAILED immediately; OnFailure
inspects after create. Avoids the fast-exit race without die-event replay or
e2e sleeps.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
Use DockerExecutorConfig.oneshot_observe_timeout_seconds (default 5s)
instead of docker_timeout for create-time Never container.wait so long
pullers do not block the serial reconciler loop.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
Add a real-Docker integration test with oneshot_observe_timeout_seconds=1
and alpine sleep 5 so create returns STARTING quickly and read_status
later reports SUCCEEDED — the long puller path.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
The observe-wait integration test timed the whole create_deployment call
with pull_images enabled, so an uncached alpine pull could push it past
the 3s bound for reasons unrelated to the observe timeout. Warm the image
cache first and bound the assertion by the configured observe timeout.

Also dedupe container.wait() exit-code parsing into
_exit_code_from_wait_result and fold the two integration backend builders
into one helper that takes config overrides.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
@tylersbray
tylersbray force-pushed the oneshot-observe-at-create/tbray branch from 8cdd674 to a1c2d4c Compare July 29, 2026 18:59
The Docker host-port allocator could hand out a port another container was
already publishing, so `create_deployment` failed with "port is already
allocated". Three defects combined:

- The free-port probe bound `127.0.0.1` with `SO_REUSEADDR`, which reports a
  port free even while another process holds `0.0.0.0:<port>`. It now binds the
  wildcard address Docker publishes on, without `SO_REUSEADDR`.
- The container scan filtered on the platform's managed-by label, so containers
  the platform does not own (a test-fixture ClickHouse on 9000, say) were
  invisible. Every container on the daemon competes for host ports.
- Docker's own reservations are not observable until a publish is attempted, so
  a concurrent create can still win the race. That publish failure is now
  retried on a different port, and the container left behind by the failed
  start is removed first (`containers.run` creates then starts).

This was surfacing as an integration-test flake: the deployments Docker tests
allocate from the 9000-9100 default while other suites publish ClickHouse on
9000. Those tests now use a range nothing else in CI claims.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
@tylersbray

Copy link
Copy Markdown
Contributor Author

Added one more commit (a80002c70) that fixes the host-port collision behind the integration-test failures we saw on this PR. Bundling it here rather than in a separate PR because it lands in the same Docker create path this PR already changes.

What was breaking. test_lost_detection_for_always failed with Bind for 0.0.0.0:9000 failed: port is already allocated. The deployments Docker tests allocate from the 9000-9100 default range, and the intake/evaluator suites publish ClickHouse on 9000 on the same daemon. Under pytest-xdist those run concurrently. Reproducible on main, not caused by this PR.

Three defects, all in the allocator:

  1. is_port_free bound 127.0.0.1 with SO_REUSEADDR. Both are wrong for checking whether Docker can publish: Docker binds 0.0.0.0, and SO_REUSEADDR lets the probe succeed against a port a wildcard publisher already holds. It now binds 0.0.0.0 with no SO_REUSEADDR.
  2. find_available_port listed containers filtered by our managed-by label, so a ClickHouse container was invisible to it. Every container on the daemon competes for host ports; resource_scope governs ownership and cleanup, not port safety.
  3. Docker's own reservations aren't observable until you attempt the publish, so a concurrent create can still win the race no matter how good the probe is. The publish failure is now retried on a different port (bounded, 3 attempts). Note containers.run creates then starts, so a failed start leaves a container holding the name — that gets removed before the retry. That leak existed before this fix too.

Fix 1 is what actually closes the observed failure; 2 and 3 close the remaining windows.

Verification. With a container publishing 0.0.0.0:9000, the old probe reports the port free and the new one doesn't:

old probe says 9000 free: True
new probe says 9000 free: False
allocator picks: 9001

The Docker integration tests pass with 9000 occupied. Unit coverage added for the wildcard probe, the foreign-container scan, and all three retry outcomes (reallocate and succeed, exhaust attempts, don't retry unrelated failures.

Separately, the deployments Docker integration tests now use 21000-21100 instead of the 9000-9100 product default, so they don't contend with ClickHouse even when the allocator is right. The product default is unchanged.
EOF
)

Keep the corrected no-SO_REUSEADDR behavior while avoiding a wildcard bind that CodeQL flags as externally exposed. A Docker wildcard publisher still blocks the loopback probe.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
Avoid collisions with well-known service ports by allocating Docker deployments from the IANA dynamic/private range by default.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
@tylersbray

Copy link
Copy Markdown
Contributor Author

Follow-up to the host-port allocator fix: after discussing the product default with Ben, commit b732f70c89 moves Docker deployment allocation from 9000-9100 to 49152-49251 (100 ports in the IANA dynamic/private range).

This avoids the standing ClickHouse/native-port collision by default while preserving roughly the same capacity. The allocator scan/retry fixes remain necessary for arbitrary collisions. Updated the executor default, platform/e2e configs, docs, and default-value test; existing deployments and explicit executor overrides are unaffected.

Validation: focused executor-config tests passed, and the full pre-commit/pre-push hooks passed.

The port-conflict retry tests hardcoded the old 9000-based default, so they broke when the default range moved. Read the configured range start instead.

Signed-off-by: Tyler Bray <tbray@nvidia.com>
@tylersbray
tylersbray added this pull request to the merge queue Jul 29, 2026
Merged via the queue into main with commit 1432732 Jul 29, 2026
56 checks passed
@tylersbray
tylersbray deleted the oneshot-observe-at-create/tbray branch July 29, 2026 22:20
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.

3 participants