Skip to content

Commit 1ffa28d

Browse files
zhongwen666Dengsheng-wzhBCeZnGeneralwindengwx2026
authored
feat(scheduler): add dynamic config reloading via Nacos #888 (#889)
* add release note 120 * Revert "add release note 120" This reverts commit 65a11fd. * add v1.0.4 doc * chore: update docs version config (#431) * scheduler conf to nacos * Feature/openclaw demo (#457) * chore: update redis image version in sandbox demo * refactor: remove redundant validation method in RockAgentConfig * feat: add OpenClaw demo guide and config files * docs: add python sdk references docs for version 1.3.x (#460) * Doc/v1.2.1 0210 (#467) * add release note 120 * Revert "add release note 120" This reverts commit 65a11fd. * add v1.2.1 doc * scheduler conf to nacos * Add callback function abstraction to Nacos; load scheduler process in pod-1 * feat: add k8s operator * feat: validate and sync experiment_id/namespace in JobConfig (#716) (#717) * feat: refine expr_id and namespace * feat(sdk): add JobConfig enhancements and fix linting issues Add job configuration improvements including experiment_id support, OSS mirror config updates, and port validation changes. Fix ruff lint and format issues across the codebase. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use urlparse for URL hostname validation in speedup tests Replace substring-based URL checks with urlparse().hostname to satisfy CodeQL's "Incomplete URL substring sanitization" rule. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: extract domain constants to avoid CodeQL URL substring warnings CodeQL flags `"domain" in var` as incomplete URL sanitization regardless of variable type. Extract hostnames to constants and use helper functions to eliminate the flagged pattern entirely. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * scheduler config to nacod * scheduler to nacos * revert other changes * rm blank dir * refactor(scheduler): incremental task rebuild + non-idempotent cleanup - Rewrite _rebuild_tasks to diff old vs new task configs via per-task md5 hash; only removed/added/changed tasks are touched, unchanged tasks keep their schedule continuity instead of being wiped on every Nacos reload. - Consolidate live-task source of truth onto TaskScheduler._tasks_by_class; delete the TaskRegistry class (no readers in the codebase, only writers). - Add BaseTask.cleanup / cleanup_on_worker / _clear_task_status. For NON_IDEMPOTENT tasks, _uninstall_task now awaits task.cleanup(workers) to pkill -9 the recorded daemon PID + children and rm the per-worker status file, so changing a daemon task's parameters cleanly stops the old process before a new one is installed. --------- Co-authored-by: dengsheng <wuzhehong.wzh@alibaba-inc.com> Co-authored-by: ShixinPeng <58160712+BCeZn@users.noreply.github.com> Co-authored-by: junxin <jiangqianjun.jqj@alibaba-inc.com> Co-authored-by: dengwx <dengwx2026@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 537ba67 commit 1ffa28d

7 files changed

Lines changed: 205 additions & 89 deletions

File tree

rock/admin/main.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from rock.admin.entrypoints.warmup_api import set_warmup_service, warmup_router
2222
from rock.admin.gem.api import gem_router, set_env_service
2323
from rock.admin.scheduler.scheduler import SchedulerThread
24-
from rock.config import DatabaseConfig, RockConfig
24+
from rock.config import DatabaseConfig, RockConfig, SchedulerConfig
2525
from rock.logger import init_logger
2626
from rock.sandbox.gem_manager import GemManager
2727
from rock.sandbox.operator.factory import OperatorContext, OperatorFactory
@@ -51,6 +51,14 @@ async def lifespan(app: FastAPI):
5151
else env_vars.ROCK_CONFIG
5252
)
5353
rock_config = RockConfig.from_env(config_file_path)
54+
55+
# Override scheduler config from Nacos if available
56+
if rock_config.nacos_provider:
57+
nacos_config = await rock_config.nacos_provider.get_config()
58+
if nacos_config and "scheduler" in nacos_config:
59+
rock_config.scheduler = SchedulerConfig(**nacos_config["scheduler"])
60+
logger.info(f"Overrode scheduler config from Nacos with {len(rock_config.scheduler.tasks)} tasks")
61+
5462
env_vars.ROCK_ADMIN_ENV = args.env
5563
env_vars.ROCK_ADMIN_ROLE = args.role
5664

