Back to Blog

Temporal Workflow Engine Deep Dive (Part 2): Schedule, Visibility, Interceptors, and Multi-Cluster

1. [Schedule: Native Cron Replacement](#1-schedule-native-cron-replacement)

CoomiaPublished on November 17, 202511 min read
Share this articleTwitter / X

Series: S8 Technology Deep Dives · Article 9 | Level: Advanced | Reading Time: 20 min

Temporal Workflow Engine Deep Dive (Part 2): Schedule, Visibility, Interceptors, and Multi-Cluster

#TL;DR

  • This is Part 2 of the Temporal deep dive, focusing on advanced topics: Schedule (native cron replacement), Visibility (Elasticsearch-based workflow search), Interceptors (cross-cutting concern injection), and multi-cluster replication
  • Deeply analyzes Temporal's Namespace isolation model, custom Search Attributes, Worker Versioning strategies, and coomia-dip observability integration
  • Includes a production troubleshooting handbook and performance tuning guide for coomia-dip

#Table of Contents

  1. Schedule: Native Cron Replacement
  2. Visibility: Workflow Search and Monitoring
  3. Custom Search Attributes
  4. Interceptors
  5. Worker Versioning
  6. Namespace Isolation Model
  7. Multi-Cluster Replication
  8. Observability Integration
  9. Troubleshooting Handbook
  10. Performance Benchmarks and Stress Testing
  11. Key Takeaways

#1. Schedule: Native Cron Replacement

#1.1 Why Not Cron?

Pain points with traditional Cron:

IssueCronTemporal Schedule
Missed execution (node down)LostAutomatic Backfill
Overlapping executionsManual locking neededNative Overlap Policy
Execution historyLogs onlyFull Event History
Pause/ResumeEdit crontabSingle API call
Parameterized schedulingDifficultNative support

#1.2 coomia-dip Schedule Configuration

Python
from temporalio.client import (
    Client,
    Schedule,
    ScheduleActionStartWorkflow,
    ScheduleIntervalSpec,
    ScheduleSpec,
    ScheduleOverlapPolicy,
    ScheduleState,
)

async def create_report_schedule(client: Client, world_id: str) -> str:
    """Create a daily report generation schedule"""

    schedule_handle = await client.create_schedule(
        id=f"daily-report-{world_id}",
        schedule=Schedule(
            action=ScheduleActionStartWorkflow(
                workflow="ReportGenerationWorkflow",
                arg={"world_id": world_id, "report_type": "daily"},
                id=f"report-{world_id}-{{{{.ScheduledTime.Format `20060102`}}}}",
                task_queue="coomia-dip-report-queue",
            ),
            spec=ScheduleSpec(
                intervals=[
                    ScheduleIntervalSpec(
                        every=timedelta(hours=24),
                        offset=timedelta(hours=6),  # Execute at 06:00 UTC daily
                    ),
                ],
            ),
            policy=ScheduleOverlapPolicy.SKIP,  # Skip if previous not finished
            state=ScheduleState(
                note="Daily report for world " + world_id,
                limited_actions=False,
            ),
        ),
    )

    return schedule_handle.id

#1.3 Overlap Policy Strategies

Python
# 4 overlap policies
OVERLAP_POLICIES = {
    "SKIP":           "Previous not finished, skip this one",
    "BUFFER_ONE":     "Buffer one, execute immediately after previous completes",
    "BUFFER_ALL":     "Buffer all, queue execution",
    "CANCEL_OTHER":   "Cancel previous, start new",
    "TERMINATE_OTHER": "Terminate previous, start new",
    "ALLOW_ALL":      "Allow parallel execution",
}

# coomia-dip scenario recommendations
SCHEDULE_CONFIGS = {
    "daily_report":     ScheduleOverlapPolicy.SKIP,          # Reports can be skipped
    "data_sync":        ScheduleOverlapPolicy.BUFFER_ONE,    # Sync cannot be skipped
    "cleanup":          ScheduleOverlapPolicy.SKIP,          # Cleanup can be skipped
    "metric_aggregate": ScheduleOverlapPolicy.CANCEL_OTHER,  # Use latest data
}

#1.4 Backfill: Catching Up Missed Schedules

Python
async def backfill_missed_schedules(
    client: Client,
    schedule_id: str,
    start: datetime,
    end: datetime,
) -> None:
    """Backfill schedules missed during maintenance window"""
    handle = client.get_schedule_handle(schedule_id)

    await handle.backfill(
        ScheduleBackfill(
            start_at=start,
            end_at=end,
            overlap=ScheduleOverlapPolicy.BUFFER_ALL,
        ),
    )

#2. Visibility: Workflow Search and Monitoring

#2.1 Visibility Store Architecture

Code
Temporal Server
    │
    ├── Standard Visibility (PostgreSQL)
    │   └── Basic filtering: WorkflowType, Status, StartTime
    │
    └── Advanced Visibility (Elasticsearch)
        └── Full features: custom Search Attributes, full-text search, complex queries

coomia-dip uses Elasticsearch as the Visibility Store:

YAML
# Temporal Server configuration
persistence:
  advancedVisibilityStore: es-visibility
  datastores:
    es-visibility:
      elasticsearch:
        version: v7
        url:
          scheme: https
          host: elasticsearch:9200
        indices:
          visibility: temporal_visibility_v1

#2.2 List Filter Query Syntax

Python
# Query all failed Action Workflows
workflows = await client.list_workflows(
    query='WorkflowType = "ActionExecutionWorkflow" AND ExecutionStatus = "Failed"'
)

# Query running workflows for a specific World
workflows = await client.list_workflows(
    query=(
        'CustomStringField = "world-123" '
        'AND ExecutionStatus = "Running" '
        'AND StartTime > "2026-03-01T00:00:00Z"'
    )
)

# Query timed-out approval workflows
workflows = await client.list_workflows(
    query=(
        'WorkflowType = "ApprovalWorkflow" '
        'AND ExecutionStatus = "Running" '
        'AND StartTime < "2026-03-20T00:00:00Z"'
    )
)

#3. Custom Search Attributes

#3.1 Registering Custom Search Attributes

Python
# Register custom search attributes for coomia-dip
async def register_search_attributes(client: Client) -> None:
    await client.operator_service.add_search_attributes(
        namespace="coomia-dip-default",
        search_attributes={
            "WorldId": SearchAttributeType.KEYWORD,
            "ObjectType": SearchAttributeType.KEYWORD,
            "ActionType": SearchAttributeType.KEYWORD,
            "Priority": SearchAttributeType.INT,
            "Initiator": SearchAttributeType.KEYWORD,
            "ErrorMessage": SearchAttributeType.TEXT,
            "DataSize": SearchAttributeType.DOUBLE,
            "Tags": SearchAttributeType.KEYWORD_LIST,
        },
    )

#3.2 Setting Search Attributes in Workflows

Python
@workflow.defn
class ActionExecutionWorkflow:
    @workflow.run
    async def run(self, request: ActionRequest) -> ActionResult:
        # Dynamically update Search Attributes during execution
        workflow.upsert_search_attributes(
            [
                SearchAttributeUpdate(
                    SearchAttributeKey.for_keyword("WorldId"),
                    request.world_id,
                ),
                SearchAttributeUpdate(
                    SearchAttributeKey.for_keyword("ActionType"),
                    request.action_type,
                ),
                SearchAttributeUpdate(
                    SearchAttributeKey.for_int("Priority"),
                    request.priority,
                ),
            ]
        )

        try:
            result = await self._execute(request)
            return result
        except Exception as e:
            # Update error info to Search Attributes on failure
            workflow.upsert_search_attributes(
                [
                    SearchAttributeUpdate(
                        SearchAttributeKey.for_text("ErrorMessage"),
                        str(e),
                    ),
                ]
            )
            raise

#4. Interceptors

#4.1 Interceptor Architecture

Code
Client Call → Client Interceptor → Temporal Server
                                        ↓
Worker Poll ← Activity Interceptor ← Workflow Interceptor

#4.2 Tracing and Logging Interceptor

Python
from temporalio.worker import (
    Interceptor,
    ExecuteWorkflowInput,
    ExecuteActivityInput,
)

class TracingInterceptor(Interceptor):
    """coomia-dip distributed tracing interceptor"""

    def intercept_activity(self, next_interceptor):
        return TracingActivityInterceptor(next_interceptor)

    def workflow_interceptor_class(self, input):
        return TracingWorkflowInterceptor


class TracingActivityInterceptor:
    def __init__(self, next_interceptor):
        self.next = next_interceptor

    async def execute_activity(self, input: ExecuteActivityInput):
        activity_name = input.fn.__name__
        start_time = time.monotonic()

        # Inject trace context
        span = tracer.start_span(
            f"temporal.activity.{activity_name}",
            attributes={
                "temporal.workflow_id": activity.info().workflow_id,
                "temporal.activity_id": activity.info().activity_id,
                "temporal.task_queue": activity.info().task_queue,
                "temporal.attempt": activity.info().attempt,
            },
        )

        try:
            result = await self.next.execute_activity(input)
            span.set_status(StatusCode.OK)
            return result
        except Exception as e:
            span.set_status(StatusCode.ERROR, str(e))
            span.record_exception(e)
            raise
        finally:
            duration = time.monotonic() - start_time
            metrics.histogram(
                "temporal_activity_duration_seconds",
                duration,
                tags={"activity": activity_name},
            )
            span.end()

#4.3 Audit Interceptor

Python
class AuditInterceptor(Interceptor):
    """Records audit logs for all workflow starts and completions"""

    def intercept_activity(self, next_interceptor):
        return AuditActivityInterceptor(next_interceptor)

    def workflow_interceptor_class(self, input):
        return AuditWorkflowInterceptor


class AuditWorkflowInterceptor:
    async def execute_workflow(self, input: ExecuteWorkflowInput):
        workflow_type = type(input.workflow).__name__
        workflow_id = workflow.info().workflow_id

        # Log workflow start
        await self._log_audit_event(
            event_type="WORKFLOW_STARTED",
            workflow_type=workflow_type,
            workflow_id=workflow_id,
            args=str(input.args)[:500],  # Truncate to avoid oversized logs
        )

        try:
            result = await input.execute()
            await self._log_audit_event(
                event_type="WORKFLOW_COMPLETED",
                workflow_type=workflow_type,
                workflow_id=workflow_id,
            )
            return result
        except Exception as e:
            await self._log_audit_event(
                event_type="WORKFLOW_FAILED",
                workflow_type=workflow_type,
                workflow_id=workflow_id,
                error=str(e),
            )
            raise

#5. Worker Versioning

#5.1 Build ID Versioning

Python
# Register new Build ID
async def register_worker_version(
    client: Client,
    task_queue: str,
    build_id: str,
    existing_compatible_id: str | None = None,
) -> None:
    if existing_compatible_id:
        # New version compatible with old version (same Task Queue)
        await client.update_worker_build_id_compatibility(
            task_queue,
            BuildIdOpAddNewCompatible(
                new_build_id=build_id,
                existing_compatible_build_id=existing_compatible_id,
            ),
        )
    else:
        # New version incompatible with old (new Task Queue Set)
        await client.update_worker_build_id_compatibility(
            task_queue,
            BuildIdOpAddNewDefault(build_id),
        )

# Worker declares its Build ID
worker = Worker(
    client,
    task_queue="coomia-dip-action-queue",
    workflows=[ActionApprovalWorkflow],
    activities=[validate_action],
    build_id="v2.3.0-abc123",
    use_worker_versioning=True,
)

#5.2 Versioned Deployment Strategy

Code
v1.0 Worker ────────────────────────────────────┐
  (handles running v1.0 workflows)               │
                                                 │
v2.0 Worker ─────────────────────────────────────┤
  (handles new workflows + v2.0-compatible old)   │
                                                 │
Time ──────────────────────────────────────────────→
     Deploy v2.0   v1.0 workflows   Shut down
                   all complete     v1.0 Workers

#6. Namespace Isolation Model

#6.1 coomia-dip Namespace Strategy

Code
coomia-dip Temporal Namespaces:
│
├── coomia-dip-default          # Default Namespace (dev/test)
├── coomia-dip-world-{id}       # One Namespace per World
│   ├── Task Queue: action-approval
│   ├── Task Queue: pipeline-etl
│   └── Task Queue: agent-reasoning
├── coomia-dip-system           # System internal workflows
│   ├── Task Queue: maintenance
│   └── Task Queue: monitoring
└── coomia-dip-staging          # Pre-production environment

#6.2 Namespace-Level Resource Limits

Python
# Create Namespace with resource limits
async def create_world_namespace(
    client: Client,
    world_id: str,
    tier: str = "standard",
) -> None:
    limits = {
        "standard": {
            "max_workflow_execution_count": 10_000,
            "max_concurrent_workflow_tasks": 200,
            "workflow_execution_rate_limit": 100,  # per second
        },
        "premium": {
            "max_workflow_execution_count": 100_000,
            "max_concurrent_workflow_tasks": 1000,
            "workflow_execution_rate_limit": 500,
        },
    }

    tier_limits = limits[tier]

    await client.operator_service.create_namespace(
        name=f"coomia-dip-world-{world_id}",
        retention_period=timedelta(days=30),
        # Resource limits via Temporal Server configuration
    )

#7. Multi-Cluster Replication

#7.1 Multi-Cluster Replication Architecture

Code
Region A (Primary)                Region B (Standby)
┌──────────────────┐              ┌──────────────────┐
│ Temporal Server  │              │ Temporal Server  │
│  ┌────────────┐  │  Replication │  ┌────────────┐  │
│  │  History   │──┼──────────────┼──│  History   │  │
│  │  Service   │  │              │  │  Service   │  │
│  └────────────┘  │              │  └────────────┘  │
│  ┌────────────┐  │              │  ┌────────────┐  │
│  │ PostgreSQL │──┼──────────────┼──│ PostgreSQL │  │
│  └────────────┘  │              │  └────────────┘  │
└──────────────────┘              └──────────────────┘
       ↑                                   ↑
   Workers A                           Workers B
   (Active)                            (Standby)

#7.2 Failover Process

Python
async def failover_to_standby(client: Client, namespace: str) -> None:
    """Failover Namespace to standby cluster"""

    # 1. Update Namespace active cluster
    await client.operator_service.update_namespace(
        namespace=namespace,
        update_info=NamespaceUpdateInfo(
            active_cluster_name="region-b",
        ),
    )

    # 2. Wait for replication to complete
    await asyncio.sleep(5)

    # 3. Verify failover success
    info = await client.operator_service.describe_namespace(namespace)
    assert info.active_cluster_name == "region-b"

#8. Observability Integration

#8.1 Metrics Integration

Python
from temporalio.runtime import PrometheusConfig, Runtime, TelemetryConfig

# Configure Prometheus Metrics
runtime = Runtime(
    telemetry=TelemetryConfig(
        metrics=PrometheusConfig(
            bind_address="0.0.0.0:9464",
        ),
    ),
)

client = await Client.connect(
    "temporal-server:7233",
    runtime=runtime,
)

#8.2 Core Grafana Dashboard Panels

YAML
# coomia-dip Temporal Grafana Dashboard core panels
panels:
  - title: "Workflow Start Rate"
    query: rate(temporal_workflow_started_total[5m])

  - title: "Workflow Failure Rate"
    query: |
      rate(temporal_workflow_failed_total[5m]) /
      rate(temporal_workflow_completed_total[5m])

  - title: "Activity Latency Distribution"
    query: histogram_quantile(0.99, temporal_activity_execution_latency_bucket)

  - title: "Task Queue Backlog"
    query: temporal_task_queue_backlog

  - title: "Schedule Missed Count"
    query: increase(temporal_schedule_missed_catchup_window_total[1h])

  - title: "Worker Slot Utilization"
    query: |
      temporal_worker_task_slots_used /
      temporal_worker_task_slots_available

#8.3 OpenTelemetry Integration

Python
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from temporalio.contrib.opentelemetry import TracingInterceptor

# Configure OpenTelemetry
tracer_provider = TracerProvider(
    resource=Resource.create({"service.name": "coomia-dip-worker"}),
)
tracer_provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="otel-collector:4317"))
)
trace.set_tracer_provider(tracer_provider)

