Skip to content

✨ Adds t_scheduler used for reliably running code in dynamic-scheduler ⚠️ - #9036

Open
GitHK wants to merge 39 commits into
ITISFoundation:masterfrom
GitHK:pr-osparc-temporalio-scheduling
Open

✨ Adds t_scheduler used for reliably running code in dynamic-scheduler ⚠️#9036
GitHK wants to merge 39 commits into
ITISFoundation:masterfrom
GitHK:pr-osparc-temporalio-scheduling

Conversation

@GitHK

@GitHK GitHK commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

What do these changes do?

TODOS:

  • add upgrade notes for to avoid issues with auto-setup image
  • upgrade temporal version to latest

Adds a Temporal.io-based workflow engine (t_scheduler) to the dynamic-scheduler service. This provides durable workflow execution (saga pattern) with automatic retries, heartbeats, and state persistence for managing dynamic service lifecycles.

Change breakdown by category

Category Files Lines % of PR
Tests 28 +2,954 net ~69%
Core source code 22 +1,131 net ~26%
CI / Docker infra 8 +116 net ~3%
Config / settings 5 +91 net ~2%
Total 63 files +4,292 net

For reviewers: ~69% of this PR is tests. The actual production code to review is ~1,131 lines across 22 files. 10 existing test files have trivial 1-line changes (adding TEMPORALIO_* env mocks).

Key areas to review

New module: services/t_scheduler/ (~1,000 lines)

  • _base_workflow.py — Saga workflow abstraction (execute/compensate pattern)
  • _engine.py — Workflow engine runner (start/cancel/query workflows)
  • _health_check.py — Temporal connectivity health check with periodic ping
  • _heartbeat.py — Activity heartbeat interceptor
  • _lifespan.py — FastAPI lifespan hooks (client + worker startup/shutdown)
  • _registry.py — Workflow/activity registration
  • _models.py — Pydantic models for workflow state

New module: services/workflows/ (~120 lines)

  • Concrete workflow implementations (healthcheck workflow, snapshot persistence)

Shared package: packages/settings-library/temporalio.py (44 lines)

  • TemporalioSettings — shared Pydantic settings (TEMPORALIO_HOST, PORT, NAMESPACE, TASK_QUEUE)

Integration with existing code (~50 lines across 6 files)

  • core/events.py — Wires temporal lifespans into app startup
  • core/settings.py — Adds TemporalioSettings to app settings
  • api/rest/_health.py — Health endpoint checks temporal connectivity
  • api/rest/_ops.py — Ops endpoint to list/shutdown temporal workflows
  • api/rest/_dependencies.py — FastAPI dependency for temporal health check
  • cli.py — Worker mode argument

Docker / CI (~165 lines)

  • docker-compose.yml — Adds temporal service (temporalio/auto-setup:1.29.1) reusing existing PostgreSQL
  • docker-compose-ops.yml — Temporal UI (temporalio/ui:2.36.0)
  • ci-testing-deploy.yml — CI job for dynamic-scheduler integration tests
  • New CI helper scripts for integration testing and maintenance reminders

Env / Config (~100 lines)

  • .env-devel — 4 new TEMPORALIO_* variables
  • requirements/_base.intemporalio Python dependency
  • openapi.json — New ops endpoints in OpenAPI spec

Impact on existing services

  • dynamic-scheduler now requires a running Temporal server at startup (120s retry). Without it, the service exits with code 3.
  • temporal service uses the existing postgres — creates separate databases (temporal, temporal_visibility), no conflict with simcoredb.
  • Webserver integration tests need temporal in the Docker stack when dynamic-schdlr is included (CI fix applied to test_computation.py).

Related issue/s

How to test

Dev-ops ⚠️

Note, for future reference when temporal workflow shutdown is required:

OPS actions to take:

1. GET /ops/temporalio-workflows  → verify running workflow is visible
2. POST /ops/temporalio-workflows:shutdown → cancel all, check count
3. GET /ops/temporalio-workflows (poll) → wait until list is empty

@GitHK GitHK self-assigned this Apr 17, 2026
@GitHK GitHK added this to the Etna milestone Apr 17, 2026
@codecov