@@ -128,6 +136,7 @@ async def lifespan(app: FastAPI):
128136
if rock_config.scheduler.enabled and is_primary_pod():
129137
scheduler_thread = SchedulerThread(
130138
scheduler_config=rock_config.scheduler,
139+
nacos_provider=rock_config.nacos_provider,
131140
)
132141
scheduler_thread.start()
133142
logger.info("Scheduler thread started on primary pod")

rock/admin/scheduler/scheduler.py

Lines changed: 141 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
# rock/admin/scheduler/scheduler.py
22
import asyncio
3+
import hashlib
4+
import json
35
import threading
46
import time
7+
from dataclasses import asdict
58
from datetime import datetime, timedelta
69

710
import pytz
811
import ray
12+
import yaml
913
from apscheduler.schedulers.asyncio import AsyncIOScheduler
1014

1115
from rock import env_vars
1216
from rock.admin.scheduler.task_base import BaseTask
13-
from rock.admin.scheduler.task_registry import TaskRegistry
1417
from rock.common.constants import SCHEDULER_LOG_NAME
15-
from rock.config import SchedulerConfig
18+
from rock.config import SchedulerConfig, TaskConfig
1619
from rock.logger import init_logger
20+
from rock.utils.providers import NacosConfigProvider
1721

1822
logger = init_logger(name="scheduler", file_name=SCHEDULER_LOG_NAME)
1923

@@ -65,27 +69,140 @@ def get_alive_workers(self, force_refresh: bool = False) -> list[str]:
6569

6670

6771
class TaskScheduler:
68-
"""Manages task scheduling using APScheduler."""
72+
"""Manages task scheduling using APScheduler with optional Nacos dynamic config."""
6973

70-
def __init__(self, scheduler_config: SchedulerConfig):
74+
def __init__(
75+
self,
76+
scheduler_config: SchedulerConfig,
77+
nacos_provider: NacosConfigProvider | None = None,
78+
):
7179
self.scheduler_config = scheduler_config
7280
self.local_tz = pytz.timezone(env_vars.ROCK_TIME_ZONE)
7381
self._scheduler: AsyncIOScheduler | None = None
7482
self._stop_event: asyncio.Event | None = None
7583
self._worker_cache: WorkerIPCache | None = None
76-
self._loop: asyncio.AbstractEventLoop | None = None
84+
self._nacos_provider = nacos_provider
85+
self._event_loop: asyncio.AbstractEventLoop | None = None
86+
self._last_scheduler_config_hash: str | None = None
87+
self._task_hashes: dict[str, str] = {}
88+
self._tasks_by_class: dict[str, BaseTask] = {}
7789

7890
def _init_worker_cache(self) -> None:
7991
"""Initialize the worker IP cache."""
8092
self._worker_cache = WorkerIPCache(
8193
cache_ttl=self.scheduler_config.worker_cache_ttl,
8294
)
8395

84-
def _register_tasks(self) -> None:
85-
"""Register all tasks from configuration."""
96+
def _on_nacos_config_changed(self, new_config: dict) -> None:
97+
"""Callback invoked by Nacos watcher when config changes (runs in Nacos polling thread)."""
98+
logger.info("Nacos config changed, checking scheduler section...")
99+
try:
100+
config_dict = yaml.safe_load(new_config["content"])
101+
if not config_dict or "scheduler" not in config_dict:
102+
logger.warning("No 'scheduler' section in updated Nacos config, skipping")
103+
return
104+
105+
# Compare scheduler section hash to avoid unnecessary reloads
106+
scheduler_raw = json.dumps(config_dict["scheduler"], sort_keys=True)
107+
config_hash = hashlib.md5(scheduler_raw.encode()).hexdigest()
108+
if config_hash == self._last_scheduler_config_hash:
109+
logger.info("Scheduler config unchanged, skipping reload")
110+
return
111+
self._last_scheduler_config_hash = config_hash
112+
113+
new_scheduler_config = SchedulerConfig(**config_dict["scheduler"])
114+
# Schedule the async reload on the event loop (thread-safe)
115+
if self._event_loop and self._event_loop.is_running():
116+
asyncio.run_coroutine_threadsafe(self._reload_scheduler_config(new_scheduler_config), self._event_loop)
117+
else:
118+
logger.warning("Event loop not available, cannot reload tasks dynamically")
119+
except yaml.YAMLError as e:
120+
logger.error(f"Failed to parse updated Nacos YAML config: {e}")
121+
except Exception as e:
122+
logger.error(f"Failed to process Nacos config change: {e}")
123+
124+
async def _reload_scheduler_config(self, new_scheduler_config: SchedulerConfig) -> None:
125+
"""Reload scheduler config by clearing and rebuilding all tasks."""
126+
self.scheduler_config = new_scheduler_config
127+
self._worker_cache.cache_ttl = new_scheduler_config.worker_cache_ttl
128+
await self._rebuild_tasks()
129+
130+
@staticmethod
131+
def _compute_task_hash(task_config: TaskConfig) -> str:
132+
raw = json.dumps(asdict(task_config), sort_keys=True)
133+
return hashlib.md5(raw.encode()).hexdigest()
134+
135+
def _install_task(self, task_config: TaskConfig) -> None:
136+
"""Create, register, and schedule a single task."""
86137
from rock.admin.scheduler.task_factory import TaskFactory
87138

88-
TaskFactory.register_all_tasks(self.scheduler_config)
139+
try:
140+
task = TaskFactory.create_task(task_config)
141+
except Exception as e:
142+
logger.error(f"Failed to create task '{task_config.task_class}': {e}")
143+
return
144+
145+
self._tasks_by_class[task_config.task_class] = task
146+
self._task_hashes[task_config.task_class] = self._compute_task_hash(task_config)
147+
self._scheduler.add_job(
148+
self._run_task,
149+
trigger="interval",
150+
seconds=task.interval_seconds,
151+
args=[task],
152+
id=task.type,
153+
name=task.type,
154+
replace_existing=True,
155+
next_run_time=datetime.now(self.local_tz) + timedelta(seconds=2),
156+
)
157+
logger.info(f"Installed task '{task.type}' with interval {task.interval_seconds}s")
158+
159+
async def _uninstall_task(self, task_class: str) -> None:
160+
"""Remove a single task from the scheduler and clean up its worker-side processes."""
161+
task = self._tasks_by_class.pop(task_class, None)
162+
self._task_hashes.pop(task_class, None)
163+
if task is None:
164+
return
165+
try:
166+
self._scheduler.remove_job(task.type)
167+
except Exception as e:
168+
logger.warning(f"Failed to remove scheduler job '{task.type}': {e}")
169+
if self._worker_cache is not None:
170+
worker_ips = self._worker_cache.get_alive_workers()
171+
if worker_ips:
172+
await task.cleanup(worker_ips)
173+
logger.info(f"Uninstalled task '{task.type}'")
174+
175+
async def _rebuild_tasks(self) -> None:
176+
"""Apply config changes by diffing old vs new tasks; only touch the ones that changed."""
177+
if not self.scheduler_config.enabled:
178+
for task_class in list(self._tasks_by_class):
179+
await self._uninstall_task(task_class)
180+
logger.info("Scheduler disabled, all tasks removed")
181+
return
182+
183+
new_by_class: dict[str, TaskConfig] = {}
184+
new_hashes: dict[str, str] = {}
185+
for task_config in self.scheduler_config.tasks:
186+
if not task_config.enabled or not task_config.task_class:
187+
continue
188+
new_by_class[task_config.task_class] = task_config
189+
new_hashes[task_config.task_class] = self._compute_task_hash(task_config)
190+
191+
old_keys = set(self._task_hashes)
192+
new_keys = set(new_hashes)
193+
removed = old_keys - new_keys
194+
added = new_keys - old_keys
195+
changed = {k for k in (old_keys & new_keys) if self._task_hashes[k] != new_hashes[k]}
196+
197+
for task_class in removed | changed:
198+
await self._uninstall_task(task_class)
199+
for task_class in added | changed:
200+
self._install_task(new_by_class[task_class])
201+
202+
if removed or added or changed:
203+
logger.info(f"Scheduler tasks updated: removed={len(removed)}, added={len(added)}, changed={len(changed)}")
204+
else:
205+
logger.info("No task changes detected")
89206