# Use Temporal's OpenTelemetry Interceptor
client = await Client.connect(
    "temporal-server:7233",
    interceptors=[TracingInterceptor()],
)

worker = Worker(
    client,
    task_queue="coomia-dip-action-queue",
    workflows=[ActionApprovalWorkflow],
    activities=[validate_action],
    interceptors=[TracingInterceptor()],
)

#9. Troubleshooting Handbook

#9.1 Common Issues and Solutions

ProblemSymptomInvestigation StepsSolution
Workflow Task stuckWorkflow in Running state for too longCheck Worker connection, Task Queue nameRestart Worker or fix Task Queue
Activity timeoutFrequent retriesCheck Heartbeat, external dependenciesIncrease timeout or optimize Activity
Non-Determinism ErrorWorkflow Task failuresCompare Event HistoryUse Patching API to fix
Schedule not executingScheduled tasks not triggeringCheck Schedule state (Paused?)Resume Schedule
State size exceededFailure after ContinueAsNewCheck passed State sizeReduce passed data, use external storage

#9.2 Debugging Commands

Bash
# View Workflow execution details
temporal workflow describe --workflow-id action-approval-123

# View Event History
temporal workflow show --workflow-id action-approval-123

# List running workflows
temporal workflow list --query 'ExecutionStatus="Running"'