codecov Bot commented Apr 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.29%. Comparing base (435cf30) to head (8d0119d).

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #9036      +/-   ##
==========================================
- Coverage   87.66%   87.29%   -0.37%     
==========================================
  Files        2124     2067      -57     
  Lines       83866    82396    -1470     
  Branches     1569     1569              
==========================================
- Hits        73519    71929    -1590     
- Misses       9921    10041     +120     
  Partials      426      426              
Flag Coverage Δ
integrationtests 71.59% <ø> (+7.83%) ⬆️
unittests 86.42% <93.33%> (-0.16%) ⬇️
Components Coverage Δ
pkg_aws_library 95.33% <ø> (ø)
pkg_celery_library 76.60% <ø> (ø)
pkg_dask_task_models_library 90.73% <ø> (ø)
pkg_models_library 92.61% <ø> (ø)
pkg_notifications_library ∅ <ø> (∅)
pkg_postgres_database 90.13% <ø> (ø)
pkg_service_integration 72.99% <ø> (ø)
pkg_service_library 70.75% <ø> (ø)
pkg_settings_library 90.97% <93.33%> (+0.04%) ⬆️
pkg_simcore_sdk 86.25% <ø> (ø)
agent 93.43% <ø> (ø)
api_server 92.95% <ø> (ø)
autoscaling 95.29% <ø> (ø)
catalog 92.47% <ø> (ø)
clusters_keeper 98.48% <ø> (ø)
dask_sidecar 93.45% <ø> (ø)
datcore_adapter 98.08% <ø> (ø)
director 79.09% <ø> (+0.07%) ⬆️
director_v2 91.59% <ø> (-0.11%) ⬇️
dynamic_scheduler ∅ <ø> (∅)
dynamic_sidecar 88.37% <ø> (ø)
efs_guardian 89.86% <ø> (ø)
invitations 91.63% <ø> (ø)
payments 92.17% <ø> (ø)
resource_usage_tracker 91.94% <ø> (+0.10%) ⬆️
storage 87.09% <ø> (ø)
webclient ∅ <ø> (∅)
webserver 86.29% <ø> (-0.74%) ⬇️

Continue to review full report in Codecov by Harness.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 435cf30...8d0119d. Read the comment docs.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@GitHK GitHK changed the title ✨ Adds t_scheduler used for reliably running code in dynamic-scheduler ✨ Adds t_scheduler used for reliable code execution, currently attached to dynamic-scheduler Apr 17, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 62 out of 62 changed files in this pull request and generated 3 comments.

Andrei Neagu added 2 commits April 21, 2026 11:42
@sonarqubecloud

Copy link
Copy Markdown

@GitHK
GitHK marked this pull request as ready for review April 21, 2026 11:06

@matusdrobuliak66 matusdrobuliak66 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@pcrespov pcrespov left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thx.

At first glance, I like both the Temporal.io functionality and the saga approach. That said, I have a common criticism: the PR feels like it generalizes too early, and you end up patching a generic interface before concrete use cases exist. I would have gone the other way — use Temporal.io as the implementation, build out real use cases, then abstract the repeated parts gradually. Though I may be missing something, since you have more context on the problems you're trying to solve.


_logger = logging.getLogger(__name__)

_HEALTHCHECK_TIMEOUT: Final[timedelta] = timedelta(seconds=3)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

THOUGHT: this is such a standard code. I wonder wether we could create an abstraction and move it to some package. This way we can have a similar pattern to use in all services.

raise WorkflowAlreadyRegisteredError(name=name)

self._workflows[name] = workflow_cls
for act in workflow_cls.get_activities():

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do you need access to the activities (get_temporalio_activities) ? they are inside of workflows.

as far as I understand th full idea is to run workflows, not indiviual activities. Exposing them outside defeats the purpse?

)


class WorkflowEngine:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I "love the engine"s ... :-D ... we like abusing same names for everything.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the engine basically a higher level client to temporal?

handle: WorkflowHandle = self._client.get_workflow_handle(workflow_id)
await handle.signal("resolve", ResolutionSignal(activity_name=activity_name, decision=decision))

async def list_running_workflows(self) -> list["RunningWorkflowInfo"]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

STYLE: Keep consistency with names, start, cancel ... or start_workflows, cancel_workflows ... but do not mix them