90207
async def _run_task(self, task: BaseTask) -> None:
91208
"""Run a single task on alive workers."""
@@ -99,28 +216,18 @@ async def _run_task(self, task: BaseTask) -> None:
99216
except Exception as e:
100217
logger.error(f"Task '{task.type}' failed: {e}")
101218

102-
def _add_jobs(self) -> None:
103-
"""Add all registered tasks as scheduler jobs."""
104-
for task in TaskRegistry.get_all_tasks().values():
105-
self._scheduler.add_job(
106-
self._run_task,
107-
trigger="interval",
108-
seconds=task.interval_seconds,
109-
args=[task],
110-
id=task.type,
111-
name=task.type,
112-
replace_existing=True,
113-
next_run_time=datetime.now(self.local_tz) + timedelta(seconds=2),
114-
)
115-
logger.info(f"Added job '{task.type}' with interval {task.interval_seconds}s")
116-
117219
async def run(self) -> None:
118220
"""Run the scheduler until stopped."""
221+
self._event_loop = asyncio.get_running_loop()
222+
119223
self._init_worker_cache()
120-
self._register_tasks()
224+
225+
if self._nacos_provider:
226+
self._nacos_provider.add_listener(self._on_nacos_config_changed)
227+
logger.info("Nacos dynamic config listener registered for scheduler")
121228

122229
self._scheduler = AsyncIOScheduler(timezone=self.local_tz)
123-
self._add_jobs()
230+
await self._rebuild_tasks()
124231

125232
# Pre-cache worker IPs before starting
126233
self._worker_cache.refresh()
@@ -129,7 +236,6 @@ async def run(self) -> None:
129236
logger.info("Scheduler started")
130237

131238
self._stop_event = asyncio.Event()
132-
self._loop = asyncio.get_event_loop()
133239

134240
try:
135241
await self._stop_event.wait()
@@ -141,22 +247,27 @@ async def run(self) -> None:
141247

142248
def stop(self) -> None:
143249
"""Thread-safe stop: signal the scheduler to shut down."""
144-
if self._stop_event and self._loop:
145-
self._loop.call_soon_threadsafe(self._stop_event.set)
250+
if self._stop_event and self._event_loop:
251+
self._event_loop.call_soon_threadsafe(self._stop_event.set)
146252

147253

148254
class SchedulerThread:
149255
"""Scheduler thread manager - runs APScheduler in a daemon thread with its own event loop."""
150256

151-
def __init__(self, scheduler_config: SchedulerConfig):
257+
def __init__(
258+
self,
259+
scheduler_config: SchedulerConfig,
260+
nacos_provider: NacosConfigProvider | None = None,
261+
):
152262
self.scheduler_config = scheduler_config
263+
self.nacos_provider = nacos_provider
153264
self._thread: threading.Thread | None = None
154265
self._task_scheduler: TaskScheduler | None = None
155266

156267
def _run_scheduler_in_thread(self) -> None:
157268
"""Entry point for running scheduler in a thread with a dedicated event loop."""
158269
try:
159-
self._task_scheduler = TaskScheduler(self.scheduler_config)
270+
self._task_scheduler = TaskScheduler(self.scheduler_config, self.nacos_provider)
160271
asyncio.run(self._task_scheduler.run())
161272
except Exception:
162273
logger.exception("Scheduler thread encountered an error")