# Send Signal
temporal workflow signal --workflow-id action-approval-123 \
    --name approve --input '"approved by admin"'

# Cancel workflow
temporal workflow cancel --workflow-id action-approval-123

# Terminate workflow (emergency)
temporal workflow terminate --workflow-id action-approval-123 \
    --reason "Manual termination for debugging"

# View Task Queue status
temporal task-queue describe --task-queue coomia-dip-action-queue

# View Schedule status
temporal schedule describe --schedule-id daily-report-world-123

#9.3 Non-Determinism Debugging

Python
# Enable Replay testing (validate Determinism in unit tests)
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Replayer

async def test_workflow_replay():
    """Verify Workflow code changes don't break Determinism"""

    # Export Event History from production
    history_json = await export_workflow_history(
        workflow_id="action-approval-prod-123"
    )

    # Replay test
    replayer = Replayer(workflows=[ActionApprovalWorkflow])

    try:
        await replayer.replay_workflow(
            WorkflowHistory.from_json("action-approval-prod-123", history_json)
        )
        print("Replay succeeded: Workflow is deterministic")
    except Exception as e:
        print(f"Replay failed: Non-determinism detected: {e}")

#10. Performance Benchmarks and Stress Testing

#10.1 Single Cluster Throughput

Test ScenarioWorkflows/secActivities/secP99 Latency
Simple workflow (1 Activity)2,0002,00050 ms
Medium workflow (5 Activities)8004,000200 ms
Complex workflow (10 Activities + Timer)3003,000500 ms
Saga workflow (5 steps + compensation)5005,000300 ms