(must match a key in ``WorkflowStatus.failed_activities``).
decision: Action to take — ``RETRY`` to re-execute,
``SKIP`` to ignore the failure and continue, or
``ROLLBACK`` to trigger compensation.

@pcrespov pcrespov Apr 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought ROLLBACK this was automatic ... isn't that the whole idea of the SAGA pattern?

THOGUTH:

If feels like you are creating a lot of abstractions on top of temporalio.workflow but then you open functions to address exceptions.

Do we need to have so many abstraction layers? Could we start with hard-coded worklfows like this example where we already decide how to react? Is it our system so dynamic??

Image

Here 1 and 2 roolback or compensate the activities on error (3) ... and the workflow is predefined.

from temporalio import activity, workflow
from temporalio.common import RetryPolicy

from ..t_scheduler._base_workflow import SagaWorkflow

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO this is wrong. I fyou want to use these you should use the public interface.

You are accessing here protected members of another service module i.e. t_scheduler

In this case, the approach followed in web/server would be

  1. instead of t_scheduler.__init__ we define t_scheduler.t_scheduler_service as the service public api.
  2. other service can use this as from ..t_scheduler import t_scheduler_service ... then all functions from this other service are t_scheduler_service.do_this(...). For models, exceptions or data-types you can import directly from ..t_scheduler.t_scheduler_service import Model1, Exception1 etc



async def t_scheduler_register_workflows_lifespan(app: FastAPI) -> AsyncIterator[State]:
_register_workflows(get_workflow_registry(app))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so now you just have a workflow to register a heatchekc.

from models_library.utils.enums import StrAutoEnum


class WorkflowNames(StrAutoEnum):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since you have a registration, i would do this automatically as well...
think in a single registration point for your workflow and produce all these at the same time... otherwise you will have to remember to check all the boxes for every new registration


"""End-to-end integration tests for Temporal workflows.

These tests exercise the full stack — real Temporal server (via Docker Swarm),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of these tests are meant to explore how to use temporal. That's fine — it's worth doing, though maybe not critical for everyone. Should we mark them specially so they don't run on every execution? We could define a custom pytest marker and use it to skip them in certain contexts, e.g. on merge to master.

what do you think?

@bisgaard-itis bisgaard-itis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This definitely looks very interesting. TBH I am not completely convinced by the idea of introducing yet another distributed task scheduling tool - we already have quite a lot of them laying around (both homemade and not). But I guess there is a good reason. But keep in mind that maintaining those system is also significant effort (ask @giancarloromeo). One thing in particular that I would rethink is how to detect if the workflows are out of sync. I am quite confident we could find a way to automate that completely (see comments)

@@ -0,0 +1,50 @@
#!/bin/bash
# Posts a one-time PR comment when workflows_signatures.json changes,
# warning OPS that Temporalio workflows must be shut down before deploying.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it not be possible to handle automate this step completely? Either by always shutting down the temporalio thingy, or by having a health check which checks that the workflows are up to date.

Having a separate CI job for posting a message to a PR seems very cumbersome and error prone to me.

types: [opened, synchronize]

jobs:
ops-temporalio-maintenance-comment:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would try to avoid having this job if possible (see also my other comment)

- default
- interactive_services_subnet # for legacy dynamic services

temporal:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't you add a custom healthcheck to this service which queries the dynamic scheduler to get the workflow signature (or maybe simply a hash of it) and fails if it doesn't match what it expects. That way OPS would not need to be involved

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I get it right, that the temporal service is where the different "sagas" are registered?


If this test fails, run ``make workflows_signatures.json`` in the service root
to regenerate ``workflows_signatures.json``, then flag the PR for OPS
to shut down Temporal workflows before deploying.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O think an alternative approach to doing this would simply be that you create your own docker image of temporal. It could simply be a copy of the temporal image, but tagged with the version we are using for the simcore services. That way when the ci runs, if the version already deployed doesn't match, it will create a new container. I guess that is actually the simplest solution. This is in the end simply a matter of retagging the official tepmoral image.

WorkflowNotFoundError: If *workflow_name* is not in the registry.
temporalio.service.RPCError: If a workflow with the same
*workflow_id* is already running.
"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow we have an ability to always introduce a new tool for doing this kind of distributed task scheduling. Couldn't we simply have used celery?

@sanderegg sanderegg left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok this looks interesting. but here are a few comments:

  • I would use the Temporal and not Temporalio to make it clear, you have currently a mixture of them
  • t_scheduler, I would rename to temporal_client or something similar or do you plan to have a worker as well?
  • please add a healthcheck in the docker compose.

from .basic_types import PortInt


class TemporalioSettings(BaseCustomSettings):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

temporal.io is the website and the python SDK right?
why you do not call it just Temporal?


router = APIRouter()

_TEMPORALIO_CLIENT_UNHEALTHY_MSG: Final[str] = "Temporalio cannot be reached!"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you mix temporal and temporalio. I think it should be probably always just temporal

async def list_workflows(
app: Annotated[FastAPI, Depends(get_app)],
) -> list[RunningWorkflowInfo]:
"""List all running Temporalio workflows on the scheduler task queue."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""List all running Temporalio workflows on the scheduler task queue."""
"""List all running Temporal workflows on the scheduler task queue."""

