Long-running workers need to keep their task lease alive on the Conductor server. When a task is polled, the server starts a responseTimeoutSeconds timer. If no update arrives before the timer expires, the server re-queues the task — potentially causing duplicate execution by a second worker.
Lease extension sends a periodic heartbeat (extendLease: true) to the server that resets this timer, allowing the worker to safely run for minutes or hours.
Enable via the @worker decorator:
import { worker } from "@io-orkes/conductor-javascript";
@worker({
taskDefName: "process_video",
leaseExtendEnabled: true, // ← heartbeat at 80% of responseTimeoutSeconds
})
async function processVideo(task: Task): Promise<TaskResult> {
// Takes 5 minutes — server lease stays alive automatically
await encodeVideo(task.inputData.videoUrl);
return { status: "COMPLETED", outputData: { done: true } };
}Or on a manually constructed ConductorWorker:
const runner = new TaskRunner({
worker: {
taskDefName: "process_video",
execute: processVideo,
leaseExtendEnabled: true,
},
client,
options: { workerID: "worker-1", domain: undefined },
});
runner.startPolling();When leaseExtendEnabled: true is set and a task is polled:
- The
LeaseTrackerrecords the task and computes a heartbeat interval:intervalMs = responseTimeoutSeconds × 0.8 × 1000 - A
setInterval(100 ms tick) runs independently of the polling loop — it fires even when all concurrency slots are occupied with executing tasks. - When
intervalMselapses since the last heartbeat (or task start), aextendLease: trueupdate is sent to the server via the v1 endpoint, resetting theresponseTimeoutSecondstimer. - The task is untracked as soon as
worker.execute()resolves (before the final result is submitted).
t=0s task polled → lease tracked
t=8s heartbeat #1 → server timer reset to 10s
t=16s heartbeat #2 → server timer reset to 10s
t=20s execute() → COMPLETED, lease untracked
(Example: responseTimeoutSeconds=10s, execution takes 20s)
The heartbeat timer is a setInterval that runs on the Node.js event loop separate from the Poller. If all concurrency slots are full (workers are busy), no new tasks are polled — but heartbeats still fire for the tasks currently executing.
@worker({
taskDefName: "my_task",
leaseExtendEnabled: true, // default: false
})Per-worker or global overrides follow the same hierarchy as all other worker config:
# Worker-specific (highest priority)
CONDUCTOR_WORKER_MY_TASK_LEASE_EXTEND_ENABLED=true
# Global (applies to all workers)
CONDUCTOR_WORKER_ALL_LEASE_EXTEND_ENABLED=trueThe task must have responseTimeoutSeconds >= 1.25 for lease extension to activate. Tasks with shorter response timeouts produce a computed interval < 1000 ms, which is skipped (matches Python SDK behaviour).
await metadataClient.registerTask({
name: "process_video",
responseTimeoutSeconds: 60, // heartbeat fires every 48s
timeoutSeconds: 3600, // hard ceiling (unchanged by heartbeats)
retryCount: 0,
});Note:
leaseExtendEnabledresets theresponseTimeoutSecondswindow on each heartbeat. It does not extend the task'stimeoutSeconds(total execution ceiling).
| Constant | Value | Description |
|---|---|---|
LEASE_EXTEND_DURATION_FACTOR |
0.8 |
Heartbeat fires at 80% of responseTimeoutSeconds |
LEASE_EXTEND_RETRY_COUNT |
3 |
Retry attempts per heartbeat on failure |
HEARTBEAT_CHECK_INTERVAL_MS |
100 |
How often to check if a heartbeat is due |
HEARTBEAT_RETRY_DELAY_MS |
500 |
Delay between heartbeat retry attempts |
import { LEASE_EXTEND_DURATION_FACTOR, LEASE_EXTEND_RETRY_COUNT } from "@io-orkes/conductor-javascript";If a heartbeat fails, it is retried up to LEASE_EXTEND_RETRY_COUNT (3) times with a 500 ms delay between attempts. If all retries fail:
- The error is logged.
- The task remains tracked — the next check interval will attempt another heartbeat.
- The task itself is not failed due to heartbeat errors.
For custom worker implementations that bypass TaskRunner, LeaseTracker is exported from the public API:
import { LeaseTracker, TaskResource, orkesConductorClient } from "@io-orkes/conductor-javascript";
import type { LeaseInfo } from "@io-orkes/conductor-javascript";
const client = await orkesConductorClient();
const tracker = new LeaseTracker(
// sendHeartbeatFn — called by LeaseTracker on each heartbeat
async (taskId, workflowInstanceId) => {
await TaskResource.updateTask({
client,
body: { taskId, workflowInstanceId, status: "IN_PROGRESS", extendLease: true },
throwOnError: true,
});
},
logger
);
tracker.start(); // start the 100ms check interval
tracker.track(task); // track a polled task
// ... worker executes task ...
tracker.untrack(task.taskId!); // untrack immediately after execute() resolves
tracker.stop(); // stop the interval (on worker shutdown)LeaseInfo describes the tracked state for a single task:
interface LeaseInfo {
readonly taskId: string;
readonly workflowInstanceId: string;
readonly responseTimeoutSeconds: number;
readonly lastHeartbeatTime: number; // Date.now() of last successful heartbeat
readonly intervalMs: number; // responseTimeoutSeconds × 0.8 × 1000
readonly isHeartbeating: boolean; // true while a heartbeat chain is in-flight
}| Behaviour | Python SDK | JS SDK |
|---|---|---|
| Heartbeat interval | responseTimeoutSeconds × 0.8 |
✓ same |
| Minimum interval | < 1s → skip |
✓ < 1000ms → skip |
| Retry count | 3 | ✓ same |
| Retry delay | ~500ms | ✓ same |
| Heartbeat endpoint | v1 updateTask |
✓ same |
| Independent of poll loop | ✓ (Python run_once() pre-poll) |
✓ (JS setInterval) |
leaseExtendEnabled flag |
✓ | ✓ |
pullWorkflowMessages— task builder for consuming workflow message queue messages, which uses theIN_PROGRESS/ re-queue pattern that lease extension is designed to protect.- METRICS.md — monitoring worker health, poll latency, and execution duration.
A task that consumes messages from the workflow's message queue (WMQ). When messages are available the task completes; when the queue is empty it returns IN_PROGRESS and is re-evaluated after ~1 second.
import { pullWorkflowMessages } from "@io-orkes/conductor-javascript";
const wf = new ConductorWorkflow(executor, "order_processor")
.add(pullWorkflowMessages("read_messages", /* batchSize */ 5))
.add(simpleTask("process_ref", "process_order", { messages: "${read_messages.output.messages}" }));| Parameter | Type | Default | Description |
|---|---|---|---|
taskReferenceName |
string |
— | Unique reference name within the workflow |
batchSize |
number |
1 |
Max messages to dequeue per execution (server cap ~100) |
optional |
boolean |
undefined |
Whether the task is optional |
Output shape:
{
"messages": [ /* WorkflowMessage objects */ ],
"count": 3
}