#10.2 Resource Bottleneck Analysis

Code
Workflow throughput limiting factors:

1. History Service CPU       → Add more instances
2. PostgreSQL IOPS           → Upgrade storage / read replicas
3. Elasticsearch write latency → Add shards / upgrade nodes
4. Worker concurrency        → Scale Workers horizontally
5. Network bandwidth         → Upgrade network

#10.3 coomia-dip Capacity Planning Formula

Code
Required History instances = ceil(target_workflow_QPS × avg_events / single_instance_capacity)

Example:
  Target: 500 QPS, average 20 events/workflow
  Single instance capacity: 3000 events/sec
  Required instances = ceil(500 × 20 / 3000) = ceil(3.33) = 4 instances

Required Worker instances = ceil(target_Activity_QPS / single_Worker_Activity_concurrency)

Example:
  Target: 2000 Activities/sec, single Worker 50 concurrent
  Required Workers = ceil(2000 / 50) = 40 Workers
  With redundancy × 1.5 = 60 Workers

#11. Key Takeaways

TopicKey Conclusion
ScheduleReplaces Cron, supports Backfill and Overlap Policy
VisibilityElasticsearch-driven, custom Search Attributes
InterceptorsFor tracing, auditing, rate limiting cross-cutting concerns
Worker VersioningBuild ID enables safe rolling upgrades
NamespaceOne Namespace per World for isolation
Multi-clusterCross-Region replication supports disaster recovery failover
ObservabilityPrometheus + Grafana + OpenTelemetry triad
Capacity planningDerive instance counts from QPS and event counts

Next up: S8-10 dives into DolphinScheduler, exploring how coomia-dip uses DolphinScheduler for batch processing DAG scheduling and data pipeline orchestration.