
Temporal Io
- 22 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
temporal-io is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- temporal-io
- AI & Agent Building
- AI-coding skill
Temporal Io by the numbers
- 22 all-time installs (skills.sh)
- Ranked #10,169 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill temporal-ioAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 22 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
Temporal.io Workflow Orchestration
Durable execution engine for reliable distributed applications.
Overview
- Long-running business processes (days/weeks/months)
- Saga patterns requiring compensation/rollback
- Microservice orchestration with retries
- Systems requiring exactly-once execution guarantees
- Complex state machines with human-in-the-loop
- Scheduled and recurring workflows
Workflow Definition
from temporalio import workflow
from temporalio.common import RetryPolicy
from datetime import timedelta
@workflow.defn
class OrderWorkflow:
def __init__(self):
self._status = "pending"
self._order_id: str | None = None
@workflow.run
async def run(self, order_data: OrderInput) -> OrderResult:
self._order_id = await workflow.execute_activity(
create_order, order_data,
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=3, initial_interval=timedelta(seconds=1)),
)
self._status = "processing"
# Parallel activities
payment, inventory = await asyncio.gather(
workflow.execute_activity(process_payment, PaymentInput(order_id=self._order_id), start_to_close_timeout=timedelta(minutes=5)),
workflow.execute_activity(reserve_inventory, InventoryInput(order_id=self._order_id), start_to_close_timeout=timedelta(minutes=2)),
)
self._status = "completed"
return OrderResult(order_id=self._order_id, payment_id=payment.id)
@workflow.query
def get_status(self) -> str:
return self._status
@workflow.signal
async def cancel_order(self, reason: str):
self._status = "cancelling"
await workflow.execute_activity(cancel_order_activity, CancelInput(order_id=self._order_id), start_to_close_timeout=timedelta(seconds=30))
self._status = "cancelled"Activity Definition
from temporalio import activity
from temporalio.exceptions import ApplicationError
@activity.defn
async def process_payment(input: PaymentInput) -> PaymentResult:
activity.logger.info(f"Processing payment for order {input.order_id}")
try:
async with httpx.AsyncClient() as client:
response = await client.post("https://payments.example.com/charge", json={"order_id": input.order_id, "amount": input.amount})
response.raise_for_status()
return PaymentResult(**response.json())
except httpx.HTTPStatusError as e:
if e.response.status_code == 402:
raise ApplicationError("Payment declined", non_retryable=True, type="PaymentDeclined")
raise
@activity.defn
async def send_notification(input: NotificationInput) -> None:
for i, recipient in enumerate(input.recipients):
activity.heartbeat(f"Sending {i+1}/{len(input.recipients)}") # For long operations
await send_email(recipient, input.subject, input.body)Worker and Client
from temporalio.client import Client
from temporalio.worker import Worker
async def main():
client = await Client.connect("localhost:7233")
worker = Worker(
client,
task_queue="order-processing",
workflows=[OrderWorkflow],
activities=[create_order, process_payment, reserve_inventory, cancel_order_activity],
)
await worker.run()
async def start_order_workflow(order_data: OrderInput) -> str:
client = await Client.connect("localhost:7233")
handle = await client.start_workflow(
OrderWorkflow.run, order_data,
id=f"order-{order_data.order_id}",
task_queue="order-processing",
)
return handle.id
async def get_order_status(workflow_id: str) -> str:
client = await Client.connect("localhost:7233")
handle = client.get_workflow_handle(workflow_id)
return await handle.query(OrderWorkflow.get_status)Saga Pattern with Compensation
@workflow.defn
class OrderSagaWorkflow:
@workflow.run
async def run(self, order: OrderInput) -> OrderResult:
compensations: list[tuple[Callable, Any]] = []
try:
reservation = await workflow.execute_activity(reserve_inventory, order.items, start_to_close_timeout=timedelta(minutes=2))
compensations.append((release_inventory, reservation.id))
payment = await workflow.execute_activity(charge_payment, PaymentInput(order_id=order.id), start_to_close_timeout=timedelta(minutes=5))
compensations.append((refund_payment, payment.id))
shipment = await workflow.execute_activity(create_shipment, ShipmentInput(order_id=order.id), start_to_close_timeout=timedelta(minutes=3))
return OrderResult(order_id=order.id, payment_id=payment.id, shipment_id=shipment.id)
except Exception:
workflow.logger.warning(f"Saga failed, running {len(compensations)} compensations")
for compensate_fn, compensate_arg in reversed(compensations):
try:
await workflow.execute_activity(compensate_fn, compensate_arg, start_to_close_timeout=timedelta(minutes=2))
except Exception as e:
workflow.logger.error(f"Compensation failed: {e}")
raiseTimers and Scheduling
@workflow.defn
class TimeoutWorkflow:
@workflow.run
async def run(self, input: TaskInput) -> TaskResult:
try:
await workflow.wait_condition(lambda: self._approved is not None, timeout=timedelta(hours=24))
except asyncio.TimeoutError:
return TaskResult(status="auto_rejected")
return TaskResult(status="approved" if self._approved else "rejected")
@workflow.signal
async def approve(self, approved: bool):
self._approved = approvedTesting
import pytest
from temporalio.testing import WorkflowEnvironment
@pytest.fixture
async def workflow_env():
async with await WorkflowEnvironment.start_local() as env:
yield env
@pytest.mark.asyncio
async def test_order_workflow(workflow_env):
async with Worker(workflow_env.client, task_queue="test", workflows=[OrderWorkflow], activities=[create_order, process_payment]):
result = await workflow_env.client.execute_workflow(
OrderWorkflow.run, OrderInput(id="test-1", total=100),
id="test-order-1", task_queue="test",
)
assert result.order_id == "test-1"Key Decisions
| Decision | Recommendation |
|---|---|
| Workflow ID | Business-meaningful, idempotent (e.g., order-{order_id}) |
| Task queue | Per-service or per-workflow-type |
| Activity timeout | start_to_close for most cases |
| Retry policy | 3 attempts default, exponential backoff |
| Heartbeating | Required for activities > 60s |
Anti-Patterns (FORBIDDEN)
# NEVER do non-deterministic operations in workflows
if random.random() > 0.5: # Different on replay!
if datetime.now() > deadline: # Different on replay!
# CORRECT: Use workflow APIs
if await workflow.random() > 0.5:
if workflow.now() > deadline:
# NEVER make network calls directly in workflows
response = await httpx.get("https://api.example.com") # WRONG!
# CORRECT: Use activities for I/O
response = await workflow.execute_activity(fetch_data, ...)
# NEVER ignore activity idempotency - use upsert with order_id as keyRelated Skills
saga-patterns- Distributed transaction patternsmessage-queues- Event-driven integrationresilience-patterns- Retry and circuit breaker patterns
Temporal Production Deployment Checklist
Comprehensive checklist for deploying Temporal clusters and workers to production.
Infrastructure Prerequisites
Temporal Cluster
- [ ] Temporal version: Using supported version (1.20+ recommended)
- [ ] Deployment method: Kubernetes Helm, Docker Compose, or Temporal Cloud
- [ ] High availability: Multiple frontend, history, matching, and worker services
- [ ] Load balancer: For frontend service access
Dependencies
- [ ] Database: PostgreSQL 13+ or MySQL 8+ (production) or Cassandra
- [ ] Elasticsearch: 7.x or 8.x for visibility (recommended)
- [ ] Object storage: S3/GCS for archival (optional)
Networking
- [ ] TLS enabled: All internal and external communication
- [ ] mTLS configured: Client certificate authentication
- [ ] Network policies: Restrict traffic between components
- [ ] DNS configured: Stable hostnames for services
Cluster Configuration
Database Setup
# Example: PostgreSQL configuration
persistence:
default:
driver: "sql"
sql:
driverName: "postgres"
host: "postgres.example.com"
port: 5432
database: "temporal"
user: "temporal"
password: "${DB_PASSWORD}"
maxConns: 20
maxIdleConns: 20
maxConnLifetime: "1h"
visibility:
driver: "elasticsearch"
elasticsearch:
url:
scheme: "https"
host: "elasticsearch.example.com:9200"
username: "${ES_USERNAME}"
password: "${ES_PASSWORD}"
indices:
visibility: "temporal_visibility_v1"- [ ] Connection pooling: Appropriate pool sizes
- [ ] Connection timeouts: Reasonable values
- [ ] Read replicas: For visibility queries (if using SQL)
- [ ] Backup strategy: Regular automated backups
Namespace Configuration
# Create production namespace
temporal operator namespace create \
--namespace production \
--retention 30d \
--description "Production workflows"- [ ] Namespaces created: Separate for prod, staging, dev
- [ ] Retention period: Based on compliance requirements (7-90 days)
- [ ] Global namespace: If multi-region (advanced)
Resource Limits
# Kubernetes resource limits
resources:
frontend:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2"
memory: "2Gi"
history:
requests:
cpu: "1"
memory: "1Gi"
limits:
cpu: "4"
memory: "4Gi"
matching:
requests:
cpu: "500m"
memory: "512Mi"
limits:
cpu: "2"
memory: "2Gi"- [ ] CPU limits: Based on expected load
- [ ] Memory limits: History service needs more
- [ ] HPA configured: Auto-scaling for frontend/matching
- [ ] PDB configured: Pod disruption budgets
Worker Deployment
Worker Configuration
# Production worker settings
worker = Worker(
client,
task_queue="production-queue",
workflows=[OrderWorkflow, PaymentWorkflow],
activities=[...],
# Concurrency tuning
max_concurrent_activities=100,
max_concurrent_workflow_task_polls=100,
max_concurrent_local_activities=100,
# Graceful shutdown
graceful_shutdown_timeout=timedelta(seconds=30),
# Sticky execution (performance)
max_cached_workflows=1000,
)- [ ] Concurrency limits: Based on load testing
- [ ] Multiple replicas: At least 2 for HA
- [ ] Sticky cache sized: Memory-appropriate
- [ ] Graceful shutdown: Configured and tested
Health Checks
# Kubernetes health checks
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5- [ ] Liveness probe: Worker process alive
- [ ] Readiness probe: Connected to Temporal
- [ ] Startup probe: For slow-starting workers
Task Queue Strategy
| Queue | Purpose | Workers |
|---|---|---|
critical-queue | Payment, core ops | Dedicated, high priority |
standard-queue | Regular workflows | Shared workers |
batch-queue | Background jobs | Separate, auto-scaled |
- [ ] Queue isolation: Separate queues for different SLAs
- [ ] Worker assignment: Right workers for right queues
- [ ] Pollers configured: Appropriate number per worker
Security Configuration
TLS Setup
# Client TLS configuration
tls_config = TLSConfig(
# Server CA certificate
server_root_ca_cert=ca_cert,
# Client certificate for mTLS
client_cert=client_cert,
client_private_key=client_key,
)
client = await Client.connect(
"temporal.example.com:7233",
tls=tls_config,
)- [ ] TLS certificates: Valid and not expiring soon
- [ ] Certificate rotation: Automated renewal
- [ ] mTLS enforced: Client authentication required
- [ ] Minimum TLS version: 1.2 or higher
Authentication & Authorization
- [ ] Client auth: mTLS or API keys
- [ ] Namespace access: RBAC configured
- [ ] Admin access: Limited to operators
- [ ] Audit logging: All admin operations logged
Secrets Management
- [ ] No hardcoded secrets: Use env vars or secret stores
- [ ] Secret rotation: Automated where possible
- [ ] Database credentials: In secure vault
- [ ] API keys: Properly scoped and rotated
Monitoring Setup
Metrics (Prometheus)
# Essential Temporal metrics to monitor
- temporal_workflow_started_total
- temporal_workflow_completed_total
- temporal_workflow_failed_total
- temporal_activity_execution_latency
- temporal_workflow_task_schedule_to_start_latency
- temporal_task_queue_poll_empty_total
- persistence_latency
- visibility_persistence_latency- [ ] Prometheus configured: Scraping all components
- [ ] Key metrics identified: Workflows, activities, persistence
- [ ] Histogram buckets: Appropriate for latency tracking
- [ ] Retention period: Long enough for trending
Dashboards
- [ ] Cluster overview: Health of all components
- [ ] Workflow metrics: Started, completed, failed by type
- [ ] Activity metrics: Duration, failures, retries
- [ ] Queue depth: Pending tasks per queue
- [ ] Latency: Schedule-to-start, execution times
Alerting Rules
# Example Prometheus alerts
groups:
- name: temporal
rules:
- alert: TemporalFrontendDown
expr: up{job="temporal-frontend"} == 0
for: 1m
labels:
severity: critical
- alert: HighWorkflowFailureRate
expr: |
rate(temporal_workflow_failed_total[5m])
/ rate(temporal_workflow_completed_total[5m]) > 0.05
for: 5m
labels:
severity: warning
- alert: LongScheduleToStartLatency
expr: |
histogram_quantile(0.99,
rate(temporal_workflow_task_schedule_to_start_latency_bucket[5m])
) > 30
for: 5m
labels:
severity: warning- [ ] Service down alerts: All components
- [ ] High failure rate: By workflow type
- [ ] Latency alerts: Schedule-to-start > threshold
- [ ] Queue buildup: Tasks pending too long
- [ ] Resource alerts: CPU, memory, disk
Logging
# Structured logging for workers
structlog.configure(
processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
],
context_class=dict,
logger_factory=structlog.PrintLoggerFactory(),
)- [ ] Structured logs: JSON format
- [ ] Correlation IDs: Workflow ID, run ID
- [ ] Log aggregation: Centralized logging
- [ ] Log retention: Based on compliance
Disaster Recovery
Backup Strategy
- [ ] Database backups: Regular, tested restores
- [ ] Configuration backups: Namespace configs, schemas
- [ ] Cross-region replication: For critical deployments
- [ ] Backup verification: Regular restore tests
Recovery Procedures
- [ ] Runbook created: Step-by-step recovery
- [ ] RTO defined: Recovery time objective
- [ ] RPO defined: Recovery point objective
- [ ] Tested quarterly: DR drills performed
Failover
- [ ] Multi-AZ deployment: For cloud deployments
- [ ] Database failover: Automated promotion
- [ ] DNS failover: For client connectivity
- [ ] Tested regularly: Failover drills
Performance Tuning
Cluster Tuning
# History service tuning
numHistoryShards: 512 # Power of 2, based on load
# Matching service tuning
matching:
numTaskqueueReadPartitions: 4
numTaskqueueWritePartitions: 4- [ ] History shards: Sized for expected load (512-2048)
- [ ] Task queue partitions: Based on throughput needs
- [ ] Cache settings: Tuned for memory
- [ ] Connection pools: Sized appropriately
Worker Tuning
- [ ] Poller count: Based on task queue load
- [ ] Concurrency limits: Tested under load
- [ ] Sticky cache: Memory vs. performance tradeoff
- [ ] Activity timeouts: Based on actual performance
Load Testing
# Example: Temporal bench tool
temporal-bench \
--server temporal.example.com:7233 \
--namespace load-test \
--workflow-count 10000 \
--concurrent-count 100 \
--scenario order-processing- [ ] Baseline established: Normal load metrics
- [ ] Peak load tested: 2-3x normal
- [ ] Failure tested: Behavior under stress
- [ ] Recovery tested: After overload
Pre-Go-Live
Verification
- [ ] Smoke tests passed: Basic workflow execution
- [ ] Integration tests passed: End-to-end flows
- [ ] Performance validated: Meets SLAs
- [ ] Security audit: Passed review
Documentation
- [ ] Architecture documented: Diagrams and descriptions
- [ ] Runbooks created: Operational procedures
- [ ] Troubleshooting guide: Common issues and fixes
- [ ] On-call rotation: Defined and staffed
Rollback Plan
- [ ] Rollback procedure: Documented steps
- [ ] Rollback tested: Verified works
- [ ] Rollback criteria: When to trigger
- [ ] Communication plan: Stakeholder notification
Post-Deployment
First Week Monitoring
- [ ] 24/7 monitoring: Close observation
- [ ] Daily review: Metrics and logs
- [ ] Issue tracking: Document any problems
- [ ] Tuning adjustments: Based on observations
Ongoing Operations
- [ ] Weekly metrics review: Trends and anomalies
- [ ] Monthly capacity planning: Growth projections
- [ ] Quarterly DR drill: Test recovery
- [ ] Upgrade planning: Track new versions
Temporal Production Deployment Checklist
Pre-Deployment
Infrastructure
- [ ] Temporal server/cloud configured with correct namespace
- [ ] TLS certificates provisioned for worker-server communication
- [ ] Database (PostgreSQL/MySQL/Cassandra) sized for expected load
- [ ] Elasticsearch configured for workflow visibility (if self-hosted)
- [ ] Network policies allow worker-to-server communication
Worker Configuration
- [ ] Task queue names finalized and documented
- [ ]
max_concurrent_activitiestuned for resource limits - [ ]
max_concurrent_workflow_tasksset appropriately - [ ] Worker scaling strategy defined (HPA, manual, etc.)
- [ ] Worker health checks implemented
- [ ] Graceful shutdown handling (SIGTERM)
Workflow Design
- [ ] All workflows are deterministic (no
random(),datetime.now()) - [ ] Non-deterministic operations moved to activities
- [ ]
continue_as_newimplemented for long-running workflows - [ ] Workflow IDs are business-meaningful and idempotent
- [ ] State size kept minimal (< 1MB per workflow)
Activity Design
- [ ] All activities are idempotent
- [ ] Heartbeating implemented for activities > 60s
- [ ] Timeouts configured:
start_to_close,heartbeat - [ ] Retry policies defined with
non_retryable_error_types - [ ] External API calls have circuit breakers
Deployment
Versioning
- [ ]
workflow.patched()used for breaking changes - [ ] Worker versioning (build IDs) configured if using worker versioning
- [ ] Old and new workers run simultaneously during rollout
- [ ] Plan to deprecate old patches after workflows complete
Monitoring
- [ ] Temporal metrics exported (Prometheus endpoint)
- [ ] Alerts configured for:
- [ ] Workflow failures
- [ ] Activity failures
- [ ] Task queue backlog
- [ ] Worker availability
- [ ] Dashboards created for workflow visibility
- [ ] Langfuse tracing integrated (if using LLM activities)
Testing
- [ ] Unit tests with
WorkflowEnvironment.start_local() - [ ] Integration tests against dev Temporal cluster
- [ ] Time-skipping tests for timer-heavy workflows
- [ ] Saga compensation paths tested
- [ ] Load testing completed
Post-Deployment
Validation
- [ ] Verify workflows start successfully
- [ ] Verify activities complete with expected results
- [ ] Verify signals and queries work
- [ ] Verify compensation runs on failure
- [ ] Check Temporal UI for workflow visibility
Runbook Items
- [ ] Document how to cancel stuck workflows
- [ ] Document how to query workflow state
- [ ] Document how to signal running workflows
- [ ] Document how to reset failed workflows
- [ ] Document how to scale workers
Security
- [ ] Namespace isolation between environments
- [ ] mTLS between workers and server
- [ ] Secrets not passed in workflow arguments (use activity to fetch)
- [ ] PII handling compliant with regulations
- [ ] Audit logging enabled
Common Issues to Check
| Issue | Check |
|---|---|
| Non-determinism | No random(), datetime.now(), uuid4() in workflows |
| History overflow | continue_as_new after ~1000 iterations |
| Activity timeout | start_to_close_timeout covers expected duration |
| Lost heartbeat | heartbeat_timeout is 1/3 of activity duration |
| Retry storm | non_retryable_error_types includes business errors |
| State too large | Workflow state < 1MB |
Workflow Design Checklist
Best practices for designing and implementing Temporal workflows.
Pre-Implementation
Requirements Analysis
- [ ] Identify durability needs: Does this process need to survive crashes/restarts?
- [ ] Define SLAs: What are the timeout requirements for each step?
- [ ] Map failure scenarios: What happens when each step fails?
- [ ] Plan compensations: For each action, what's the rollback?
- [ ] Identify idempotency requirements: Which operations must be idempotent?
- [ ] Define consistency model: Eventual or strong consistency needed?
Workflow Boundaries
- [ ] Single responsibility: Does this workflow do one logical thing?
- [ ] Reasonable duration: Will this complete in a reasonable time (< 30 days typical)?
- [ ] State size: Is the workflow state < 50KB serialized?
- [ ] Event history: Will history stay < 10K events?
Workflow Definition
Determinism Rules
# NEVER use in workflows:
# - random.random() -> workflow.random()
# - datetime.now() -> workflow.now()
# - uuid.uuid4() -> workflow.uuid4()
# - time.sleep() -> asyncio.sleep() or workflow.sleep()
# - os.environ -> Pass as workflow input
# - threading/multiprocessing -> Use activities
# - Direct I/O (files, network, DB) -> Use activities- [ ] No non-deterministic operations in workflow code
- [ ] No side effects (I/O, network, etc.) in workflow code
- [ ] All external calls go through activities
- [ ] Use workflow APIs for time, random, uuid
- [ ] Immutable inputs: Don't modify workflow input objects
State Management
- [ ] Initialize state in
__init__or at start ofrun - [ ] Private state fields: Use
_prefix for internal state - [ ] Queries for read access: Expose state via
@workflow.query - [ ] Signals for write access: Modify state via
@workflow.signal - [ ] Validate state transitions: Check valid state before changing
Error Handling
- [ ] Distinguish retryable vs non-retryable errors
- [ ] Use ApplicationError for non-retryable business errors
- [ ] Log with workflow.logger: Not standard logging
- [ ] Handle ContinueAsNew for long-running workflows
- [ ] Graceful degradation: What happens if an activity permanently fails?
Activity Definition
Idempotency
@activity.defn
async def create_order(order_id: str, data: dict) -> Order:
# WRONG: Creates duplicate on retry
# return await db.insert(Order(id=generate_id(), ...))
# CORRECT: Idempotent upsert
return await db.upsert(Order(id=order_id, ...))- [ ] Idempotency key: Use business-meaningful ID, not generated
- [ ] Upsert over insert: Prefer upsert for creates
- [ ] Check before mutate: Verify state before making changes
- [ ] Idempotent external calls: Use idempotency keys for APIs
Timeouts and Retries
| Timeout Type | When to Use | Example |
|---|---|---|
start_to_close | Max execution time | Database query: 30s |
schedule_to_close | Include queue wait time | Batch job: 1 hour |
schedule_to_start | Max queue wait | Alert if delayed: 5min |
heartbeat | Long-running activities | File processing: 60s |
- [ ] Always set timeouts: Never use default infinite
- [ ] start_to_close for most cases: Simple timeout semantics
- [ ] Heartbeat for long activities: > 60 seconds
- [ ] Retry policy configured: Attempts, backoff, max interval
- [ ] Non-retryable errors specified: Business logic failures
Heartbeating
@activity.defn
async def process_large_file(file_id: str) -> dict:
lines_processed = 0
with open(file_path) as f:
for line in f:
process_line(line)
lines_processed += 1
# Heartbeat every 100 lines
if lines_processed % 100 == 0:
activity.heartbeat(f"Processed {lines_processed} lines")
return {"lines": lines_processed}- [ ] Heartbeat progress for long activities
- [ ] Heartbeat frequency: At least every
heartbeat_timeout/3 - [ ] Include checkpoint data: Enable resume from heartbeat
- [ ] Handle cancellation: Check
activity.is_cancelled()if needed
Saga Pattern
Compensation Design
- [ ] Every action has compensation: Or explicit decision why not
- [ ] Compensations are idempotent: Can run multiple times safely
- [ ] Compensations always succeed: Retry until they do
- [ ] Reverse order: Run compensations in reverse
- [ ] Log compensation failures: For manual intervention
Compensation Testing
- [ ] Test happy path: All steps succeed
- [ ] Test each failure point: Fail at step 1, 2, 3, etc.
- [ ] Test compensation failures: What if compensation fails?
- [ ] Test concurrent modifications: Race conditions
Testing
Unit Tests
@pytest.mark.asyncio
async def test_workflow_happy_path(workflow_env):
async with Worker(
workflow_env.client,
task_queue="test",
workflows=[MyWorkflow],
activities=[my_activity],
):
result = await workflow_env.client.execute_workflow(
MyWorkflow.run,
test_input,
id="test-1",
task_queue="test",
)
assert result.status == "completed"- [ ] Use WorkflowEnvironment: Local testing without server
- [ ] Mock activities: Test workflow logic in isolation
- [ ] Test signals and queries: Verify external interactions
- [ ] Time skipping tests: For timer-heavy workflows
- [ ] Test error scenarios: Activity failures, cancellations
Integration Tests
- [ ] Real Temporal server: Use dev server or test cluster
- [ ] Real activities: With test databases/services
- [ ] End-to-end flows: Start to completion
- [ ] Concurrent workflows: Multiple simultaneous executions
- [ ] Long-running scenarios: Timer and schedule testing
Versioning and Updates
Workflow Versioning
# Safe code evolution
version = workflow.patched("add-notification-step")
if version:
await workflow.execute_activity(send_notification, ...)- [ ] Use workflow.patched() for breaking changes
- [ ] Version new code paths: Not old ones
- [ ] Plan deprecation: When to remove old code
- [ ] Test both paths: Old and new workflows
Workflow Updates
- [ ] Use @workflow.update for runtime modifications
- [ ] Validate update requests: Check preconditions
- [ ] Document update behavior: What can be updated when
Observability
Logging
- [ ] Use workflow.logger: Not standard logging
- [ ] Structured logging: Key-value pairs
- [ ] Log step transitions: Entry/exit of major steps
- [ ] Include workflow ID: For correlation
Metrics
- [ ] Workflow started/completed counters: By type
- [ ] Activity duration histograms: By type
- [ ] Error counters: By type and reason
- [ ] Queue depth: Pending tasks
Tracing
- [ ] Trace context propagation: Across activities
- [ ] Custom spans: For logical operations
- [ ] Link to parent traces: From client to workflow
Security
Input Validation
- [ ] Validate workflow inputs: Type and value checks
- [ ] Validate activity inputs: Before processing
- [ ] Sanitize outputs: No sensitive data in results
- [ ] Size limits: Prevent oversized payloads
Access Control
- [ ] Namespace isolation: Separate test/prod
- [ ] Task queue permissions: Restrict who can process
- [ ] Workflow start permissions: Who can initiate
- [ ] Query/signal permissions: Who can interact
Pre-Deployment
Performance
- [ ] Load tested: Expected throughput achieved
- [ ] Resource limits set: Worker concurrency
- [ ] Timeout tuning: Based on actual performance
- [ ] History size check: Events stay manageable
Operational Readiness
- [ ] Runbook created: Common operations documented
- [ ] Alerts configured: For failures and delays
- [ ] Dashboards ready: Visibility into operations
- [ ] Rollback plan: How to revert if needed
Order Processing Saga Example
Complete e-commerce order processing with inventory, payment, and shipping.
Architecture
┌─────────────────────────────────────────────────────────────┐
│ ORDER SAGA WORKFLOW │
├─────────────────────────────────────────────────────────────┤
│ │
│ [Order Request] ─────────────────────────────────────────► │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Reserve │ ◄──── Compensation: Release │
│ │ Inventory │ │
│ └────────┬────────┘ │
│ │ success │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Charge │ ◄──── Compensation: Refund │
│ │ Payment │ │
│ └────────┬────────┘ │
│ │ success │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Create │ ◄──── Compensation: Cancel │
│ │ Shipment │ │
│ └────────┬────────┘ │
│ │ success │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Send │ (no compensation needed) │
│ │ Confirmation │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘Data Models
from dataclasses import dataclass
from datetime import datetime
from enum import Enum
class OrderStatus(Enum):
PENDING = "pending"
PROCESSING = "processing"
COMPLETED = "completed"
FAILED = "failed"
COMPENSATING = "compensating"
@dataclass
class OrderItem:
sku: str
quantity: int
price: float
@dataclass
class Address:
street: str
city: str
state: str
zip_code: str
country: str
@dataclass
class OrderInput:
order_id: str
customer_id: str
items: list[OrderItem]
shipping_address: Address
payment_method_id: str
@dataclass
class OrderResult:
order_id: str
status: OrderStatus
reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
tracking_number: str | None = None
error: str | None = NoneActivities
from temporalio import activity
from temporalio.exceptions import ApplicationError
import httpx
@activity.defn
async def reserve_inventory(order_id: str, items: list[OrderItem]) -> str:
"""Reserve inventory for all items."""
activity.logger.info(f"Reserving inventory for order {order_id}")
async with httpx.AsyncClient() as client:
response = await client.post(
"https://inventory.internal/reservations",
json={
"order_id": order_id,
"items": [{"sku": i.sku, "qty": i.quantity} for i in items],
},
timeout=30,
)
if response.status_code == 409:
raise ApplicationError(
"Insufficient inventory",
non_retryable=True,
type="InsufficientInventory",
)
response.raise_for_status()
return response.json()["reservation_id"]
@activity.defn
async def release_inventory(reservation_id: str) -> None:
"""Compensation: Release reserved inventory."""
activity.logger.info(f"Releasing reservation {reservation_id}")
async with httpx.AsyncClient() as client:
response = await client.delete(
f"https://inventory.internal/reservations/{reservation_id}",
timeout=30,
)
# Ignore 404 - already released
if response.status_code != 404:
response.raise_for_status()
@activity.defn
async def charge_payment(
order_id: str,
customer_id: str,
payment_method_id: str,
amount: float,
) -> str:
"""Charge customer payment method."""
activity.logger.info(f"Charging ${amount} for order {order_id}")
async with httpx.AsyncClient() as client:
response = await client.post(
"https://payments.internal/charges",
json={
"order_id": order_id,
"customer_id": customer_id,
"payment_method_id": payment_method_id,
"amount": amount,
"currency": "USD",
"idempotency_key": f"order-{order_id}",
},
timeout=60,
)
if response.status_code == 402:
raise ApplicationError(
"Payment declined",
non_retryable=True,
type="PaymentDeclined",
)
response.raise_for_status()
return response.json()["payment_id"]
@activity.defn
async def refund_payment(payment_id: str) -> None:
"""Compensation: Refund payment."""
activity.logger.info(f"Refunding payment {payment_id}")
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://payments.internal/refunds",
json={"payment_id": payment_id, "reason": "order_cancelled"},
timeout=60,
)
response.raise_for_status()
@activity.defn
async def create_shipment(order_id: str, address: Address) -> dict:
"""Create shipment with carrier."""
activity.logger.info(f"Creating shipment for order {order_id}")
async with httpx.AsyncClient() as client:
response = await client.post(
"https://shipping.internal/shipments",
json={
"order_id": order_id,
"address": {
"street": address.street,
"city": address.city,
"state": address.state,
"zip": address.zip_code,
"country": address.country,
},
},
timeout=30,
)
response.raise_for_status()
data = response.json()
return {
"shipment_id": data["shipment_id"],
"tracking_number": data["tracking_number"],
}
@activity.defn
async def cancel_shipment(shipment_id: str) -> None:
"""Compensation: Cancel shipment."""
activity.logger.info(f"Cancelling shipment {shipment_id}")
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://shipping.internal/shipments/{shipment_id}/cancel",
timeout=30,
)
# Ignore 409 - already shipped, compensation failed
if response.status_code == 409:
activity.logger.warning(f"Shipment {shipment_id} already shipped")
elif response.status_code != 404:
response.raise_for_status()
@activity.defn
async def send_confirmation(customer_id: str, order_id: str, tracking: str) -> None:
"""Send order confirmation email."""
activity.logger.info(f"Sending confirmation for order {order_id}")
# Implementation: Send email via notification serviceWorkflow Implementation
from temporalio import workflow
from temporalio.common import RetryPolicy
from datetime import timedelta
@workflow.defn
class OrderSagaWorkflow:
def __init__(self):
self._status = OrderStatus.PENDING
self._reservation_id: str | None = None
self._payment_id: str | None = None
self._shipment: dict | None = None
@workflow.run
async def run(self, order: OrderInput) -> OrderResult:
self._status = OrderStatus.PROCESSING
compensations: list[tuple] = []
try:
# Step 1: Reserve inventory
self._reservation_id = await workflow.execute_activity(
reserve_inventory,
args=[order.order_id, order.items],
start_to_close_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(maximum_attempts=3),
)
compensations.append((release_inventory, self._reservation_id))
# Step 2: Charge payment
total = sum(item.price * item.quantity for item in order.items)
self._payment_id = await workflow.execute_activity(
charge_payment,
args=[order.order_id, order.customer_id, order.payment_method_id, total],
start_to_close_timeout=timedelta(minutes=5),
retry_policy=RetryPolicy(
maximum_attempts=3,
non_retryable_error_types=["PaymentDeclined"],
),
)
compensations.append((refund_payment, self._payment_id))
# Step 3: Create shipment
self._shipment = await workflow.execute_activity(
create_shipment,
args=[order.order_id, order.shipping_address],
start_to_close_timeout=timedelta(minutes=3),
retry_policy=RetryPolicy(maximum_attempts=3),
)
compensations.append((cancel_shipment, self._shipment["shipment_id"]))
# Step 4: Send confirmation (no compensation needed)
await workflow.execute_activity(
send_confirmation,
args=[order.customer_id, order.order_id, self._shipment["tracking_number"]],
start_to_close_timeout=timedelta(seconds=30),
)
self._status = OrderStatus.COMPLETED
return OrderResult(
order_id=order.order_id,
status=self._status,
reservation_id=self._reservation_id,
payment_id=self._payment_id,
shipment_id=self._shipment["shipment_id"],
tracking_number=self._shipment["tracking_number"],
)
except Exception as e:
self._status = OrderStatus.COMPENSATING
workflow.logger.warning(f"Order failed, compensating: {e}")
# Run compensations in reverse
for comp_fn, comp_arg in reversed(compensations):
try:
await workflow.execute_activity(
comp_fn,
comp_arg,
start_to_close_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(maximum_attempts=5),
)
except Exception as comp_error:
workflow.logger.error(f"Compensation failed: {comp_error}")
self._status = OrderStatus.FAILED
return OrderResult(
order_id=order.order_id,
status=self._status,
error=str(e),
)
@workflow.query
def get_status(self) -> OrderStatus:
return self._status
@workflow.signal
async def cancel(self, reason: str):
"""Cancel order (if not yet shipped)."""
if self._status == OrderStatus.COMPLETED:
workflow.logger.warning("Cannot cancel completed order")
return
raise ApplicationError(f"Order cancelled: {reason}", non_retryable=True)Client Usage
from temporalio.client import Client
async def place_order(order: OrderInput) -> OrderResult:
client = await Client.connect("temporal.example.com:7233")
# Start workflow with idempotent ID
result = await client.execute_workflow(
OrderSagaWorkflow.run,
order,
id=f"order-{order.order_id}",
task_queue="orders",
)
return result
async def get_order_status(order_id: str) -> OrderStatus:
client = await Client.connect("temporal.example.com:7233")
handle = client.get_workflow_handle(f"order-{order_id}")
return await handle.query(OrderSagaWorkflow.get_status)
async def cancel_order(order_id: str, reason: str):
client = await Client.connect("temporal.example.com:7233")
handle = client.get_workflow_handle(f"order-{order_id}")
await handle.signal(OrderSagaWorkflow.cancel, reason)Testing
import pytest
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker
@pytest.mark.asyncio
async def test_order_saga_success():
async with await WorkflowEnvironment.start_local() as env:
async with Worker(
env.client,
task_queue="test",
workflows=[OrderSagaWorkflow],
activities=[
reserve_inventory,
charge_payment,
create_shipment,
send_confirmation,
],
):
result = await env.client.execute_workflow(
OrderSagaWorkflow.run,
test_order,
id="test-order-1",
task_queue="test",
)
assert result.status == OrderStatus.COMPLETED
assert result.tracking_number is not None
@pytest.mark.asyncio
async def test_order_saga_payment_failure():
"""Test compensation runs when payment fails."""
async with await WorkflowEnvironment.start_local() as env:
# Mock payment to fail
@activity.defn(name="charge_payment")
async def mock_payment(*args):
raise ApplicationError("Declined", non_retryable=True)
async with Worker(
env.client,
task_queue="test",
workflows=[OrderSagaWorkflow],
activities=[reserve_inventory, mock_payment, release_inventory],
):
result = await env.client.execute_workflow(
OrderSagaWorkflow.run,
test_order,
id="test-order-2",
task_queue="test",
)
assert result.status == OrderStatus.FAILED
# Verify inventory was released (compensation ran)# Order Fulfillment Saga Example
# Complete implementation of a production-ready order saga with Temporal
"""
Order Fulfillment Saga
This example demonstrates a complete order fulfillment process using the saga pattern:
1. Validate Order -> No compensation (read-only)
2. Reserve Inventory -> Release Inventory
3. Process Payment -> Refund Payment
4. Create Shipment -> Cancel Shipment
5. Send Confirmation -> No compensation (notification)
Key features:
- Automatic compensation on failure
- Idempotent operations
- Structured error handling
- Query and signal support
- Production-ready patterns
"""
import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from enum import Enum
from typing import Any
from uuid import UUID
from temporalio import activity, workflow
from temporalio.client import Client
from temporalio.common import RetryPolicy
from temporalio.exceptions import ApplicationError
from temporalio.worker import Worker
# ============================================================================
# Domain Models
# ============================================================================
class OrderStatus(str, Enum):
PENDING = "pending"
VALIDATING = "validating"
RESERVING_INVENTORY = "reserving_inventory"
PROCESSING_PAYMENT = "processing_payment"
CREATING_SHIPMENT = "creating_shipment"
SENDING_CONFIRMATION = "sending_confirmation"
COMPLETED = "completed"
COMPENSATING = "compensating"
FAILED = "failed"
@dataclass
class OrderItem:
sku: str
quantity: int
unit_price: Decimal
@dataclass
class Address:
street: str
city: str
state: str
postal_code: str
country: str
@dataclass
class OrderInput:
"""Input for the order fulfillment workflow."""
order_id: str
customer_id: str
items: list[OrderItem]
shipping_address: Address
billing_address: Address
idempotency_key: str # For payment idempotency
@dataclass
class OrderResult:
"""Result of the order fulfillment workflow."""
order_id: str
status: str
reservation_id: str | None = None
payment_id: str | None = None
shipment_id: str | None = None
tracking_number: str | None = None
completed_at: datetime | None = None
error: str | None = None
# Activity inputs/outputs
@dataclass
class ValidationResult:
valid: bool
total_amount: Decimal
errors: list[str] = field(default_factory=list)
@dataclass
class ReservationResult:
reservation_id: str
items_reserved: int
warehouse_id: str
@dataclass
class PaymentResult:
payment_id: str
amount: Decimal
status: str
transaction_id: str
@dataclass
class ShipmentResult:
shipment_id: str
tracking_number: str
carrier: str
estimated_delivery: datetime
# ============================================================================
# Activities
# ============================================================================
@activity.defn
async def validate_order(order: OrderInput) -> ValidationResult:
"""
Validate order details.
No compensation needed - read-only operation.
"""
activity.logger.info(f"Validating order {order.order_id}")
errors = []
# Validate items
if not order.items:
errors.append("Order must have at least one item")
for item in order.items:
if item.quantity <= 0:
errors.append(f"Invalid quantity for {item.sku}")
if item.unit_price <= 0:
errors.append(f"Invalid price for {item.sku}")
# Calculate total
total = sum(
item.unit_price * item.quantity
for item in order.items
)
if total <= 0:
errors.append("Order total must be positive")
# Validate addresses
if not order.shipping_address.postal_code:
errors.append("Shipping postal code is required")
return ValidationResult(
valid=len(errors) == 0,
total_amount=total,
errors=errors,
)
@activity.defn
async def reserve_inventory(order: OrderInput) -> ReservationResult:
"""
Reserve inventory for order items.
Compensation: release_inventory
"""
activity.logger.info(f"Reserving inventory for order {order.order_id}")
# Simulate inventory API call
# In production: call inventory service with idempotency key
await asyncio.sleep(0.5)
# Simulate occasional failures for testing
# import random
# if random.random() < 0.1:
# raise ApplicationError("Inventory service unavailable", non_retryable=False)
return ReservationResult(
reservation_id=f"res-{order.order_id}",
items_reserved=len(order.items),
warehouse_id="WH-001",
)
@activity.defn
async def release_inventory(reservation_id: str) -> None:
"""
Compensate: Release reserved inventory.
Must be idempotent - safe to call multiple times.
"""
activity.logger.info(f"Releasing inventory reservation {reservation_id}")
# Simulate release API call
await asyncio.sleep(0.2)
# Idempotent: releasing non-existent reservation is a no-op
activity.logger.info(f"Inventory released: {reservation_id}")
@activity.defn
async def process_payment(
order: OrderInput,
amount: Decimal,
) -> PaymentResult:
"""
Process customer payment.
Compensation: refund_payment
Uses idempotency_key to prevent duplicate charges on retry.
"""
activity.logger.info(
f"Processing payment for order {order.order_id}, amount: {amount}"
)
# Simulate payment API call with idempotency key
await asyncio.sleep(1.0)
# Example: simulate payment declined
# if amount > 1000:
# raise ApplicationError(
# "Payment declined: insufficient funds",
# non_retryable=True,
# type="PaymentDeclined",
# )
return PaymentResult(
payment_id=f"pay-{order.order_id}",
amount=amount,
status="captured",
transaction_id=f"txn-{order.idempotency_key}",
)
@activity.defn
async def refund_payment(payment_id: str, amount: Decimal) -> None:
"""
Compensate: Refund payment.
Must be idempotent - safe to call multiple times.
"""
activity.logger.info(f"Refunding payment {payment_id}, amount: {amount}")
# Simulate refund API call
await asyncio.sleep(0.5)
# Idempotent: refunding already-refunded payment is a no-op
activity.logger.info(f"Payment refunded: {payment_id}")
@activity.defn
async def create_shipment(
order: OrderInput,
reservation_id: str,
) -> ShipmentResult:
"""
Create shipment for the order.
Compensation: cancel_shipment
"""
activity.logger.info(f"Creating shipment for order {order.order_id}")
# Simulate shipping API call
await asyncio.sleep(0.5)
return ShipmentResult(
shipment_id=f"ship-{order.order_id}",
tracking_number=f"TRK-{order.order_id}-001",
carrier="FedEx",
estimated_delivery=datetime.now(timezone.utc) + timedelta(days=3),
)
@activity.defn
async def cancel_shipment(shipment_id: str) -> None:
"""
Compensate: Cancel shipment.
Must be idempotent - safe to call multiple times.
"""
activity.logger.info(f"Cancelling shipment {shipment_id}")
# Simulate cancel API call
await asyncio.sleep(0.2)
# Idempotent: cancelling already-cancelled shipment is a no-op
activity.logger.info(f"Shipment cancelled: {shipment_id}")
@activity.defn
async def send_order_confirmation(
order: OrderInput,
payment: PaymentResult,
shipment: ShipmentResult,
) -> dict:
"""
Send order confirmation email.
No compensation needed - notification is best-effort.
"""
activity.logger.info(f"Sending confirmation for order {order.order_id}")
# Simulate email send
await asyncio.sleep(0.2)
return {
"notification_id": f"notif-{order.order_id}",
"sent_to": f"customer-{order.customer_id}@example.com",
"tracking_number": shipment.tracking_number,
}
# ============================================================================
# Workflow
# ============================================================================
@workflow.defn
class OrderFulfillmentWorkflow:
"""
Order fulfillment saga workflow.
Orchestrates the complete order fulfillment process with
automatic compensation on failure.
"""
def __init__(self):
self._status = OrderStatus.PENDING
self._order: OrderInput | None = None
self._validation: ValidationResult | None = None
self._reservation: ReservationResult | None = None
self._payment: PaymentResult | None = None
self._shipment: ShipmentResult | None = None
self._error: str | None = None
self._compensations: list[tuple[str, Any]] = []
@workflow.run
async def run(self, order: OrderInput) -> OrderResult:
"""Execute the order fulfillment saga."""
self._order = order
workflow.logger.info(f"Starting order fulfillment: {order.order_id}")
try:
# Step 1: Validate order (no compensation)
self._status = OrderStatus.VALIDATING
self._validation = await workflow.execute_activity(
validate_order,
order,
start_to_close_timeout=timedelta(seconds=30),
)
if not self._validation.valid:
raise ApplicationError(
f"Order validation failed: {self._validation.errors}",
non_retryable=True,
type="ValidationFailed",
)
# Step 2: Reserve inventory
self._status = OrderStatus.RESERVING_INVENTORY
self._reservation = await workflow.execute_activity(
reserve_inventory,
order,
start_to_close_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=1),
),
)
# Register compensation
self._compensations.append(
("release_inventory", self._reservation.reservation_id)
)
# Step 3: Process payment
self._status = OrderStatus.PROCESSING_PAYMENT
self._payment = await workflow.execute_activity(
process_payment,
args=[order, self._validation.total_amount],
start_to_close_timeout=timedelta(minutes=5),
retry_policy=RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=2),
non_retryable_error_types=["PaymentDeclined"],
),
)
# Register compensation
self._compensations.append(
("refund_payment", (self._payment.payment_id, self._payment.amount))
)
# Step 4: Create shipment
self._status = OrderStatus.CREATING_SHIPMENT
self._shipment = await workflow.execute_activity(
create_shipment,
args=[order, self._reservation.reservation_id],
start_to_close_timeout=timedelta(minutes=3),
retry_policy=RetryPolicy(maximum_attempts=3),
)
# Register compensation
self._compensations.append(
("cancel_shipment", self._shipment.shipment_id)
)
# Step 5: Send confirmation (no compensation)
self._status = OrderStatus.SENDING_CONFIRMATION
await workflow.execute_activity(
send_order_confirmation,
args=[order, self._payment, self._shipment],
start_to_close_timeout=timedelta(minutes=1),
retry_policy=RetryPolicy(maximum_attempts=2),
)
# Success!
self._status = OrderStatus.COMPLETED
workflow.logger.info(f"Order completed: {order.order_id}")
return OrderResult(
order_id=order.order_id,
status="completed",
reservation_id=self._reservation.reservation_id,
payment_id=self._payment.payment_id,
shipment_id=self._shipment.shipment_id,
tracking_number=self._shipment.tracking_number,
completed_at=workflow.now(),
)
except Exception as e:
self._error = str(e)
self._status = OrderStatus.COMPENSATING
workflow.logger.warning(
f"Order {order.order_id} failed, running "
f"{len(self._compensations)} compensations: {e}"
)
# Run compensations in reverse order
await self._run_compensations()
self._status = OrderStatus.FAILED
return OrderResult(
order_id=order.order_id,
status="failed",
reservation_id=self._reservation.reservation_id if self._reservation else None,
payment_id=self._payment.payment_id if self._payment else None,
shipment_id=self._shipment.shipment_id if self._shipment else None,
error=str(e),
)
async def _run_compensations(self):
"""Run all registered compensations in reverse order."""
for compensation_name, compensation_args in reversed(self._compensations):
try:
workflow.logger.info(f"Running compensation: {compensation_name}")
if compensation_name == "release_inventory":
await workflow.execute_activity(
release_inventory,
compensation_args,
start_to_close_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(maximum_attempts=5),
)
elif compensation_name == "refund_payment":
payment_id, amount = compensation_args
await workflow.execute_activity(
refund_payment,
args=[payment_id, amount],
start_to_close_timeout=timedelta(minutes=5),
retry_policy=RetryPolicy(maximum_attempts=5),
)
elif compensation_name == "cancel_shipment":
await workflow.execute_activity(
cancel_shipment,
compensation_args,
start_to_close_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(maximum_attempts=5),
)
except Exception as comp_error:
# Log but continue with other compensations
workflow.logger.error(
f"Compensation {compensation_name} failed: {comp_error}. "
"Manual intervention required."
)
# =========================================================================
# Queries - Read workflow state
# =========================================================================
@workflow.query
def get_status(self) -> str:
"""Get current order status."""
return self._status.value
@workflow.query
def get_order_details(self) -> dict:
"""Get full order details."""
return {
"order_id": self._order.order_id if self._order else None,
"status": self._status.value,
"total_amount": str(self._validation.total_amount) if self._validation else None,
"reservation_id": self._reservation.reservation_id if self._reservation else None,
"payment_id": self._payment.payment_id if self._payment else None,
"shipment_id": self._shipment.shipment_id if self._shipment else None,
"tracking_number": self._shipment.tracking_number if self._shipment else None,
"error": self._error,
}
# =========================================================================
# Signals - External input to workflow
# =========================================================================
@workflow.signal
async def expedite_shipping(self):
"""
Signal to expedite shipping.
Only valid before shipment is created.
"""
if self._shipment:
workflow.logger.warning("Cannot expedite - already shipped")
return
workflow.logger.info("Expedite shipping requested")
# In production: set a flag that create_shipment reads
# ============================================================================
# Client Usage Example
# ============================================================================
async def example_usage():
"""Example: Starting and monitoring order workflow."""
# Connect to Temporal
client = await Client.connect("localhost:7233")
# Create order input
order = OrderInput(
order_id="ORD-12345",
customer_id="CUST-001",
items=[
OrderItem(sku="SKU-001", quantity=2, unit_price=Decimal("29.99")),
OrderItem(sku="SKU-002", quantity=1, unit_price=Decimal("49.99")),
],
shipping_address=Address(
street="123 Main St",
city="San Francisco",
state="CA",
postal_code="94105",
country="US",
),
billing_address=Address(
street="123 Main St",
city="San Francisco",
state="CA",
postal_code="94105",
country="US",
),
idempotency_key="idem-ORD-12345-v1",
)
# Start workflow
handle = await client.start_workflow(
OrderFulfillmentWorkflow.run,
order,
id=f"order-{order.order_id}",
task_queue="order-processing",
)
print(f"Started workflow: {handle.id}")
# Query status while running
await asyncio.sleep(1)
status = await handle.query(OrderFulfillmentWorkflow.get_status)
print(f"Current status: {status}")
# Wait for completion
result = await handle.result()
print(f"Order completed: {result}")
return result
# ============================================================================
# Worker Setup
# ============================================================================
async def run_worker():
"""Run the order fulfillment worker."""
client = await Client.connect("localhost:7233")
worker = Worker(
client,
task_queue="order-processing",
workflows=[OrderFulfillmentWorkflow],
activities=[
validate_order,
reserve_inventory,
release_inventory,
process_payment,
refund_payment,
create_shipment,
cancel_shipment,
send_order_confirmation,
],
)
print("Starting order fulfillment worker...")
await worker.run()
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "client":
asyncio.run(example_usage())
else:
asyncio.run(run_worker())
# Subscription Billing Workflow Example
# Recurring billing with Temporal - production patterns
"""
Subscription Billing Workflow
This example demonstrates a production-ready recurring billing system:
1. Subscription lifecycle management (create, pause, resume, cancel)
2. Recurring billing with retry and grace period
3. Dunning process for failed payments
4. Proration for plan changes
5. Usage-based billing aggregation
Key features:
- Long-running workflow (months/years)
- Continue-as-new for history management
- Signals for lifecycle events
- Queries for subscription status
- Durable timers for billing cycles
"""
import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from enum import Enum
from typing import Any
from temporalio import activity, workflow
from temporalio.client import Client
from temporalio.common import RetryPolicy
from temporalio.exceptions import ApplicationError
from temporalio.worker import Worker
# ============================================================================
# Domain Models
# ============================================================================
class SubscriptionStatus(str, Enum):
ACTIVE = "active"
PAST_DUE = "past_due"
PAUSED = "paused"
CANCELLED = "cancelled"
EXPIRED = "expired"
class BillingInterval(str, Enum):
MONTHLY = "monthly"
QUARTERLY = "quarterly"
YEARLY = "yearly"
@dataclass
class Plan:
plan_id: str
name: str
amount: Decimal
interval: BillingInterval
trial_days: int = 0
@dataclass
class SubscriptionInput:
"""Input for creating a subscription."""
subscription_id: str
customer_id: str
plan: Plan
payment_method_id: str
start_date: datetime | None = None # None = start immediately
@dataclass
class SubscriptionState:
"""Current state of a subscription."""
subscription_id: str
customer_id: str
plan: Plan
status: SubscriptionStatus
current_period_start: datetime
current_period_end: datetime
payment_method_id: str
billing_cycle_count: int = 0
failed_payment_attempts: int = 0
last_payment_date: datetime | None = None
cancelled_at: datetime | None = None
pause_start: datetime | None = None
metadata: dict = field(default_factory=dict)
@dataclass
class InvoiceResult:
invoice_id: str
amount: Decimal
status: str # "paid", "failed", "pending"
payment_id: str | None = None
error: str | None = None
@dataclass
class UsageRecord:
"""Usage-based billing record."""
subscription_id: str
metric: str
quantity: int
unit_price: Decimal
recorded_at: datetime
# ============================================================================
# Activities
# ============================================================================
@activity.defn
async def create_invoice(
subscription_id: str,
customer_id: str,
amount: Decimal,
period_start: datetime,
period_end: datetime,
line_items: list[dict] | None = None,
) -> str:
"""Create an invoice for the billing period."""
activity.logger.info(
f"Creating invoice for subscription {subscription_id}, amount: {amount}"
)
# Simulate invoice creation in billing system
await asyncio.sleep(0.2)
invoice_id = f"inv-{subscription_id}-{period_start.strftime('%Y%m%d')}"
activity.logger.info(f"Invoice created: {invoice_id}")
return invoice_id
@activity.defn
async def charge_payment(
invoice_id: str,
payment_method_id: str,
amount: Decimal,
idempotency_key: str,
) -> InvoiceResult:
"""Attempt to charge the customer's payment method."""
activity.logger.info(
f"Charging payment method {payment_method_id} for invoice {invoice_id}"
)
# Simulate payment processing
await asyncio.sleep(0.5)
# Simulate occasional payment failures for testing dunning
# import random
# if random.random() < 0.2:
# return InvoiceResult(
# invoice_id=invoice_id,
# amount=amount,
# status="failed",
# error="Card declined",
# )
return InvoiceResult(
invoice_id=invoice_id,
amount=amount,
status="paid",
payment_id=f"pay-{invoice_id}",
)
@activity.defn
async def send_payment_failed_notification(
customer_id: str,
invoice_id: str,
attempt_number: int,
next_retry_date: datetime | None,
) -> None:
"""Notify customer of failed payment."""
activity.logger.info(
f"Sending payment failed notification to {customer_id}, "
f"attempt {attempt_number}"
)
# Simulate email/SMS notification
await asyncio.sleep(0.1)
@activity.defn
async def send_subscription_cancelled_notification(
customer_id: str,
subscription_id: str,
reason: str,
) -> None:
"""Notify customer of subscription cancellation."""
activity.logger.info(
f"Sending cancellation notification to {customer_id}: {reason}"
)
await asyncio.sleep(0.1)
@activity.defn
async def get_usage_records(
subscription_id: str,
period_start: datetime,
period_end: datetime,
) -> list[UsageRecord]:
"""Fetch usage records for usage-based billing."""
activity.logger.info(
f"Fetching usage for {subscription_id} from {period_start} to {period_end}"
)
# Simulate fetching from usage tracking system
await asyncio.sleep(0.2)
# Example usage records
return [
UsageRecord(
subscription_id=subscription_id,
metric="api_calls",
quantity=1500,
unit_price=Decimal("0.001"),
recorded_at=period_start + timedelta(days=15),
),
UsageRecord(
subscription_id=subscription_id,
metric="storage_gb",
quantity=25,
unit_price=Decimal("0.10"),
recorded_at=period_start + timedelta(days=15),
),
]
@activity.defn
async def calculate_proration(
old_plan: Plan,
new_plan: Plan,
days_remaining: int,
total_days: int,
) -> Decimal:
"""Calculate proration amount for plan change."""
activity.logger.info(
f"Calculating proration: {old_plan.name} -> {new_plan.name}"
)
# Credit for unused time on old plan
old_daily_rate = old_plan.amount / Decimal(total_days)
credit = old_daily_rate * days_remaining
# Charge for remaining time on new plan
new_daily_rate = new_plan.amount / Decimal(total_days)
charge = new_daily_rate * days_remaining
proration = charge - credit
activity.logger.info(f"Proration amount: {proration}")
return proration
@activity.defn
async def update_subscription_in_database(state: SubscriptionState) -> None:
"""Persist subscription state to database."""
activity.logger.info(f"Updating subscription {state.subscription_id} in database")
await asyncio.sleep(0.1)
# ============================================================================
# Billing Workflow
# ============================================================================
@workflow.defn
class SubscriptionBillingWorkflow:
"""
Long-running subscription billing workflow.
Handles:
- Recurring billing cycles
- Payment retries (dunning)
- Subscription lifecycle (pause, resume, cancel)
- Plan changes with proration
- Usage-based billing
"""
# Configuration
MAX_PAYMENT_ATTEMPTS = 4
PAYMENT_RETRY_INTERVALS = [
timedelta(days=3),
timedelta(days=5),
timedelta(days=7),
]
GRACE_PERIOD_DAYS = 14
MAX_BILLING_CYCLES_BEFORE_CONTINUE_AS_NEW = 12 # ~1 year for monthly
def __init__(self):
self._state: SubscriptionState | None = None
self._pending_plan_change: Plan | None = None
self._cancel_requested = False
self._pause_requested = False
self._resume_requested = False
@workflow.run
async def run(self, input: SubscriptionInput) -> dict:
"""
Main subscription billing loop.
Runs until subscription is cancelled or expires.
Uses continue-as-new to manage history size.
"""
# Initialize state
now = workflow.now()
period_start = input.start_date or now
period_end = self._calculate_period_end(period_start, input.plan.interval)
self._state = SubscriptionState(
subscription_id=input.subscription_id,
customer_id=input.customer_id,
plan=input.plan,
status=SubscriptionStatus.ACTIVE,
current_period_start=period_start,
current_period_end=period_end,
payment_method_id=input.payment_method_id,
)
workflow.logger.info(
f"Starting subscription {input.subscription_id} for {input.customer_id}"
)
# Handle trial period
if input.plan.trial_days > 0:
workflow.logger.info(f"Trial period: {input.plan.trial_days} days")
trial_end = period_start + timedelta(days=input.plan.trial_days)
await self._wait_until(trial_end)
# Main billing loop
while self._state.status == SubscriptionStatus.ACTIVE:
# Check for continue-as-new
if self._state.billing_cycle_count >= self.MAX_BILLING_CYCLES_BEFORE_CONTINUE_AS_NEW:
workflow.logger.info("Continuing as new workflow")
workflow.continue_as_new(self._create_continuation_input())
# Wait for billing date
await self._wait_until(self._state.current_period_end)
# Check if cancelled or paused during wait
if self._cancel_requested:
await self._handle_cancellation("customer_requested")
break
if self._pause_requested:
await self._handle_pause()
continue
# Handle pending plan change
if self._pending_plan_change:
await self._apply_plan_change()
# Bill the customer
success = await self._process_billing_cycle()
if not success:
# Dunning failed - cancel subscription
await self._handle_cancellation("payment_failed")
break
# Advance to next period
self._advance_period()
# Persist final state
await workflow.execute_activity(
update_subscription_in_database,
self._state,
start_to_close_timeout=timedelta(seconds=30),
)
return {
"subscription_id": self._state.subscription_id,
"final_status": self._state.status.value,
"billing_cycles": self._state.billing_cycle_count,
"cancelled_at": self._state.cancelled_at.isoformat() if self._state.cancelled_at else None,
}
async def _process_billing_cycle(self) -> bool:
"""
Process a single billing cycle.
Returns True if payment succeeded, False if all retries exhausted.
"""
workflow.logger.info(
f"Processing billing cycle {self._state.billing_cycle_count + 1}"
)
# Get usage-based charges
usage_records = await workflow.execute_activity(
get_usage_records,
args=[
self._state.subscription_id,
self._state.current_period_start,
self._state.current_period_end,
],
start_to_close_timeout=timedelta(minutes=2),
)
# Calculate total amount
base_amount = self._state.plan.amount
usage_amount = sum(
r.quantity * r.unit_price for r in usage_records
)
total_amount = base_amount + usage_amount
# Create invoice
invoice_id = await workflow.execute_activity(
create_invoice,
args=[
self._state.subscription_id,
self._state.customer_id,
total_amount,
self._state.current_period_start,
self._state.current_period_end,
[{"metric": r.metric, "quantity": r.quantity} for r in usage_records],
],
start_to_close_timeout=timedelta(minutes=1),
)
# Attempt payment with retries
for attempt in range(self.MAX_PAYMENT_ATTEMPTS):
result = await workflow.execute_activity(
charge_payment,
args=[
invoice_id,
self._state.payment_method_id,
total_amount,
f"{invoice_id}-attempt-{attempt}",
],
start_to_close_timeout=timedelta(minutes=5),
retry_policy=RetryPolicy(
maximum_attempts=2, # Network retries
non_retryable_error_types=["CardDeclined", "InsufficientFunds"],
),
)
if result.status == "paid":
self._state.last_payment_date = workflow.now()
self._state.failed_payment_attempts = 0
self._state.status = SubscriptionStatus.ACTIVE
workflow.logger.info(f"Payment succeeded: {result.payment_id}")
return True
# Payment failed
self._state.failed_payment_attempts = attempt + 1
self._state.status = SubscriptionStatus.PAST_DUE
workflow.logger.warning(
f"Payment failed (attempt {attempt + 1}): {result.error}"
)
# Notify customer
next_retry = None
if attempt < len(self.PAYMENT_RETRY_INTERVALS):
next_retry = workflow.now() + self.PAYMENT_RETRY_INTERVALS[attempt]
await workflow.execute_activity(
send_payment_failed_notification,
args=[
self._state.customer_id,
invoice_id,
attempt + 1,
next_retry,
],
start_to_close_timeout=timedelta(minutes=1),
)
# Wait before retry (unless last attempt)
if attempt < len(self.PAYMENT_RETRY_INTERVALS):
await asyncio.sleep(self.PAYMENT_RETRY_INTERVALS[attempt].total_seconds())
# All attempts failed
workflow.logger.error(
f"All payment attempts exhausted for {self._state.subscription_id}"
)
return False
async def _handle_cancellation(self, reason: str):
"""Cancel the subscription."""
workflow.logger.info(f"Cancelling subscription: {reason}")
self._state.status = SubscriptionStatus.CANCELLED
self._state.cancelled_at = workflow.now()
await workflow.execute_activity(
send_subscription_cancelled_notification,
args=[self._state.customer_id, self._state.subscription_id, reason],
start_to_close_timeout=timedelta(minutes=1),
)
async def _handle_pause(self):
"""Pause the subscription."""
workflow.logger.info("Pausing subscription")
self._state.status = SubscriptionStatus.PAUSED
self._state.pause_start = workflow.now()
self._pause_requested = False
# Wait for resume signal
await workflow.wait_condition(
lambda: self._resume_requested or self._cancel_requested
)
if self._resume_requested:
# Resume from pause
pause_duration = workflow.now() - self._state.pause_start
self._state.current_period_end += pause_duration # Extend period
self._state.status = SubscriptionStatus.ACTIVE
self._state.pause_start = None
self._resume_requested = False
workflow.logger.info("Subscription resumed")
async def _apply_plan_change(self):
"""Apply pending plan change with proration."""
new_plan = self._pending_plan_change
self._pending_plan_change = None
now = workflow.now()
days_remaining = (self._state.current_period_end - now).days
total_days = (
self._state.current_period_end - self._state.current_period_start
).days
# Calculate proration
proration = await workflow.execute_activity(
calculate_proration,
args=[self._state.plan, new_plan, days_remaining, total_days],
start_to_close_timeout=timedelta(seconds=30),
)
# Charge or credit proration
if proration > 0:
invoice_id = await workflow.execute_activity(
create_invoice,
args=[
self._state.subscription_id,
self._state.customer_id,
proration,
now,
self._state.current_period_end,
[{"description": "Plan change proration"}],
],
start_to_close_timeout=timedelta(minutes=1),
)
await workflow.execute_activity(
charge_payment,
args=[
invoice_id,
self._state.payment_method_id,
proration,
f"prorate-{self._state.subscription_id}-{now.isoformat()}",
],
start_to_close_timeout=timedelta(minutes=5),
)
# Update plan
old_plan = self._state.plan
self._state.plan = new_plan
workflow.logger.info(f"Plan changed: {old_plan.name} -> {new_plan.name}")
def _advance_period(self):
"""Advance to the next billing period."""
self._state.current_period_start = self._state.current_period_end
self._state.current_period_end = self._calculate_period_end(
self._state.current_period_start,
self._state.plan.interval,
)
self._state.billing_cycle_count += 1
def _calculate_period_end(
self,
start: datetime,
interval: BillingInterval,
) -> datetime:
"""Calculate billing period end date."""
if interval == BillingInterval.MONTHLY:
# Add one month
if start.month == 12:
return start.replace(year=start.year + 1, month=1)
return start.replace(month=start.month + 1)
elif interval == BillingInterval.QUARTERLY:
# Add three months
month = start.month + 3
year = start.year
if month > 12:
month -= 12
year += 1
return start.replace(year=year, month=month)
elif interval == BillingInterval.YEARLY:
return start.replace(year=start.year + 1)
else:
raise ValueError(f"Unknown interval: {interval}")
async def _wait_until(self, target: datetime):
"""Wait until target time, handling signals."""
while workflow.now() < target:
remaining = (target - workflow.now()).total_seconds()
# Wait in chunks to check for signals
wait_time = min(remaining, 3600) # Max 1 hour chunks
try:
await workflow.wait_condition(
lambda: (
self._cancel_requested
or self._pause_requested
or self._resume_requested
or self._pending_plan_change is not None
),
timeout=timedelta(seconds=wait_time),
)
# Signal received - return to main loop
return
except asyncio.TimeoutError:
# No signal, continue waiting
continue
def _create_continuation_input(self) -> SubscriptionInput:
"""Create input for continue-as-new."""
return SubscriptionInput(
subscription_id=self._state.subscription_id,
customer_id=self._state.customer_id,
plan=self._state.plan,
payment_method_id=self._state.payment_method_id,
start_date=self._state.current_period_start,
)
# =========================================================================
# Signals
# =========================================================================
@workflow.signal
async def cancel(self, reason: str = "customer_requested"):
"""Cancel the subscription at end of current period."""
workflow.logger.info(f"Cancel requested: {reason}")
self._cancel_requested = True
@workflow.signal
async def pause(self):
"""Pause the subscription."""
workflow.logger.info("Pause requested")
self._pause_requested = True
@workflow.signal
async def resume(self):
"""Resume a paused subscription."""
workflow.logger.info("Resume requested")
self._resume_requested = True
@workflow.signal
async def change_plan(self, new_plan: Plan):
"""Change subscription plan (takes effect next cycle)."""
workflow.logger.info(f"Plan change requested: {new_plan.name}")
self._pending_plan_change = new_plan
@workflow.signal
async def update_payment_method(self, payment_method_id: str):
"""Update payment method."""
workflow.logger.info(f"Payment method updated: {payment_method_id}")
self._state.payment_method_id = payment_method_id
# =========================================================================
# Queries
# =========================================================================
@workflow.query
def get_status(self) -> str:
"""Get subscription status."""
return self._state.status.value if self._state else "unknown"
@workflow.query
def get_state(self) -> dict:
"""Get full subscription state."""
if not self._state:
return {}
return {
"subscription_id": self._state.subscription_id,
"customer_id": self._state.customer_id,
"plan": {
"id": self._state.plan.plan_id,
"name": self._state.plan.name,
"amount": str(self._state.plan.amount),
},
"status": self._state.status.value,
"current_period_start": self._state.current_period_start.isoformat(),
"current_period_end": self._state.current_period_end.isoformat(),
"billing_cycle_count": self._state.billing_cycle_count,
"failed_payment_attempts": self._state.failed_payment_attempts,
"last_payment_date": (
self._state.last_payment_date.isoformat()
if self._state.last_payment_date
else None
),
"pending_plan_change": (
self._pending_plan_change.name if self._pending_plan_change else None
),
}
@workflow.query
def get_next_billing_date(self) -> str | None:
"""Get next billing date."""
if not self._state or self._state.status != SubscriptionStatus.ACTIVE:
return None
return self._state.current_period_end.isoformat()
# ============================================================================
# Client Usage Example
# ============================================================================
async def example_usage():
"""Example: Create and manage a subscription."""
client = await Client.connect("localhost:7233")
# Create subscription
plan = Plan(
plan_id="pro-monthly",
name="Pro Monthly",
amount=Decimal("29.99"),
interval=BillingInterval.MONTHLY,
trial_days=14,
)
sub_input = SubscriptionInput(
subscription_id="sub-12345",
customer_id="cust-001",
plan=plan,
payment_method_id="pm-card-123",
)
handle = await client.start_workflow(
SubscriptionBillingWorkflow.run,
sub_input,
id=f"subscription-{sub_input.subscription_id}",
task_queue="billing",
)
print(f"Started subscription workflow: {handle.id}")
# Query status
await asyncio.sleep(1)
status = await handle.query(SubscriptionBillingWorkflow.get_status)
print(f"Subscription status: {status}")
# Upgrade plan
new_plan = Plan(
plan_id="enterprise-monthly",
name="Enterprise Monthly",
amount=Decimal("99.99"),
interval=BillingInterval.MONTHLY,
)
await handle.signal(SubscriptionBillingWorkflow.change_plan, new_plan)
print("Plan upgrade scheduled")
# Get full state
state = await handle.query(SubscriptionBillingWorkflow.get_state)
print(f"Full state: {state}")
# ============================================================================
# Worker Setup
# ============================================================================
async def run_worker():
"""Run the billing worker."""
client = await Client.connect("localhost:7233")
worker = Worker(
client,
task_queue="billing",
workflows=[SubscriptionBillingWorkflow],
activities=[
create_invoice,
charge_payment,
send_payment_failed_notification,
send_subscription_cancelled_notification,
get_usage_records,
calculate_proration,
update_subscription_in_database,
],
)
print("Starting billing worker...")
await worker.run()
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "client":
asyncio.run(example_usage())
else:
asyncio.run(run_worker())
Activity Best Practices
Heartbeating
Required for activities > 60 seconds.
from temporalio import activity
import asyncio
@activity.defn
async def process_large_file(file_path: str) -> ProcessResult:
"""Long-running activity with heartbeating."""
total_lines = count_lines(file_path)
processed = 0
async with aiofiles.open(file_path) as f:
async for line in f:
await process_line(line)
processed += 1
# Heartbeat every 100 lines with progress
if processed % 100 == 0:
activity.heartbeat(f"{processed}/{total_lines}")
return ProcessResult(processed=processed)Heartbeat details:
heartbeat_timeoutin workflow triggers retry if heartbeat stops- Pass checkpoint data for resume on retry
activity.info().heartbeat_detailsretrieves last heartbeat
Timeout Configuration
# In workflow
await workflow.execute_activity(
my_activity,
args,
# Max time for single attempt (most common)
start_to_close_timeout=timedelta(seconds=30),
# Max time from schedule to completion (includes queue wait)
schedule_to_close_timeout=timedelta(minutes=5),
# Max time from schedule to worker pickup
schedule_to_start_timeout=timedelta(seconds=60),
# Heartbeat must be received within this interval
heartbeat_timeout=timedelta(seconds=10),
)Timeout selection:
start_to_close: Default choice, per-attempt timeoutschedule_to_close: End-to-end SLA guaranteeheartbeat_timeout: For long activities, 1/3 of expected duration
Retry Policies
from temporalio.common import RetryPolicy
# Conservative retry (payments, critical ops)
conservative_retry = RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
maximum_interval=timedelta(seconds=30),
non_retryable_error_types=["PaymentDeclined", "InvalidInput"],
)
# Aggressive retry (idempotent reads)
aggressive_retry = RetryPolicy(
maximum_attempts=10,
initial_interval=timedelta(milliseconds=100),
backoff_coefficient=1.5,
maximum_interval=timedelta(seconds=10),
)Idempotency
Activities may retry - ensure idempotent operations.
@activity.defn
async def create_order(order: OrderInput) -> str:
"""Idempotent order creation using client-provided ID."""
# Use upsert, not insert
await db.execute(
"""
INSERT INTO orders (id, data, created_at)
VALUES ($1, $2, NOW())
ON CONFLICT (id) DO NOTHING
RETURNING id
""",
order.id, # Client-provided idempotency key
order.data,
)
return order.idNon-Retryable Errors
from temporalio.exceptions import ApplicationError
@activity.defn
async def validate_input(data: dict) -> bool:
if not data.get("required_field"):
raise ApplicationError(
"Missing required_field",
non_retryable=True, # Don't retry validation errors
type="ValidationError",
)
return TrueSignals, Queries, and Updates
Signals
Async external input to running workflows.
from temporalio import workflow
from dataclasses import dataclass
@dataclass
class ApprovalSignal:
approved: bool
approver: str
comment: str
@workflow.defn
class ApprovalWorkflow:
def __init__(self):
self._pending_approvals: list[ApprovalSignal] = []
self._approved = False
@workflow.run
async def run(self, request: ApprovalRequest) -> ApprovalResult:
# Wait for approval signal
await workflow.wait_condition(lambda: self._approved)
return ApprovalResult(
status="approved",
approvals=self._pending_approvals,
)
@workflow.signal
async def approve(self, signal: ApprovalSignal):
"""Receive approval from external system."""
self._pending_approvals.append(signal)
if len(self._pending_approvals) >= 2: # Require 2 approvals
self._approved = True
@workflow.signal
async def cancel(self, reason: str):
"""Cancel the approval request."""
raise workflow.ContinueAsNewError(
CancelledRequest(reason=reason)
)Signal from client:
handle = client.get_workflow_handle("approval-123")
await handle.signal(ApprovalWorkflow.approve, ApprovalSignal(
approved=True,
approver="manager@example.com",
comment="LGTM",
))Queries
Synchronous state inspection (read-only).
@workflow.defn
class OrderWorkflow:
def __init__(self):
self._status = "pending"
self._items: list[str] = []
@workflow.query
def get_status(self) -> str:
"""Query current order status."""
return self._status
@workflow.query
def get_items(self) -> list[str]:
"""Query order items."""
return self._items.copy() # Return copy to prevent mutationQuery from client:
handle = client.get_workflow_handle("order-456")
status = await handle.query(OrderWorkflow.get_status)
items = await handle.query(OrderWorkflow.get_items)Updates (Temporal 1.10+)
Synchronous state mutation with validation.
from temporalio import workflow
@workflow.defn
class ShippingWorkflow:
def __init__(self):
self._address: Address | None = None
self._shipped = False
@workflow.update
async def update_address(self, new_address: Address) -> bool:
"""Update shipping address with validation."""
if self._shipped:
return False # Cannot update after shipping
# Validate address via activity
valid = await workflow.execute_activity(
validate_address,
new_address,
start_to_close_timeout=timedelta(seconds=10),
)
if valid:
self._address = new_address
return True
return False
@workflow.update_validator
def validate_update_address(self, new_address: Address):
"""Reject invalid updates before processing."""
if not new_address.street or not new_address.city:
raise ValueError("Address must have street and city")Update from client:
handle = client.get_workflow_handle("shipping-789")
success = await handle.execute_update(
ShippingWorkflow.update_address,
Address(street="123 Main St", city="NYC"),
)Comparison
| Feature | Signal | Query | Update |
|---|---|---|---|
| Direction | Fire-and-forget | Read-only | Request-response |
| Blocks caller | No | Yes | Yes |
| Can mutate state | Yes | No | Yes |
| Can run activities | Yes | No | Yes |
| Validation | None | N/A | @update_validator |
Versioning Strategies
The Determinism Problem
Workflows must be deterministic for replay. Changing workflow code breaks running workflows.
workflow.patched() - Branching
For adding new code paths while supporting running workflows.
@workflow.defn
class PaymentWorkflow:
@workflow.run
async def run(self, payment: PaymentInput) -> PaymentResult:
# Version 1: Old workflows take this path
# Version 2+: New workflows take patched path
if workflow.patched("v2-fraud-check"):
# New: Add fraud check before payment
fraud_result = await workflow.execute_activity(
check_fraud,
payment,
start_to_close_timeout=timedelta(seconds=30),
)
if fraud_result.is_fraudulent:
return PaymentResult(status="blocked")
# Both versions execute this
return await workflow.execute_activity(
process_payment,
payment,
start_to_close_timeout=timedelta(minutes=5),
)When to use:
- Adding new steps to existing workflows
- Changing activity parameters
- Adding error handling
workflow.deprecate_patch() - Cleanup
Remove old code paths after all old workflows complete.
@workflow.defn
class PaymentWorkflow:
@workflow.run
async def run(self, payment: PaymentInput) -> PaymentResult:
# Phase 1: Use patched() to add new code
# Phase 2: After all v1 workflows complete, use deprecate_patch()
# Phase 3: Remove deprecate_patch() entirely
workflow.deprecate_patch("v2-fraud-check")
# Now only new code exists
fraud_result = await workflow.execute_activity(
check_fraud,
payment,
start_to_close_timeout=timedelta(seconds=30),
)
if fraud_result.is_fraudulent:
return PaymentResult(status="blocked")
return await workflow.execute_activity(
process_payment,
payment,
start_to_close_timeout=timedelta(minutes=5),
)Worker Versioning (Build IDs)
Assign workers to specific workflow versions.
from temporalio.worker import Worker
# Register worker with build ID
worker = Worker(
client,
task_queue="payments",
workflows=[PaymentWorkflow],
activities=[process_payment, check_fraud],
build_id="v2.1.0", # Worker version
use_worker_versioning=True,
)Server-side versioning rules:
# Add new version as default
temporal task-queue update-build-ids add-new-default \
--task-queue payments \
--build-id v2.1.0
# Make compatible with previous version
temporal task-queue update-build-ids add-new-compatible \
--task-queue payments \
--build-id v2.1.0 \
--existing-compatible-build-id v2.0.0Safe Deployment Pattern
1. Deploy new workers with version N+1 (do NOT remove old workers yet) 2. Test new workflows start on N+1 workers 3. Wait for all N workflows to complete 4. Remove version N workers
# Deployment script
async def safe_deploy():
# 1. Deploy new workers
await deploy_workers("v2.1.0")
# 2. Verify new workflows use new workers
handle = await client.start_workflow(
PaymentWorkflow.run,
test_payment,
id="deploy-test",
task_queue="payments",
)
assert (await handle.result()).version == "v2.1.0"
# 3. Wait for old workflows
while await count_running_workflows("v2.0.0") > 0:
await asyncio.sleep(60)
# 4. Remove old workers
await remove_workers("v2.0.0")Activity Versioning
Activities are easier - just deploy new code.
# Old activity signature
@activity.defn
async def send_email(to: str, subject: str, body: str): ...
# New activity signature - NEW NAME required
@activity.defn
async def send_email_v2(input: EmailInput): ...For activity signature changes, create new activity with new name.
Advanced Workflow Patterns
Child Workflows
Use child workflows for modularity and isolation.
from temporalio import workflow
from datetime import timedelta
@workflow.defn
class ParentWorkflow:
@workflow.run
async def run(self, items: list[str]) -> list[dict]:
# Start children in parallel
handles = []
for item in items:
handle = await workflow.start_child_workflow(
ProcessItemWorkflow.run,
item,
id=f"process-{item}",
# Inherit parent's task queue by default
)
handles.append(handle)
# Wait for all to complete
return await asyncio.gather(*[h.result() for h in handles])When to use child workflows:
- Separate failure domains
- Different retry policies per sub-process
- Modular, reusable workflow components
- Need independent workflow history
Continue-As-New
Prevent unbounded history for long-running workflows.
@workflow.defn
class LongRunningWorkflow:
@workflow.run
async def run(self, state: WorkflowState) -> WorkflowState:
iteration = 0
max_iterations = 1000 # Prevent unbounded history
while not state.is_complete:
await workflow.execute_activity(
process_batch,
state.current_batch,
start_to_close_timeout=timedelta(minutes=5),
)
state.advance()
iteration += 1
# Continue-as-new before history grows too large
if iteration >= max_iterations:
workflow.continue_as_new(state)
return stateKey points:
- Workflow history is capped (~50K events)
continue_as_newstarts fresh history with current state- Use for polling loops, scheduled tasks, batch processing
Dynamic Workflow Selection
Route to workflows at runtime.
@workflow.defn
class DispatcherWorkflow:
@workflow.run
async def run(self, request: Request) -> Result:
workflow_type = self._select_workflow(request.type)
return await workflow.execute_child_workflow(
workflow_type,
request.payload,
id=f"dynamic-{request.id}",
)
def _select_workflow(self, type: str) -> str:
mapping = {
"order": "OrderWorkflow",
"refund": "RefundWorkflow",
"subscription": "SubscriptionWorkflow",
}
return mapping.get(type, "DefaultWorkflow")Related
- saga-compensation - Full saga implementation
- signals-queries-updates - External interaction
"""
Saga Workflow Template with Automatic Compensation
Implements the Saga pattern for distributed transactions with:
- Ordered step execution
- Automatic compensation on failure
- Compensation retry logic
- Structured logging
"""
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Any, Callable, TypeVar
from temporalio import activity, workflow
from temporalio.common import RetryPolicy
from temporalio.exceptions import ApplicationError
# ============================================================================
# Data Models
# ============================================================================
@dataclass
class SagaStep:
"""Represents a single saga step with its compensation."""
name: str
activity: str
compensation_activity: str
args: Any = None
compensation_args: Any = None
timeout: timedelta = field(default_factory=lambda: timedelta(minutes=5))
@dataclass
class SagaResult:
"""Result of saga execution."""
success: bool
completed_steps: list[str]
failed_step: str | None = None
error: str | None = None
compensation_errors: list[str] = field(default_factory=list)
# ============================================================================
# Activities
# ============================================================================
@activity.defn
async def reserve_inventory(order_id: str, items: list[dict]) -> str:
"""Step 1: Reserve inventory."""
activity.logger.info(f"Reserving inventory for order {order_id}")
# Implementation: Call inventory service
return f"reservation-{order_id}"
@activity.defn
async def release_inventory(reservation_id: str) -> None:
"""Compensation: Release reserved inventory."""
activity.logger.info(f"Releasing reservation {reservation_id}")
# Implementation: Call inventory service to release
@activity.defn
async def charge_payment(order_id: str, amount: float) -> str:
"""Step 2: Charge payment."""
activity.logger.info(f"Charging {amount} for order {order_id}")
# Implementation: Call payment processor
return f"payment-{order_id}"
@activity.defn
async def refund_payment(payment_id: str) -> None:
"""Compensation: Refund payment."""
activity.logger.info(f"Refunding payment {payment_id}")
# Implementation: Call payment processor to refund
@activity.defn
async def create_shipment(order_id: str, address: dict) -> str:
"""Step 3: Create shipment."""
activity.logger.info(f"Creating shipment for order {order_id}")
# Implementation: Call shipping service
return f"shipment-{order_id}"
@activity.defn
async def cancel_shipment(shipment_id: str) -> None:
"""Compensation: Cancel shipment."""
activity.logger.info(f"Cancelling shipment {shipment_id}")
# Implementation: Call shipping service to cancel
# ============================================================================
# Saga Workflow
# ============================================================================
@workflow.defn
class SagaWorkflow:
"""
Generic Saga workflow with automatic compensation.
Usage:
result = await client.execute_workflow(
SagaWorkflow.run,
OrderSagaInput(order_id="123", items=[...], amount=99.99),
id="order-saga-123",
task_queue="orders",
)
"""
def __init__(self):
self._completed_steps: list[tuple[str, Any]] = []
self._status = "pending"
@workflow.run
async def run(self, input: "OrderSagaInput") -> SagaResult:
"""Execute saga steps with compensation on failure."""
self._status = "running"
# Define saga steps in order
steps = [
SagaStep(
name="reserve_inventory",
activity="reserve_inventory",
compensation_activity="release_inventory",
args=(input.order_id, input.items),
timeout=timedelta(minutes=2),
),
SagaStep(
name="charge_payment",
activity="charge_payment",
compensation_activity="refund_payment",
args=(input.order_id, input.amount),
timeout=timedelta(minutes=5),
),
SagaStep(
name="create_shipment",
activity="create_shipment",
compensation_activity="cancel_shipment",
args=(input.order_id, input.shipping_address),
timeout=timedelta(minutes=3),
),
]
try:
# Execute steps in order
for step in steps:
result = await self._execute_step(step)
self._completed_steps.append((step.compensation_activity, result))
self._status = "completed"
return SagaResult(
success=True,
completed_steps=[s.name for s in steps],
)
except Exception as e:
self._status = "compensating"
workflow.logger.warning(f"Saga failed at step, running compensations: {e}")
# Run compensations in reverse order
compensation_errors = await self._run_compensations()
self._status = "failed"
return SagaResult(
success=False,
completed_steps=[s[0] for s in self._completed_steps],
failed_step=steps[len(self._completed_steps)].name if self._completed_steps else steps[0].name,
error=str(e),
compensation_errors=compensation_errors,
)
async def _execute_step(self, step: SagaStep) -> Any:
"""Execute a single saga step."""
# Map activity names to functions
activities = {
"reserve_inventory": reserve_inventory,
"charge_payment": charge_payment,
"create_shipment": create_shipment,
}
activity_fn = activities[step.activity]
return await workflow.execute_activity(
activity_fn,
args=step.args,
start_to_close_timeout=step.timeout,
retry_policy=RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
),
)
async def _run_compensations(self) -> list[str]:
"""Run all compensations in reverse order."""
errors = []
# Map compensation activity names to functions
compensations = {
"release_inventory": release_inventory,
"refund_payment": refund_payment,
"cancel_shipment": cancel_shipment,
}
for compensation_activity, result in reversed(self._completed_steps):
try:
activity_fn = compensations[compensation_activity]
await workflow.execute_activity(
activity_fn,
result,
start_to_close_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(
maximum_attempts=5, # More retries for compensation
initial_interval=timedelta(seconds=2),
backoff_coefficient=2.0,
),
)
except Exception as e:
error_msg = f"Compensation {compensation_activity} failed: {e}"
workflow.logger.error(error_msg)
errors.append(error_msg)
return errors
@workflow.query
def get_status(self) -> str:
return self._status
@workflow.query
def get_completed_steps(self) -> list[str]:
return [s[0] for s in self._completed_steps]
# ============================================================================
# Input Model
# ============================================================================
@dataclass
class OrderSagaInput:
order_id: str
items: list[dict]
amount: float
shipping_address: dict
"""
Scheduled/Cron Workflow Template
Implements recurring workflows with:
- Durable timers (survives worker restarts)
- Continue-as-new for unbounded execution
- Configurable schedule
- Proper error handling
"""
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Any
from temporalio import activity, workflow
from temporalio.client import Client, Schedule, ScheduleActionStartWorkflow, ScheduleSpec, ScheduleIntervalSpec
from temporalio.common import RetryPolicy
# ============================================================================
# Schedule Types
# ============================================================================
class ScheduleType(Enum):
INTERVAL = "interval" # Every N minutes/hours
CRON = "cron" # Cron expression
DAILY = "daily" # Once per day at specific time
@dataclass
class ScheduleConfig:
schedule_type: ScheduleType
interval_seconds: int = 3600 # For INTERVAL type
cron_expression: str = "0 0 * * *" # For CRON type
timezone: str = "UTC"
@dataclass
class ScheduledTaskInput:
task_name: str
config: dict[str, Any]
schedule: ScheduleConfig
@dataclass
class ScheduledTaskResult:
task_name: str
execution_time: str
success: bool
result: Any = None
error: str | None = None
# ============================================================================
# Activities
# ============================================================================
@activity.defn
async def execute_scheduled_task(task_name: str, config: dict) -> dict:
"""Execute the actual scheduled task."""
activity.logger.info(f"Executing scheduled task: {task_name}")
# Heartbeat for long tasks
activity.heartbeat(f"Starting {task_name}")
# Replace with your task implementation
result = {
"task": task_name,
"executed_at": datetime.now(timezone.utc).isoformat(),
"config": config,
}
activity.heartbeat(f"Completed {task_name}")
return result
@activity.defn
async def send_task_notification(task_name: str, success: bool, error: str | None) -> None:
"""Send notification about task completion."""
activity.logger.info(f"Task {task_name} completed: success={success}")
# Implementation: Send Slack/email notification
# ============================================================================
# Scheduled Workflow (Internal Loop)
# ============================================================================
@workflow.defn
class ScheduledWorkflow:
"""
Self-scheduling workflow using durable timers.
Uses continue-as-new to prevent unbounded history.
"""
def __init__(self):
self._execution_count = 0
self._last_result: ScheduledTaskResult | None = None
@workflow.run
async def run(self, input: ScheduledTaskInput) -> None:
"""Run scheduled task in a loop with continue-as-new."""
max_iterations = 100 # Continue-as-new after 100 iterations
while self._execution_count < max_iterations:
# Execute the task
result = await self._execute_task(input)
self._last_result = result
self._execution_count += 1
# Notify on failure
if not result.success:
await workflow.execute_activity(
send_task_notification,
args=[input.task_name, False, result.error],
start_to_close_timeout=timedelta(seconds=30),
)
# Sleep until next execution (durable timer)
await asyncio.sleep(input.schedule.interval_seconds)
# Continue-as-new to reset history
workflow.continue_as_new(input)
async def _execute_task(self, input: ScheduledTaskInput) -> ScheduledTaskResult:
"""Execute single task iteration."""
try:
result = await workflow.execute_activity(
execute_scheduled_task,
args=[input.task_name, input.config],
start_to_close_timeout=timedelta(minutes=10),
heartbeat_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=5),
backoff_coefficient=2.0,
),
)
return ScheduledTaskResult(
task_name=input.task_name,
execution_time=workflow.now().isoformat(),
success=True,
result=result,
)
except Exception as e:
return ScheduledTaskResult(
task_name=input.task_name,
execution_time=workflow.now().isoformat(),
success=False,
error=str(e),
)
@workflow.query
def get_execution_count(self) -> int:
return self._execution_count
@workflow.query
def get_last_result(self) -> ScheduledTaskResult | None:
return self._last_result
# ============================================================================
# Native Schedule (Temporal Schedules API - Recommended)
# ============================================================================
async def create_schedule(
client: Client,
schedule_id: str,
task_name: str,
config: dict,
cron: str = "0 * * * *", # Every hour
) -> None:
"""
Create a native Temporal schedule (recommended for production).
Advantages over workflow-based scheduling:
- Server-managed, no worker needed between executions
- Built-in pause/resume/backfill
- Better visibility in UI
"""
await client.create_schedule(
schedule_id,
Schedule(
action=ScheduleActionStartWorkflow(
"ScheduledTaskWorkflow",
args=[task_name, config],
id=f"scheduled-{task_name}-{{{{.ScheduleTime.Format `20060102-150405`}}}}",
task_queue="scheduled-tasks",
),
spec=ScheduleSpec(
cron_expressions=[cron],
),
),
)
async def create_interval_schedule(
client: Client,
schedule_id: str,
task_name: str,
config: dict,
interval: timedelta = timedelta(hours=1),
) -> None:
"""Create interval-based schedule."""
await client.create_schedule(
schedule_id,
Schedule(
action=ScheduleActionStartWorkflow(
"ScheduledTaskWorkflow",
args=[task_name, config],
id=f"scheduled-{task_name}-{{{{.ScheduleTime.Format `20060102-150405`}}}}",
task_queue="scheduled-tasks",
),
spec=ScheduleSpec(
intervals=[ScheduleIntervalSpec(every=interval)],
),
),
)
# ============================================================================
# One-Shot Scheduled Workflow (for native schedules)
# ============================================================================
@workflow.defn
class ScheduledTaskWorkflow:
"""Single execution workflow for use with native schedules."""
@workflow.run
async def run(self, task_name: str, config: dict) -> ScheduledTaskResult:
try:
result = await workflow.execute_activity(
execute_scheduled_task,
args=[task_name, config],
start_to_close_timeout=timedelta(minutes=10),
heartbeat_timeout=timedelta(minutes=2),
retry_policy=RetryPolicy(maximum_attempts=3),
)
return ScheduledTaskResult(
task_name=task_name,
execution_time=workflow.now().isoformat(),
success=True,
result=result,
)
except Exception as e:
# Notify on failure
await workflow.execute_activity(
send_task_notification,
args=[task_name, False, str(e)],
start_to_close_timeout=timedelta(seconds=30),
)
return ScheduledTaskResult(
task_name=task_name,
execution_time=workflow.now().isoformat(),
success=False,
error=str(e),
)
"""
Production Temporal Worker Template
Usage:
python worker.py --task-queue orders --namespace production
Features:
- Graceful shutdown handling
- Structured logging
- Health checks
- Metrics export (optional Prometheus)
"""
import asyncio
import logging
import signal
import sys
from argparse import ArgumentParser
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import timedelta
from temporalio import activity, workflow
from temporalio.client import Client, TLSConfig
from temporalio.worker import Worker
from temporalio.common import RetryPolicy
# ============================================================================
# Configuration
# ============================================================================
@dataclass
class WorkerConfig:
temporal_host: str = "localhost:7233"
namespace: str = "default"
task_queue: str = "default"
max_concurrent_activities: int = 100
max_concurrent_workflow_tasks: int = 100
# TLS for production
tls_cert_path: str | None = None
tls_key_path: str | None = None
# ============================================================================
# Activities
# ============================================================================
@activity.defn
async def example_activity(input: str) -> str:
"""Replace with your activity implementation."""
activity.logger.info(f"Processing: {input}")
# Heartbeat for long operations
for i in range(10):
activity.heartbeat(f"Step {i}/10")
await asyncio.sleep(0.1)
return f"Processed: {input}"
# ============================================================================
# Workflows
# ============================================================================
@workflow.defn
class ExampleWorkflow:
"""Replace with your workflow implementation."""
@workflow.run
async def run(self, input: str) -> str:
result = await workflow.execute_activity(
example_activity,
input,
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(
maximum_attempts=3,
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
),
)
return result
# ============================================================================
# Worker Setup
# ============================================================================
@asynccontextmanager
async def create_worker(config: WorkerConfig):
"""Create worker with proper lifecycle management."""
# TLS config for production
tls_config = None
if config.tls_cert_path and config.tls_key_path:
with open(config.tls_cert_path, "rb") as f:
cert = f.read()
with open(config.tls_key_path, "rb") as f:
key = f.read()
tls_config = TLSConfig(client_cert=cert, client_private_key=key)
# Connect to Temporal
client = await Client.connect(
config.temporal_host,
namespace=config.namespace,
tls=tls_config,
)
# Create worker
worker = Worker(
client,
task_queue=config.task_queue,
workflows=[ExampleWorkflow],
activities=[example_activity],
max_concurrent_activities=config.max_concurrent_activities,
max_concurrent_workflow_tasks=config.max_concurrent_workflow_tasks,
)
try:
yield worker
finally:
await client.close()
async def run_worker(config: WorkerConfig):
"""Run worker with graceful shutdown."""
shutdown_event = asyncio.Event()
def signal_handler():
logging.info("Shutdown signal received")
shutdown_event.set()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, signal_handler)
async with create_worker(config) as worker:
logging.info(f"Worker started on task queue: {config.task_queue}")
# Run until shutdown signal
worker_task = asyncio.create_task(worker.run())
shutdown_task = asyncio.create_task(shutdown_event.wait())
done, pending = await asyncio.wait(
[worker_task, shutdown_task],
return_when=asyncio.FIRST_COMPLETED,
)
# Cancel pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logging.info("Worker shutdown complete")
# ============================================================================
# Entry Point
# ============================================================================
def main():
parser = ArgumentParser(description="Temporal Worker")
parser.add_argument("--host", default="localhost:7233")
parser.add_argument("--namespace", default="default")
parser.add_argument("--task-queue", default="default")
parser.add_argument("--max-activities", type=int, default=100)
parser.add_argument("--tls-cert", default=None)
parser.add_argument("--tls-key", default=None)
args = parser.parse_args()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
config = WorkerConfig(
temporal_host=args.host,
namespace=args.namespace,
task_queue=args.task_queue,
max_concurrent_activities=args.max_activities,
tls_cert_path=args.tls_cert,
tls_key_path=args.tls_key,
)
asyncio.run(run_worker(config))
if __name__ == "__main__":
main()