rock/admin/scheduler/task_base.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,47 @@ async def save_task_status(self, runtime: RemoteSandboxRuntime, status: TaskStat
171171
"""Save task status to worker file."""
172172
await runtime.write_file(WriteFileRequest(path=self.status_file_path, content=status.to_json()))
173173

174+
async def _clear_task_status(self, runtime: RemoteSandboxRuntime) -> None:
175+
"""Remove the status file from worker."""
176+
await runtime.execute(Command(command=f"rm -f {self.status_file_path}", shell=True))
177+
178+
async def cleanup_on_worker(self, ip: str) -> None:
179+
"""Stop any long-running process spawned by this task on a single worker.
180+
181+
For idempotent tasks this is a no-op (no daemon process to kill).
182+
"""
183+
if self.idempotency == IdempotencyType.IDEMPOTENT:
184+
return
185+
runtime = self._get_runtime(ip)
186+
status = await self.get_task_status(runtime)
187+
if status is None or not status.pid:
188+
return
189+
if await runtime.check_pid_exists(status.pid):
190+
kill_cmd = f"pkill -9 -P {status.pid}; kill -9 {status.pid}"
191+
await runtime.execute(Command(command=kill_cmd, shell=True))
192+
logger.info(f"[{self.type}] killed pid {status.pid} on worker[{ip}]")
193+
await self._clear_task_status(runtime)
194+
195+
async def cleanup(self, worker_ips: list[str], max_concurrency: int = 50) -> None:
196+
"""Cleanup task across all workers, parallel and best-effort.
197+
198+
Idempotent tasks return immediately. For non-idempotent tasks, kills the
199+
recorded daemon process and clears the status file on each worker. Failures
200+
on individual workers are logged but do not propagate.
201+
"""
202+
if self.idempotency == IdempotencyType.IDEMPOTENT:
203+
return
204+
semaphore = asyncio.Semaphore(max_concurrency)
205+
206+
async def cleanup_with_limit(ip: str) -> None:
207+
async with semaphore:
208+
try:
209+
await self.cleanup_on_worker(ip)
210+
except Exception as e:
211+
logger.warning(f"[{self.type}] cleanup failed on worker[{ip}]: {e}")
212+
213+
await asyncio.gather(*[cleanup_with_limit(ip) for ip in worker_ips])
214+
174215
async def should_run(self, runtime: RemoteSandboxRuntime) -> bool:
175216
"""Determine if the task should be run."""
176217
if self.idempotency == IdempotencyType.IDEMPOTENT:

rock/admin/scheduler/task_factory.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,11 @@
22
import importlib
33

44
from rock.admin.scheduler.task_base import BaseTask
5-
from rock.admin.scheduler.task_registry import TaskRegistry
6-
from rock.common.constants import SCHEDULER_LOG_NAME
7-
from rock.config import SchedulerConfig, TaskConfig
8-
from rock.logger import init_logger
9-
10-
logger = init_logger("task_factory", file_name=SCHEDULER_LOG_NAME)
5+
from rock.config import TaskConfig
116

127

138
class TaskFactory:
14-
"""Task factory - dynamically creates and registers tasks from config."""
9+
"""Task factory - dynamically creates tasks from config."""
1510

1611
@staticmethod
1712
def _load_task_class(class_path: str) -> type[BaseTask]:
@@ -39,29 +34,5 @@ def create_task(cls, task_config: TaskConfig) -> BaseTask:
3934
Returns:
4035
Task instance
4136
"""
42-
# Dynamically load task class
4337
task_class = cls._load_task_class(task_config.task_class)
44-
45-
# Create task instance with config params
46-
task = task_class.from_config(task_config)
47-
48-
return task
49-
50-
@classmethod
51-
def register_all_tasks(cls, scheduler_config: SchedulerConfig):
52-
"""Register all enabled tasks from config."""
53-
for task_config in scheduler_config.tasks:
54-
if not task_config.enabled:
55-
logger.info(f"Task '{task_config.task_class}' is disabled, skipping")
56-
continue
57-
58-
if not task_config.task_class:
59-
logger.warning(f"Task '{task_config.task_class}' has no task_class, skipping")
60-
continue
61-
62-
try:
63-
task = cls.create_task(task_config)
64-
TaskRegistry.register(task)
65-
logger.info(f"Registered task '{task.type}' with interval {task.interval_seconds}s")
66-
except Exception as e:
67-
logger.error(f"Failed to create task '{task_config.task_class}': {e}")
38+
return task_class.from_config(task_config)

0 commit comments

Comments
 (0)