Skip to content

Commit 6e0741d

Browse files
committed
schema and on update failure
1 parent 866a76c commit 6e0741d

17 files changed

Lines changed: 2690 additions & 31 deletions

WORKER_CONFIGURATION.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,15 @@ The following properties can be configured via environment variables:
2525
| `domain` | string | Worker domain for task routing | `production` | ✅ Yes |
2626
| `worker_id` | string | Unique worker identifier | `worker-1` | ✅ Yes |
2727
| `thread_count` | int | Max concurrent executions (threads for sync, coroutines for async) | `10` | ✅ Yes |
28-
| `register_task_def` | bool | Auto-register task definition | `true` | ✅ Yes |
28+
| `register_task_def` | bool | Auto-register task definition with JSON schemas on startup | `true` | ✅ Yes |
2929
| `poll_timeout` | int | Poll request timeout in milliseconds | `100` | ✅ Yes |
3030
| `lease_extend_enabled` | bool | ⚠️ **Not implemented** - reserved for future use | `false` | ✅ Yes |
3131
| `paused` | bool | Pause worker from polling/executing tasks | `true` |**Environment-only** |
3232

3333
**Notes**:
3434
- The `paused` property is intentionally **not available** in the `@worker_task` decorator. It can only be controlled via environment variables, allowing operators to pause/resume workers at runtime without code changes or redeployment.
3535
- The `lease_extend_enabled` parameter is accepted but **not currently implemented**. For lease extension, use manual `TaskInProgress` returns (see below).
36+
- The `register_task_def` parameter automatically registers task definitions with JSON Schema (draft-07) generated from Python type hints. Does not overwrite existing definitions.
3637

3738
### Understanding `thread_count`
3839

docs/design/WORKER_DESIGN.md

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ async def __get_authentication_headers(self):
409409
| `worker_id` | str | auto | Worker identifier |
410410
| `poll_timeout` | int | 100 | Poll timeout (ms) |
411411
| `lease_extend_enabled` | bool | False | ⚠️ **Not implemented** - use `TaskInProgress` instead |
412-
| `register_task_def` | bool | False | Auto-register task |
412+
| `register_task_def` | bool | False | Auto-register task definition with JSON schemas (draft-07) |
413413
| `paused` | bool | False | Pause worker (env-only, not in decorator) |
414414

415415
### Examples
@@ -437,6 +437,85 @@ export conductor.worker.process_order.thread_count=50
437437

438438
**Result:** `domain=production`, `thread_count=50`
439439

440+
### Automatic Task Definition Registration
441+
442+
When `register_task_def=True`, the worker automatically registers its task definition with Conductor on startup, including JSON schemas generated from type hints.
443+
444+
**Example:**
445+
```python
446+
from dataclasses import dataclass
447+
448+
@dataclass
449+
class OrderInfo:
450+
order_id: str
451+
amount: float
452+
customer_id: int
453+
454+
@worker_task(
455+
task_definition_name='process_order',
456+
register_task_def=True # Auto-register on startup
457+
)
458+
def process_order(order: OrderInfo, priority: int = 1) -> dict:
459+
return {'status': 'processed', 'order_id': order.order_id}
460+
```
461+
462+
**What Gets Registered:**
463+
464+
1. **Task Definition**: `process_order`
465+
466+
2. **Input Schema** (`process_order_input`):
467+
```json
468+
{
469+
"$schema": "http://json-schema.org/draft-07/schema#",
470+
"type": "object",
471+
"properties": {
472+
"order": {
473+
"type": "object",
474+
"properties": {
475+
"order_id": {"type": "string"},
476+
"amount": {"type": "number"},
477+
"customer_id": {"type": "integer"}
478+
},
479+
"required": ["order_id", "amount", "customer_id"]
480+
},
481+
"priority": {"type": "integer"}
482+
},
483+
"required": ["order"]
484+
}
485+
```
486+
487+
3. **Output Schema** (`process_order_output`):
488+
```json
489+
{
490+
"$schema": "http://json-schema.org/draft-07/schema#",
491+
"type": "object"
492+
}
493+
```
494+
495+
**Supported Types:**
496+
- Basic: `str`, `int`, `float`, `bool`, `dict`, `list`
497+
- Optional: `Optional[T]`
498+
- Collections: `List[T]`, `Dict[str, T]`
499+
- Dataclasses (with recursive field conversion)
500+
- Union types (filters out TaskInProgress/None for return types)
501+
502+
**Behavior:**
503+
- ✅ Skips if task definition already exists (no overwrite)
504+
- ✅ Skips if schemas already exist (no overwrite)
505+
- ✅ Workers start even if registration fails (just logs warning)
506+
- ✅ Works for both sync and async workers
507+
- ⚠️ Only works for function-based workers (`@worker_task` decorator)
508+
- ⚠️ Class-based workers not supported (no execute_function attribute)
509+
510+
**Environment Override:**
511+
```bash
512+
# Enable for all workers
513+
export conductor.worker.all.register_task_def=true
514+
515+
# Enable for specific worker
516+
export conductor.worker.process_order.register_task_def=true
517+
```
518+
440519
### Startup Configuration Logging
441520

442521
When workers start, they log their resolved configuration in a compact single-line format:
@@ -988,6 +1067,7 @@ The built-in `MetricsCollector` is implemented as an event listener that respond
9881067
- `TaskExecutionStarted(task_type, task_id, worker_id, workflow_instance_id)` - When task execution begins
9891068
- `TaskExecutionCompleted(task_type, task_id, worker_id, workflow_instance_id, duration_ms, output_size_bytes)` - When task completes (includes actual async execution time)
9901069
- `TaskExecutionFailure(task_type, task_id, worker_id, workflow_instance_id, cause, duration_ms)` - When task fails
1070+
- `TaskUpdateFailure(task_type, task_id, worker_id, workflow_instance_id, cause, retry_count, task_result)` - **Critical!** When task update fails after all retries (4 attempts with 10s/20s/30s backoff)
9911071

9921072
**Event Properties:**
9931073
- All events are dataclasses with type hints
@@ -1021,6 +1101,59 @@ handler = TaskHandler(
10211101
)
10221102
```
10231103

1104+
### Update Retry Logic & Failure Handling
1105+
1106+
**Critical: Task updates are retried with exponential backoff**
1107+
1108+
Both TaskRunner and AsyncTaskRunner implement robust retry logic for task updates:
1109+
1110+
**Retry Configuration:**
1111+
- **4 attempts total** (0, 1, 2, 3)
1112+
- **Exponential backoff**: 10s, 20s, 30s between retries
1113+
- **Idempotent**: Safe to retry updates
1114+
- **Event on final failure**: `TaskUpdateFailure` published
1115+
1116+
**Why This Matters:**
1117+
Task updates are **critical** - if a worker executes a task successfully but fails to update Conductor, the task result is lost. The retry logic ensures maximum reliability.
1118+
1119+
**Handling Update Failures:**
1120+
```python
1121+
class UpdateFailureHandler(TaskRunnerEventsListener):
1122+
"""Handle critical update failures after all retries exhausted."""
1123+
1124+
def on_task_update_failure(self, event: TaskUpdateFailure):
1125+
# CRITICAL: Task was executed but Conductor doesn't know!
1126+
# External intervention required
1127+
1128+
# Option 1: Alert operations team
1129+
send_pagerduty_alert(
1130+
f"CRITICAL: Task update failed after {event.retry_count} attempts",
1131+
task_id=event.task_id,
1132+
workflow_id=event.workflow_instance_id
1133+
)
1134+
1135+
# Option 2: Log to external storage for recovery
1136+
backup_db.save_task_result(
1137+
task_id=event.task_id,
1138+
result=event.task_result, # Contains the actual result that was lost
1139+
timestamp=event.timestamp,
1140+
error=str(event.cause)
1141+
)
1142+
1143+
# Option 3: Attempt custom recovery
1144+
try:
1145+
# Custom retry logic with different strategy
1146+
custom_update_service.update_task_with_custom_retry(event.task_result)
1147+
except Exception as e:
1148+
logger.critical(f"Recovery failed: {e}")
1149+
1150+
# Register handler
1151+
handler = TaskHandler(
1152+
configuration=config,
1153+
event_listeners=[UpdateFailureHandler()]
1154+
)
1155+
```
1156+
10241157
### Advanced Examples
10251158

10261159
**SLA Monitoring:**
@@ -1311,9 +1444,23 @@ Expected improvements for I/O-bound async workers:
13111444
- Both TaskRunner and AsyncTaskRunner use dynamic batch polling
13121445
- Batch size = thread_count - currently_running_tasks
13131446
- TaskRunner: ThreadPoolExecutor capacity limits execution
1314-
- AsyncTaskRunner: Semaphore limits execution (during execute, not poll)
1447+
- AsyncTaskRunner: Semaphore limits execution (during execute + update)
1448+
- Semaphore held until update succeeds (ensures capacity represents fully-handled tasks)
1449+
- **Implemented register_task_def functionality:**
1450+
- Automatically registers task definitions on worker startup
1451+
- Generates JSON Schema (draft-07) from Python type hints
1452+
- Supports dataclasses, Optional, List, Dict, Union types
1453+
- Creates schemas named {task_name}_input and {task_name}_output
1454+
- Does not overwrite existing definitions or schemas
1455+
- Works for both TaskRunner and AsyncTaskRunner
1456+
- **Added TaskUpdateFailure event:**
1457+
- Published when task update fails after all retry attempts (4 retries with exponential backoff: 10s/20s/30s)
1458+
- Contains TaskResult for recovery/logging
1459+
- Enables external handling of critical update failures
1460+
- Event count: 7 total events (was 6)
13151461
- Added detailed polling loop with dynamic batch sizing examples
13161462
- Improved troubleshooting guidance
1463+
- Fixed class-based worker support in TaskHandler async detection
13171464

13181465
### Version 4.0 (2025-11-28)
13191466
- AsyncTaskRunner: Pure async/await execution (zero thread overhead)

examples/helloworld/greetings_workflow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44
from conductor.client.workflow.conductor_workflow import ConductorWorkflow
55
from conductor.client.workflow.executor.workflow_executor import WorkflowExecutor
6-
from helloworld import greetings_worker
6+
from greetings_worker import *
77

88

99
def greetings_workflow(workflow_executor: WorkflowExecutor) -> ConductorWorkflow:

examples/user_example/user_workers.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@
88

99
from conductor.client.context import get_task_context
1010
from conductor.client.worker.worker_task import worker_task
11-
from user_example.models import User
11+
from examples.user_example.models import User
1212

1313

1414
@worker_task(
1515
task_definition_name='fetch_user',
1616
thread_count=10,
17-
poll_timeout=100
17+
poll_timeout=100,
18+
register_task_def=True
1819
)
1920
async def fetch_user(user_id: int) -> User:
2021
"""

0 commit comments

Comments
 (0)