Skip to content

feat(node): resource health-check daemon#378

Merged
pratyush618 merged 8 commits into
masterfrom
feat/node-resource-health
Jul 6, 2026
Merged

feat(node): resource health-check daemon#378
pratyush618 merged 8 commits into
masterfrom
feat/node-resource-health

Conversation

@pratyush618

@pratyush618 pratyush618 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #377 — retargets to master automatically when it merges.

Summary

  • Resource health checks: queue.resource(name, factory, { healthCheck, healthCheckIntervalMs, maxRecreationAttempts }) (worker scope only — registering them on task/pooled scope throws). A HealthChecker daemon wakes every 500ms (unref'd, no-op when nothing asks for checks) and runs each resource's check on its own schedule; a failed or throwing check triggers recreation (dispose old instance, rebuild via the normal path); when recreation fails and the failure budget (default 3) is spent, the resource is marked permanently unhealthy for the rest of the worker run and every later resolve rejects with ResourceUnavailableError.
  • JS-driven worker heartbeat: the native 5s heartbeat tick wrote resource_health = NULL on every beat, so any JS writer would flap. The tick moves to JS — a 5s unref'd interval calls the new native workerHeartbeat(workerId, resourceHealth) (heartbeat + best-effort dead-worker reap), carrying {name: "healthy"|"unhealthy"} per registered resource. The Rust lifecycle task now only registers (advertising sorted resource names on the cross-SDK wire shape) and unregisters.
  • listWorkers() rows now surface resources and resourceHealth.

Native API

  • JsQueue.workerHeartbeat(workerId, resourceHealth?) → Promise<string[]> (reaped worker ids)
  • JsWorker.id getter; WorkerOptions.resources?: string[]; JsWorkerRow.resources?/resourceHealth?

Notes

  • Reaped-id return value is not yet consumed in JS (no worker lifecycle events in this SDK) — follow-up scope.

Test plan

  • 6 new tests in test/resources/health.test.ts: recreate on failing check, exhaustion → permanently unhealthy, throwing check treated unhealthy, start() no-op without checks, registration scope guard, worker integration (listWorkers shows healthy resourceHealth JSON).
  • cargo check/clippy -D warnings/fmt --check on the native crate; pnpm typecheck, pnpm lint, full suite 269 tests green.

Summary by CodeRabbit

  • New Features
    • Added worker-scoped resource health checks (healthCheck, healthCheckIntervalMs, maxRecreationAttempts) with automatic recreation on failure.
    • Workers now periodically send heartbeats that include a health snapshot for the resources they manage.
    • Worker registration/inspection data now surfaces advertised resources and their health state.
  • Bug Fixes
    • Improved worker lifecycle shutdown/unregistration behavior to prevent stale/lingering worker registration.
    • Resources are now marked permanently unhealthy only after exhausting configured recreation attempts.
  • Tests
    • Added coverage for health-check behavior, heartbeat metadata, and scope validation.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds worker resource health checks, runtime recreation and unhealthy-state tracking, and worker heartbeat reporting. Native bindings now carry resource metadata and heartbeat updates, while the Node SDK adds health-check configuration, a periodic checker, and heartbeat emission from workers.

Changes

Rust native worker heartbeat and resource registration

Layer / File(s) Summary
Worker options and stats contract updates
crates/taskito-node/src/config.rs, crates/taskito-node/src/convert/stats.rs
Adds optional resource metadata to worker registration options and worker stats, and maps the storage row fields into the JS worker row shape.
Native worker heartbeat API
crates/taskito-node/src/queue/inspect.rs
Adds the N-API worker heartbeat method that records a heartbeat and reaps dead workers from storage.
JS-driven worker lifecycle and registration
crates/taskito-node/src/worker.rs
Changes worker lifecycle signaling to a JS stop notification, removes the Rust-side periodic heartbeat loop, and forwards serialized resource names into worker registration.

Node SDK resource health checking

Layer / File(s) Summary
ResourceDefinition health-check contract
sdks/node/src/resources/types.ts
Adds optional health-check predicate, interval, and recreation-attempt settings to resource definitions.
ResourceRuntime health and recreation state
sdks/node/src/resources/runtime.ts
Adds permanent unhealthy tracking, built-instance tracking, recreation support, health snapshots, and runtime APIs for querying and updating resource health state.
HealthChecker daemon implementation and exports
sdks/node/src/resources/health.ts, sdks/node/src/resources/index.ts
Implements the periodic health-check daemon that checks worker resources, recreates failing instances, and marks resources permanently unhealthy after repeated failure; re-exports the checker and health-state type.
Queue.resource() health-check validation and registration
sdks/node/src/queue.ts
Extends resource options with health-check settings, rejects non-worker scopes, and forwards the settings into resource registration.
Worker heartbeat scheduling
sdks/node/src/worker.ts
Adds periodic worker heartbeats that send health snapshots to storage and clears the heartbeat timer during shutdown.
Health-check and heartbeat test coverage
sdks/node/test/resources/health.test.ts
Adds tests for recreation, permanent unhealthy state, thrown health-check errors, checker lifecycle behavior, shutdown timing, scope validation, and heartbeat metadata.

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

🚥 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 is concise and accurately points to the main new Node resource health-check daemon.
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 feat/node-resource-health

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

Base automatically changed from feat/node-resource-pool to master July 6, 2026 12:01

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

🧹 Nitpick comments (3)
crates/taskito-node/src/worker.rs (1)

94-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Stale comment: lifecycle loop no longer heartbeats.

This comment still describes the lifecycle loop as "registers/heartbeats", but heartbeats have been fully moved to JS (per the new module doc at Line 2-4) and the loop now only registers, waits on stop, then unregisters (Line 267). Worth updating to avoid confusing future readers about where heartbeats originate.

📝 Proposed comment fix
-    // The dispatcher reads cancel flags, and the lifecycle loop registers/heartbeats
+    // The dispatcher reads cancel flags, and the lifecycle loop registers/unregisters
     // — both need their own storage handle before `storage` moves into the scheduler.

Also applies to: 267-267

🤖 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 `@crates/taskito-node/src/worker.rs` around lines 94 - 96, Update the stale
comment near dispatcher_storage in worker.rs so it no longer says the lifecycle
loop “registers/heartbeats”; the heartbeats now live in JS, and the Rust
lifecycle loop only registers, waits on stop, then unregisters. Keep the comment
aligned with the current behavior around the dispatcher storage clone and the
lifecycle loop so future readers can locate the responsibility split correctly.
crates/taskito-node/src/queue/inspect.rs (1)

129-149: 🚀 Performance & Scalability | 🔵 Trivial

Reaping on every heartbeat may not scale with worker count.

Each worker heartbeats independently (every ~5s), and each call unconditionally invokes storage.reap_dead_workers(). With N live workers, that's N reap scans every heartbeat interval instead of one global periodic reap — redundant load that grows with fleet size.

Consider throttling reaping (e.g., only reap if enough time elapsed since the last reap, tracked via a shared atomic/mutex on JsQueue or storage), or moving it to a single background interval decoupled from per-worker heartbeats.

🤖 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 `@crates/taskito-node/src/queue/inspect.rs` around lines 129 - 149, The worker
heartbeat path in worker_heartbeat currently triggers
storage.reap_dead_workers() on every call, which creates redundant reap scans as
worker count grows. Update the JsQueue/inspect flow so reaping is throttled or
centralized: either gate reaping with a shared last-reap timestamp/lock on
JsQueue or storage, or move it to a single background interval. Keep heartbeat
recording in worker_heartbeat intact and ensure the reap step remains
opportunistic without blocking or failing the heartbeat.
sdks/node/src/resources/health.ts (1)

8-8: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Health-checked resource set is frozen at start(); later registrations/replacements are invisible to the daemon.

checked is captured once from runtime.healthChecked() and reused for the timer's whole life. ResourceRuntime.register() explicitly supports replacing a definition on a live runtime, but if that happens after start() (e.g. a worker is already running), the new healthCheck/healthCheckIntervalMs/maxRecreationAttempts — or a brand-new health-checked resource registered later — never gets picked up by this already-running daemon.

Also, TICK_MS = 500 means no resource is ever checked more often than every 500ms, even if healthCheckIntervalMs is configured smaller (as several tests here do, e.g. 50/100ms) — worth documenting on ResourceDefinition.healthCheckIntervalMs.

♻️ Re-derive `checked` per tick
-    const checked = this.runtime.healthChecked();
-    if (checked.size === 0) {
-      return;
-    }
-    const now = Date.now();
-    for (const [name, def] of checked) {
-      this.nextDue.set(name, now + (def.healthCheckIntervalMs ?? 0));
-      this.failCount.set(name, 0);
-    }
-    this.timer = setInterval(() => void this.tick(checked), TICK_MS);
+    if (this.runtime.healthChecked().size === 0) {
+      return;
+    }
+    this.timer = setInterval(() => void this.tick(), TICK_MS);
     this.timer.unref();

and inside tick(), call const checked = this.runtime.healthChecked(); at the top, seeding nextDue/failCount lazily on first sight of a name.

Also applies to: 32-49

🤖 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 `@sdks/node/src/resources/health.ts` at line 8, The health daemon in
`HealthCheckDaemon` caches the result of `runtime.healthChecked()` once at
`start()`, so later `ResourceRuntime.register()` changes and newly added
health-checked resources are never observed; move the lookup into `tick()` and
re-derive `checked` each tick, initializing `nextDue` and `failCount` lazily for
newly seen names. Also account for `TICK_MS` as a hard polling floor by
documenting on `ResourceDefinition.healthCheckIntervalMs` that checks cannot run
more frequently than the daemon’s tick interval.
🤖 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 `@sdks/node/src/resources/health.ts`:
- Around line 51-78: `HealthChecker.stop()` only clears the timer, so an
in-flight `tick()` can still run `checkAndRepair()` and call
`runtime.recreate()` after shutdown starts. Update the `HealthChecker` flow so
`stop()` also prevents any pending/active tick from doing work and `tick()`
exits early once stop has begun; then make `Worker.stop()` await
`resources.teardownWorker()` before returning so teardown and health checks
cannot race. Use the existing `stop()`, `tick()`, and `Worker.stop()` entry
points to wire this shutdown guard through the lifecycle.

In `@sdks/node/src/worker.ts`:
- Around line 200-227: Worker.start() is creating a separate HealthChecker per
worker even though the shared ResourceRuntime can already be leased
concurrently, which can lead to overlapping recreate() calls on the same
resources. Move HealthChecker lifecycle management to the shared runtime lease
path so a single checker is started and stopped with the ResourceRuntime instead
of per Worker instance, and update the Worker.start() / Worker constructor flow
to avoid creating multiple checkers for the same resources.
- Around line 213-224: The teardown path can still race with an in-flight
HealthChecker tick, allowing recreate() to run after the heartbeat timer is
cleared but before native.stop() and resources.teardownWorker() finish. Update
HealthChecker.stop() to wait for any active tick/recreate work to complete
instead of only clearing its interval, and make Worker.stop() await that stop()
result before releasing worker resources. Use the HealthChecker and
Worker.stop() paths to ensure no health-check activity remains before teardown
proceeds.

---

Nitpick comments:
In `@crates/taskito-node/src/queue/inspect.rs`:
- Around line 129-149: The worker heartbeat path in worker_heartbeat currently
triggers storage.reap_dead_workers() on every call, which creates redundant reap
scans as worker count grows. Update the JsQueue/inspect flow so reaping is
throttled or centralized: either gate reaping with a shared last-reap
timestamp/lock on JsQueue or storage, or move it to a single background
interval. Keep heartbeat recording in worker_heartbeat intact and ensure the
reap step remains opportunistic without blocking or failing the heartbeat.

In `@crates/taskito-node/src/worker.rs`:
- Around line 94-96: Update the stale comment near dispatcher_storage in
worker.rs so it no longer says the lifecycle loop “registers/heartbeats”; the
heartbeats now live in JS, and the Rust lifecycle loop only registers, waits on
stop, then unregisters. Keep the comment aligned with the current behavior
around the dispatcher storage clone and the lifecycle loop so future readers can
locate the responsibility split correctly.

In `@sdks/node/src/resources/health.ts`:
- Line 8: The health daemon in `HealthCheckDaemon` caches the result of
`runtime.healthChecked()` once at `start()`, so later
`ResourceRuntime.register()` changes and newly added health-checked resources
are never observed; move the lookup into `tick()` and re-derive `checked` each
tick, initializing `nextDue` and `failCount` lazily for newly seen names. Also
account for `TICK_MS` as a hard polling floor by documenting on
`ResourceDefinition.healthCheckIntervalMs` that checks cannot run more
frequently than the daemon’s tick interval.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3cc1cf53-d00f-4acd-82c7-85f873b16bcb

📥 Commits

Reviewing files that changed from the base of the PR and between a53e37e and 16e94b6.

📒 Files selected for processing (11)
  • crates/taskito-node/src/config.rs
  • crates/taskito-node/src/convert/stats.rs
  • crates/taskito-node/src/queue/inspect.rs
  • crates/taskito-node/src/worker.rs
  • sdks/node/src/queue.ts
  • sdks/node/src/resources/health.ts
  • sdks/node/src/resources/index.ts
  • sdks/node/src/resources/runtime.ts
  • sdks/node/src/resources/types.ts
  • sdks/node/src/worker.ts
  • sdks/node/test/resources/health.test.ts

Comment thread sdks/node/src/resources/health.ts Outdated
Comment thread sdks/node/src/worker.ts
Comment thread sdks/node/src/worker.ts

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
sdks/node/src/worker.ts (1)

213-221: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Wrap sendHeartbeat's synchronous body in try/catch.

Only the async queue.workerHeartbeat(...) call is guarded via .catch(). If resources.healthSnapshot() or JSON.stringify(snapshot) throws synchronously, the exception escapes the setInterval callback uncaught, contradicting the intent stated in the comment above ("Failures are logged, never thrown").

🛡️ Proposed fix
 const sendHeartbeat = (): void => {
-  const snapshot = resources.healthSnapshot();
-  void queue.workerHeartbeat(native.id, snapshot && JSON.stringify(snapshot)).catch((error) => {
-    log.debug(() => "worker heartbeat failed", error);
-  });
+  try {
+    const snapshot = resources.healthSnapshot();
+    void queue.workerHeartbeat(native.id, snapshot && JSON.stringify(snapshot)).catch((error) => {
+      log.debug(() => "worker heartbeat failed", error);
+    });
+  } catch (error) {
+    log.debug(() => "worker heartbeat failed", error);
+  }
 };
🤖 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 `@sdks/node/src/worker.ts` around lines 213 - 221, The sendHeartbeat function
in worker.ts only catches errors from queue.workerHeartbeat, so synchronous
failures from resources.healthSnapshot() or JSON.stringify(snapshot) can still
escape the setInterval callback. Wrap the entire body of sendHeartbeat in a
try/catch, keep the existing log.debug error reporting for both sync and async
failures, and ensure the function continues to match the “Failures are logged,
never thrown” behavior.
🤖 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 `@sdks/node/src/worker.ts`:
- Around line 213-221: The sendHeartbeat function in worker.ts only catches
errors from queue.workerHeartbeat, so synchronous failures from
resources.healthSnapshot() or JSON.stringify(snapshot) can still escape the
setInterval callback. Wrap the entire body of sendHeartbeat in a try/catch, keep
the existing log.debug error reporting for both sync and async failures, and
ensure the function continues to match the “Failures are logged, never thrown”
behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 74c20324-f042-4126-9497-6ed33f05f1ba

📥 Commits

Reviewing files that changed from the base of the PR and between 16e94b6 and b35c1c5.

📒 Files selected for processing (4)
  • sdks/node/src/resources/health.ts
  • sdks/node/src/resources/runtime.ts
  • sdks/node/src/worker.ts
  • sdks/node/test/resources/health.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • sdks/node/test/resources/health.test.ts
  • sdks/node/src/resources/health.ts
  • sdks/node/src/resources/runtime.ts

@pratyush618
pratyush618 merged commit b9fb969 into master Jul 6, 2026
36 checks passed
@pratyush618
pratyush618 deleted the feat/node-resource-health branch July 6, 2026 16:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant