Skip to content

feat(dhcp): self-contained heartbeat liveness for config-sync sidecar#171

Open
dmathur0 wants to merge 1 commit into
mainfrom
davesh/dhcp-sync-heartbeat-liveness
Open

feat(dhcp): self-contained heartbeat liveness for config-sync sidecar#171
dmathur0 wants to merge 1 commit into
mainfrom
davesh/dhcp-sync-heartbeat-liveness

Conversation

@dmathur0

@dmathur0 dmathur0 commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Summary

Gives the config-sync-v4 sidecar its own liveness signal so a genuinely wedged reconcile loop is restarted by kubelet, without adding an HTTP server and without restart-looping during external dependency outages.

The sync loop now touches a heartbeat file after every completed reconcile attempt (advancing even on recoverable Redis/PG/Kea errors, but never advancing if an iteration is truly stuck). A new check-sync-heartbeat CLI subcommand backs an exec livenessProbe. Dependency calls are bounded by timeouts, and "last successful reconciliation" is tracked separately from loop progress.

Changes

  • heartbeat.py (new): touch/age/freshness helpers + separate last-successful-reconciliation state.
  • cli.py: heartbeat touches (seed after initial set_config, then in the finally of every loop iteration) + bounded timeouts in the sync loop; new check-sync-heartbeat command; --heartbeat-file on sync-kea-configuration.
  • deploy/helm/templates/dhcp.yaml: config-sync-v4 HTTP probe → exec heartbeat probe.

check-sync-heartbeat

  • --heartbeat-file (default /tmp/config-sync-v4.heartbeat, env CONFIG_SYNC_HEARTBEAT_FILE)
  • --max-age seconds (default 90, env CONFIG_SYNC_HEARTBEAT_MAX_AGE)
  • Exit 0 only when the file exists and age ≤ max-age; exit 1 when stale/missing.

Timeouts (via asyncio.wait_for; timeout = recoverable error → logged/counted, heartbeat still advances)

  • Redis load_kea_config: 10s (CONFIG_SYNC_REDIS_TIMEOUT)
  • Kea set_config: 15s (CONFIG_SYNC_KEA_TIMEOUT)

Helm probe

exec: [nv-config-manager-dhcp-confgen, check-sync-heartbeat], initialDelaySeconds: 180, periodSeconds: 30, timeoutSeconds: 10, failureThreshold: 10 (300s — looser than kea/api at 100s).

Test plan

  • CLI fresh / stale / missing exit codes
  • loop advances heartbeat on success
  • loop advances after a recoverable error without counting it as success
  • hung Redis call bounded (<2s) and heartbeat still advances
  • 9/9 new tests pass; 105/106 dhcp suite (1 pre-existing Docker-only failure)
  • ruff + mypy clean; helm template renders

Part 2 of 5 from the DHCP config-sync recovery proposal, opened independently against main.

Merge note

Both cli.py (_sync_kea_configuration_async) and deploy/helm/templates/dhcp.yaml are also touched by #168 (Part 1), #170 (Part 3), and Part 4. Whichever merges later needs a small rebase — heartbeat advancement should wrap Part 1's hash-reconcile body, and the config-sync-v4 probe here (exec) replaces the HTTP probe Part 3 repoints to /livez.

Summary by CodeRabbit

  • New Features

    • Added heartbeat-based health monitoring for DHCP configuration synchronization.
    • Added a command to verify whether synchronization is active and responsive.
    • Added configurable timeouts to prevent stalled Redis or KEA operations from blocking synchronization.
    • Synchronization now continues after recoverable dependency errors and reports heartbeat status accordingly.
  • Bug Fixes

    • Improved container liveness detection by checking synchronization progress directly instead of relying on a shared health endpoint.
  • Tests

    • Added coverage for heartbeat freshness, missing or stale heartbeats, recovery behavior, and stalled dependencies.

Replace the config-sync-v4 backstop HTTP livenessProbe (which hit the shared
healthcheck sidecar) with a self-contained heartbeat mechanism so the sidecar's
liveness depends only on its own event-loop progress.

- Touch a heartbeat file (default /tmp/config-sync-v4.heartbeat) after every
  completed reconcile attempt in _sync_kea_configuration_async. The heartbeat
  represents event-loop progress, not successful config application: recoverable
  Redis/PostgreSQL/Kea errors are logged/counted but still advance it, while a
  genuinely wedged loop stops advancing it.
- Add a check-sync-heartbeat CLI subcommand (--heartbeat-file, --max-age; env
  CONFIG_SYNC_HEARTBEAT_FILE / CONFIG_SYNC_HEARTBEAT_MAX_AGE; default max-age
  90s) that exits 0 only when the file is fresh and non-zero when stale/missing.
- Bound the Redis load and Kea set_config calls with asyncio.wait_for (10s /
  15s, overridable via CONFIG_SYNC_REDIS_TIMEOUT / CONFIG_SYNC_KEA_TIMEOUT) so a
  hung dependency cannot suspend the loop and freeze the heartbeat.
- Track last-successful-reconciliation separately from loop progress (minimal
  module-level state) for later readiness/alerting use.
- Swap the Helm config-sync-v4 livenessProbe to an exec probe running
  check-sync-heartbeat, keeping thresholds looser than the kea/api probes.

Adds unit tests for the CLI exit codes, heartbeat advancement on success and on
recoverable errors, and bounded-timeout behavior.

Signed-off-by: Davesh Mathur <daveshm@nvidia.com>
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The DHCP config-sync loop now uses bounded Redis and KEA operations, maintains a heartbeat file, and records successful reconciliations. A new CLI command validates heartbeat freshness, and the Helm liveness probe invokes it. Tests cover heartbeat behavior, CLI outcomes, recoverable errors, and hung dependencies.

DHCP config-sync liveness

Layer / File(s) Summary
Heartbeat state and freshness contract
src/nv_config_manager/dhcp/heartbeat.py, src/tests/dhcp/test_heartbeat.py
Adds heartbeat-file creation, age and freshness checks, successful-reconciliation tracking, and unit tests for these primitives.
Bounded reconciliation and heartbeat CLI
src/nv_config_manager/dhcp/cli.py, src/tests/dhcp/test_heartbeat.py
Bounds Redis and KEA operations, updates heartbeat state after reconciliation attempts, adds heartbeat CLI options and the freshness-check command, and tests success, error, and timeout paths.
Exec probe deployment wiring
deploy/helm/templates/dhcp.yaml
Changes the config-sync-v4 liveness probe from HTTP to the heartbeat-check exec command and sets its timeout to 10 seconds.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConfigSync
  participant Redis
  participant KEA
  participant HeartbeatFile
  ConfigSync->>Redis: Load cached KEA configuration with timeout
  Redis-->>ConfigSync: Configuration or recoverable error
  ConfigSync->>KEA: Apply configuration with timeout
  KEA-->>ConfigSync: Apply result
  ConfigSync->>HeartbeatFile: Touch heartbeat after each attempt
  ConfigSync->>HeartbeatFile: Record successful reconciliation
Loading

Possibly related PRs

Suggested reviewers: jojung1, polarweasel, ryanheffernan

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: a self-contained heartbeat-based liveness probe for the DHCP config-sync sidecar.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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 davesh/dhcp-sync-heartbeat-liveness

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 Checkov (3.3.8)
deploy/helm/templates/dhcp.yaml

Traceback (most recent call last):
File "/usr/local/bin/checkov", line 2, in
from checkov.main import Checkov
ModuleNotFoundError: No module named 'checkov'


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

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
deploy/helm/templates/dhcp.yaml (1)

191-213: 📐 Maintainability & Code Quality | 🔵 Trivial

Validate this probe change with helm template before merge.

As per coding guidelines, dry-run this change with helm template ... -f values-ci.yaml (layering values-observability.yaml if exercising the observability stack) rather than values.yaml alone, since values.yaml needs secrets/vault paths unavailable in CI.

🤖 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 `@deploy/helm/templates/dhcp.yaml` around lines 191 - 213, Validate the updated
livenessProbe template with helm template using values-ci.yaml, layering
values-observability.yaml when exercising the observability stack; do not
validate against values.yaml alone because it requires unavailable CI secrets
and vault paths.

Source: Coding guidelines

src/nv_config_manager/dhcp/heartbeat.py (1)

64-74: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Only FileNotFoundError is treated as "missing"; other OSErrors will crash the CLI.

If os.stat fails for a reason other than a missing file (e.g., permission denied, ENOTDIR), the exception propagates uncaught through check-sync-heartbeat, producing a raw traceback instead of the intended clean stale/missing verdict for the liveness probe. Broadening the catch is a small, cheap hardening given this function backs a livenessProbe exec check.

🛡️ Proposed fix
     try:
         mtime = os.stat(path).st_mtime
-    except FileNotFoundError:
+    except OSError:
         return None
🤖 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 `@src/nv_config_manager/dhcp/heartbeat.py` around lines 64 - 74, Update
heartbeat_age_seconds to catch the broader OSError from os.stat, not only
FileNotFoundError, and return None for any stat failure so check-sync-heartbeat
preserves its clean stale/missing liveness result without an uncaught traceback.
🤖 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 `@src/nv_config_manager/dhcp/cli.py`:
- Around line 256-275: Update the initial config-wait loop in the reconciliation
flow to call touch_heartbeat(heartbeat_file) on each polling iteration while
config is None, before sleeping or retrying _load_kea_config_with_timeout.
Preserve the existing heartbeat update after successful configuration
application so both waiting and completed states advance the liveness marker.

---

Nitpick comments:
In `@deploy/helm/templates/dhcp.yaml`:
- Around line 191-213: Validate the updated livenessProbe template with helm
template using values-ci.yaml, layering values-observability.yaml when
exercising the observability stack; do not validate against values.yaml alone
because it requires unavailable CI secrets and vault paths.

In `@src/nv_config_manager/dhcp/heartbeat.py`:
- Around line 64-74: Update heartbeat_age_seconds to catch the broader OSError
from os.stat, not only FileNotFoundError, and return None for any stat failure
so check-sync-heartbeat preserves its clean stale/missing liveness result
without an uncaught traceback.
🪄 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: d6f0390b-0c58-4395-bf4b-6855d491c277

📥 Commits

Reviewing files that changed from the base of the PR and between 784510c and 4120063.

📒 Files selected for processing (4)
  • deploy/helm/templates/dhcp.yaml
  • src/nv_config_manager/dhcp/cli.py
  • src/nv_config_manager/dhcp/heartbeat.py
  • src/tests/dhcp/test_heartbeat.py

Comment on lines 256 to +275
try:
config = await redis_client.load_kea_config(ip_version)
config = await _load_kea_config_with_timeout(redis_client, ip_version)
while config is None:
logger.info(
f"Waiting for KEA DHCP{ip_version} Configuration to be available in Redis..."
)
await asyncio.sleep(1)
config = await redis_client.load_kea_config(ip_version)
config = await _load_kea_config_with_timeout(redis_client, ip_version)

# Inject Lease DB details after loading from Redis
# so that secrets are not stored in the Redis cache
config = inject_lease_db_config(config, ip_version)

# Run once
logger.info(f"Setting initial KEA DHCPv{ip_version} Configuration from Redis.")
await kea_client.set_config(config, version=ip_version)
await _set_kea_config_with_timeout(kea_client, config, ip_version)
record_successful_reconciliation()
# Seed the heartbeat immediately so the liveness probe has a fresh
# marker before the first monitoring iteration completes.
touch_heartbeat(heartbeat_file)

Copy link
Copy Markdown

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

Initial "waiting for config in Redis" loop never advances the heartbeat.

touch_heartbeat is only called after the initial config is successfully loaded and applied (line 275). While config is None, the loop polls every second — genuine, non-wedged progress — but the heartbeat file stays missing the entire time. Per heartbeat.py's own contract, only a truly wedged loop should fail to advance the heartbeat; a slow-to-populate Redis (e.g. cold-start ordering, first deployment before any producer has written config) is exactly the "recoverable, still-progressing" case this feature is meant to tolerate. Once initialDelaySeconds+failureThreshold*periodSeconds elapses (300s+ per the dhcp.yaml probe), check-sync-heartbeat will report "missing" and kubelet will start recycling a healthy sidecar that's simply waiting on upstream data.

🩹 Proposed fix
         config = await _load_kea_config_with_timeout(redis_client, ip_version)
         while config is None:
             logger.info(
                 f"Waiting for KEA DHCP{ip_version} Configuration to be available in Redis..."
             )
+            touch_heartbeat(heartbeat_file)
             await asyncio.sleep(1)
             config = await _load_kea_config_with_timeout(redis_client, ip_version)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
config = await redis_client.load_kea_config(ip_version)
config = await _load_kea_config_with_timeout(redis_client, ip_version)
while config is None:
logger.info(
f"Waiting for KEA DHCP{ip_version} Configuration to be available in Redis..."
)
await asyncio.sleep(1)
config = await redis_client.load_kea_config(ip_version)
config = await _load_kea_config_with_timeout(redis_client, ip_version)
# Inject Lease DB details after loading from Redis
# so that secrets are not stored in the Redis cache
config = inject_lease_db_config(config, ip_version)
# Run once
logger.info(f"Setting initial KEA DHCPv{ip_version} Configuration from Redis.")
await kea_client.set_config(config, version=ip_version)
await _set_kea_config_with_timeout(kea_client, config, ip_version)
record_successful_reconciliation()
# Seed the heartbeat immediately so the liveness probe has a fresh
# marker before the first monitoring iteration completes.
touch_heartbeat(heartbeat_file)
try:
config = await _load_kea_config_with_timeout(redis_client, ip_version)
while config is None:
logger.info(
f"Waiting for KEA DHCP{ip_version} Configuration to be available in Redis..."
)
touch_heartbeat(heartbeat_file)
await asyncio.sleep(1)
config = await _load_kea_config_with_timeout(redis_client, ip_version)
# Inject Lease DB details after loading from Redis
# so that secrets are not stored in the Redis cache
config = inject_lease_db_config(config, ip_version)
# Run once
logger.info(f"Setting initial KEA DHCPv{ip_version} Configuration from Redis.")
await _set_kea_config_with_timeout(kea_client, config, ip_version)
record_successful_reconciliation()
# Seed the heartbeat immediately so the liveness probe has a fresh
# marker before the first monitoring iteration completes.
touch_heartbeat(heartbeat_file)
🤖 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 `@src/nv_config_manager/dhcp/cli.py` around lines 256 - 275, Update the initial
config-wait loop in the reconciliation flow to call
touch_heartbeat(heartbeat_file) on each polling iteration while config is None,
before sleeping or retrying _load_kea_config_with_timeout. Preserve the existing
heartbeat update after successful configuration application so both waiting and
completed states advance the liveness marker.

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