async def shutdown_workflows(
app: Annotated[FastAPI, Depends(get_app)],
) -> dict[str, int]:
"""Cancel all running Temporalio workflows, triggering saga compensation."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compensation? of what?

Comment on lines +34 to +50
def __init__(self) -> None:
self._state: WorkflowState = WorkflowState.RUNNING

self._pending_decisions: dict[str, Decision] = {}
self._awaiting_decisions: set[str] = set()

self._running_activities: set[str] = set()
self._compensations: list[Compensation] = []
self._completed_activities: set[str] = set()
self._failed_activities: dict[str, str] = {}
self._compensated_activities: set[str] = set()
self._failed_compensations: dict[str, str] = {}
self._skipped_activities: set[str] = set()

self._steps_total: int = 0
self._history: list[dict[str, Any]] = []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it not be a bit more readable using a dataclass here?

activity.heartbeat()
except asyncio.CancelledError:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a kind of dangerous construct as this can swallow a higher level cancellation call.
you should probably use cancel_wait_task here

Comment on lines +13 to +15
def __init__(self) -> None:
self._workflows: dict[str, type[SagaWorkflow]] = {}
self._activities: list[Callable[..., Coroutine[Any, Any, Any]]] = []

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could also use a dataclass for simplifications

Comment on lines +14 to +23
@activity.defn
async def healthcheck_activity(_ctx: dict[str, Any]) -> dict[str, Any]:
_logger.info("Healthcheck activity executed")
return {"healthcheck": "ok"}


@activity.defn
async def undo_healthcheck_activity(_ctx: dict[str, Any]) -> None:
_logger.info("Healthcheck activity compensated (no-op)")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what you are doing here.
is activity.defn implicitely connecting with the temporal platform?
the logger is not really a no-op right? it writes to the stdout stream.
also not sure I understand the undo part of this.

pytest.param(True, False, True, True, False, id="rabbit_rpc_client_bad"),
pytest.param(True, True, False, True, False, id="redis_client_bad"),
pytest.param(True, True, True, False, False, id="docker_api_proxy_bad"),
pytest.param(True, True, True, True, True, True, id="ok"),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe using a dataclass here or a named tuple would make it a bit more readable

POSTGRES_SEEDS: postgres
TEMPORAL_ADDRESS: temporal:7233
BIND_ON_IP: 0.0.0.0
networks:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

healthcheck is missing here. please add one thanks

@GitHK GitHK modified the milestones: Etna, Toynbee Tiles Jul 7, 2026
@github-actions github-actions Bot added a:infra+ops maintenance of infrastructure or operations (discussed in retro) a:webserver webserver's codebase. Assigning the area is particularly useful for bugs a:services-library issues on packages/service-libs a:dynamic-scheduler dependencies Pull requests that update a dependency file labels Aug 6, 2026
@sonarqubecloud

sonarqubecloud Bot commented Aug 6, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a:dynamic-scheduler a:infra+ops maintenance of infrastructure or operations (discussed in retro) a:services-library issues on packages/service-libs a:webserver webserver's codebase. Assigning the area is particularly useful for bugs dependencies Pull requests that update a dependency file

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants