simstack 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simstack/__init__.py +0 -0
- simstack/_version.py +34 -0
- simstack/core/__init__.py +0 -0
- simstack/core/artifacts.py +447 -0
- simstack/core/asnyc_helper.py +14 -0
- simstack/core/config_file.py +60 -0
- simstack/core/context.py +167 -0
- simstack/core/data.py +139 -0
- simstack/core/definitions.py +21 -0
- simstack/core/engine.py +116 -0
- simstack/core/find_simstack_modules.py +43 -0
- simstack/core/hash.py +177 -0
- simstack/core/model_table.py +199 -0
- simstack/core/node.py +911 -0
- simstack/core/node_runner.py +254 -0
- simstack/core/node_table.py +380 -0
- simstack/core/recompute_artifacts.py +120 -0
- simstack/core/reset_db.py +12 -0
- simstack/core/resources.py +101 -0
- simstack/core/run_node.py +69 -0
- simstack/core/runner.py +435 -0
- simstack/core/simstack_result.py +41 -0
- simstack/core/submit_node.py +143 -0
- simstack/core/task_id.py +22 -0
- simstack/data/__init__.py +0 -0
- simstack/methods/__init__.py +0 -0
- simstack/methods/get_file.py +10 -0
- simstack/methods/multiple_executor.py +307 -0
- simstack/models/__init__.py +37 -0
- simstack/models/array_list.py +48 -0
- simstack/models/array_storage.py +54 -0
- simstack/models/artifact_models.py +127 -0
- simstack/models/base_types.py +208 -0
- simstack/models/charts_artifact.py +411 -0
- simstack/models/dataset.py +432 -0
- simstack/models/dataset_metadata.py +303 -0
- simstack/models/file_instance.py +77 -0
- simstack/models/file_list.py +321 -0
- simstack/models/files.py +229 -0
- simstack/models/log_entry_model.py +37 -0
- simstack/models/models.py +40 -0
- simstack/models/node_registry.py +105 -0
- simstack/models/pandas_model.py +210 -0
- simstack/models/parameters.py +388 -0
- simstack/models/pickle_models.py +311 -0
- simstack/models/runner_model.py +39 -0
- simstack/models/simple_table.py +32 -0
- simstack/models/simstack_model.py +97 -0
- simstack/models/slurm_info.py +17 -0
- simstack/models/table_artifact.py +87 -0
- simstack/util/__init__.py +0 -0
- simstack/util/async_zip_utils.py +18 -0
- simstack/util/cleaned_json_schema.py +20 -0
- simstack/util/config_reader.py +273 -0
- simstack/util/custom_model_dump.py +75 -0
- simstack/util/db.py +457 -0
- simstack/util/db_logger.py +117 -0
- simstack/util/default_from_dict.py +57 -0
- simstack/util/directory_iterator.py +216 -0
- simstack/util/file_hashing.py +68 -0
- simstack/util/files.py +396 -0
- simstack/util/find_python_files.py +33 -0
- simstack/util/generate_ui_schema.py +80 -0
- simstack/util/geometry_utils.py +118 -0
- simstack/util/get_module_path.py +26 -0
- simstack/util/git_pull.py +65 -0
- simstack/util/git_repository_status.py +93 -0
- simstack/util/import_module.py +65 -0
- simstack/util/importer.py +163 -0
- simstack/util/make_graph.py +107 -0
- simstack/util/make_table.py +827 -0
- simstack/util/minimal_route_finder.py +72 -0
- simstack/util/mock_db.py +144 -0
- simstack/util/module_path_checker.py +38 -0
- simstack/util/path_manager.py +234 -0
- simstack/util/project_root_finder.py +36 -0
- simstack/util/queue_watcher.py +262 -0
- simstack/util/route_finder.py +74 -0
- simstack/util/runner_util.py +162 -0
- simstack/util/safe_code_executor.py +194 -0
- simstack/util/sample_config.py +98 -0
- simstack/util/sensitive_content_filter.py +48 -0
- simstack/util/setup_logging.py +49 -0
- simstack/util/submit_to_watchdog.py +106 -0
- simstack/util/ui_tools.py +213 -0
- simstack-0.2.0.dist-info/METADATA +361 -0
- simstack-0.2.0.dist-info/RECORD +91 -0
- simstack-0.2.0.dist-info/WHEEL +5 -0
- simstack-0.2.0.dist-info/entry_points.txt +10 -0
- simstack-0.2.0.dist-info/licenses/LICENSE +21 -0
- simstack-0.2.0.dist-info/top_level.txt +1 -0
simstack/core/runner.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from odmantic import ObjectId
|
|
14
|
+
|
|
15
|
+
from simstack.core.context import context
|
|
16
|
+
from simstack.core.definitions import TaskStatus
|
|
17
|
+
from simstack.core.node import node_from_database
|
|
18
|
+
from simstack.core.submit_node import submit_node
|
|
19
|
+
from simstack.models import NodeRegistry
|
|
20
|
+
from simstack.models.parameters import Resource
|
|
21
|
+
from simstack.models.runner_model import RunnerEvent, RunnerType, RunnerEventEnum
|
|
22
|
+
from simstack.models.slurm_info import SlurmInfo
|
|
23
|
+
from simstack.util.git_repository_status import get_git_status
|
|
24
|
+
from simstack.util.submit_to_watchdog import submit_to_watchdog
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("NodeRunner")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def run_node(registry_entry: NodeRegistry):
|
|
30
|
+
"""Run a single node by its ID from the database"""
|
|
31
|
+
if isinstance(context.config.resource, str):
|
|
32
|
+
resource = Resource(value=context.config.resource)
|
|
33
|
+
else:
|
|
34
|
+
resource = context.config.resource
|
|
35
|
+
pid = os.getpid()
|
|
36
|
+
username = os.environ.get("USER", os.environ.get("USERNAME", "unknown"))
|
|
37
|
+
hostname = socket.gethostname()
|
|
38
|
+
|
|
39
|
+
runner_event = RunnerEvent(
|
|
40
|
+
runner_type=RunnerType.NODE_RUNNER,
|
|
41
|
+
event=RunnerEventEnum.NODE_STARTED,
|
|
42
|
+
pid=pid,
|
|
43
|
+
hostname=hostname,
|
|
44
|
+
user=username,
|
|
45
|
+
resource=resource,
|
|
46
|
+
node_id=registry_entry.id,
|
|
47
|
+
)
|
|
48
|
+
await context.db.save(runner_event)
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
logger.info(
|
|
52
|
+
f"Running node task_id: {registry_entry.id} on resource {context.config.resource}"
|
|
53
|
+
)
|
|
54
|
+
if (
|
|
55
|
+
hasattr(registry_entry.parameters, "queue")
|
|
56
|
+
and registry_entry.parameters.queue == "slurm-queue"
|
|
57
|
+
):
|
|
58
|
+
await submit_node(registry_entry)
|
|
59
|
+
else:
|
|
60
|
+
# Create the node from the registry entry
|
|
61
|
+
node = await node_from_database(registry_entry)
|
|
62
|
+
if not node:
|
|
63
|
+
logger.error(
|
|
64
|
+
f"Failed to create node from registry entry task_id: {registry_entry.id} on resource {context.config.resource}"
|
|
65
|
+
)
|
|
66
|
+
registry_entry.status = TaskStatus.FAILED
|
|
67
|
+
await context.db.save(registry_entry)
|
|
68
|
+
return False
|
|
69
|
+
registry_entry = node.registry_entry # it may have changed
|
|
70
|
+
if node.status == TaskStatus.SUBMITTED or node.status == TaskStatus.SLURM_QUEUED or node.status == TaskStatus.SLURM_QUEUED:
|
|
71
|
+
await node.execute_node_locally()
|
|
72
|
+
else:
|
|
73
|
+
logger.info(
|
|
74
|
+
f"task_id: {registry_entry.id} skipping task: {registry_entry.name} with status {registry_entry.status}")
|
|
75
|
+
|
|
76
|
+
return node.status == TaskStatus.COMPLETED
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.exception(
|
|
79
|
+
f"Error running node task_id: {registry_entry.id} on resource {context.config.resource} : {str(e)}"
|
|
80
|
+
)
|
|
81
|
+
if registry_entry:
|
|
82
|
+
registry_entry.status = TaskStatus.FAILED
|
|
83
|
+
await context.db.save(registry_entry)
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def make_git_list() -> List[str]:
|
|
88
|
+
git_list = []
|
|
89
|
+
for path in context.config.git:
|
|
90
|
+
result = get_git_status(path)
|
|
91
|
+
if result["branch"]:
|
|
92
|
+
value = result["branch"] + "[" + result["short_hash"] + "]"
|
|
93
|
+
if result["up_to_date"]:
|
|
94
|
+
value += " (up-to-date)"
|
|
95
|
+
else:
|
|
96
|
+
value += " (behind " + str(result["behind"]) + " commits)"
|
|
97
|
+
git_list.append(value)
|
|
98
|
+
else:
|
|
99
|
+
git_list.append("No branch found")
|
|
100
|
+
return git_list
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run_squeue_for_job(job_id: str) -> str:
|
|
104
|
+
if context.config.docker:
|
|
105
|
+
result = subprocess.run(
|
|
106
|
+
f"squeue -j {job_id}",
|
|
107
|
+
shell=True,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=30,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
watchdog_id = f"slurm_{uuid.uuid4()}"
|
|
114
|
+
queue_dir = context.config.workdir / "queue"
|
|
115
|
+
result = submit_to_watchdog(
|
|
116
|
+
f"squeue -j {job_id}", watchdog_id, queue_dir=queue_dir
|
|
117
|
+
)
|
|
118
|
+
return result.stdout
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_job_info(job_id: str, task_id: ObjectId, resource: str) -> SlurmInfo | None:
|
|
122
|
+
"""Get job information from SLURM queue using squeue"""
|
|
123
|
+
try:
|
|
124
|
+
result = run_squeue_for_job(job_id)
|
|
125
|
+
if result.returncode == 0:
|
|
126
|
+
if not result.stdout or result.stdout == "":
|
|
127
|
+
return None
|
|
128
|
+
lines = result.stdout.splitlines()
|
|
129
|
+
logger.info(f"slurm info for job {job_id}: {lines}")
|
|
130
|
+
if len(lines) < 2:
|
|
131
|
+
return None
|
|
132
|
+
# The first line is the header; the second line is the single info line
|
|
133
|
+
|
|
134
|
+
info_line = lines[1].strip()
|
|
135
|
+
if not info_line:
|
|
136
|
+
return None
|
|
137
|
+
# Split the single line into parts separated by whitespace
|
|
138
|
+
parts = re.split(r"\s+", info_line)
|
|
139
|
+
logger.info(f"slurm info for job {job_id}: {parts}")
|
|
140
|
+
# Expected default squeue columns:
|
|
141
|
+
# JOBID PARTITION NAME USER ST TIME NODES NODELIST(REASON)
|
|
142
|
+
name = parts[2] if len(parts) > 2 else ""
|
|
143
|
+
user = parts[3] if len(parts) > 3 else ""
|
|
144
|
+
code = parts[4] if len(parts) > 4 else ""
|
|
145
|
+
time_str = parts[5] if len(parts) > 5 else ""
|
|
146
|
+
nodelist_raw = parts[7] if len(parts) > 7 else ""
|
|
147
|
+
# Split nodelist on commas or whitespace, filter empties
|
|
148
|
+
nodes = [n for n in re.split(r"[,\s]+", nodelist_raw) if n]
|
|
149
|
+
|
|
150
|
+
slurm_info = SlurmInfo(
|
|
151
|
+
node_registry=task_id,
|
|
152
|
+
resource=resource,
|
|
153
|
+
job_id=job_id,
|
|
154
|
+
updated=datetime.now(),
|
|
155
|
+
name=name,
|
|
156
|
+
user=user,
|
|
157
|
+
code=code,
|
|
158
|
+
time=time_str,
|
|
159
|
+
nodes=nodes,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return slurm_info
|
|
163
|
+
else:
|
|
164
|
+
# after a while slurm will stop returning info for jobs that are no longer running
|
|
165
|
+
# logger.error(f"Failed to get info for job {job_id}: {result.stderr}")
|
|
166
|
+
return None
|
|
167
|
+
except Exception as e:
|
|
168
|
+
logger.exception(f"Error getting job info for {job_id}: {str(e)}")
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def clean_slurm_info(user: str, resource: Resource):
|
|
173
|
+
"""Clean up old slurm info entries"""
|
|
174
|
+
try:
|
|
175
|
+
if context.config.docker:
|
|
176
|
+
watchdog_id = f"slurm_{uuid.uuid4()}"
|
|
177
|
+
queue_dir = context.config.workdir / "queue"
|
|
178
|
+
result = submit_to_watchdog(
|
|
179
|
+
f"squeue -u {user}", watchdog_id, queue_dir=queue_dir
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
result = subprocess.run(
|
|
183
|
+
f"squeue -u {user}",
|
|
184
|
+
shell=True,
|
|
185
|
+
capture_output=True,
|
|
186
|
+
text=True,
|
|
187
|
+
timeout=30,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if result.returncode == 0:
|
|
191
|
+
job_ids = [line.split()[0] for line in result.stdout.splitlines()]
|
|
192
|
+
# logger.info(f"Cleaning up slurm info for {resource}: {result.stdout}")
|
|
193
|
+
|
|
194
|
+
# Get list of running job IDs, skip the header line
|
|
195
|
+
active_jobs = job_ids[1:] if len(job_ids) > 1 else []
|
|
196
|
+
active_job_ids = set()
|
|
197
|
+
for line in active_jobs:
|
|
198
|
+
job_id = line.split()[0]
|
|
199
|
+
active_job_ids.add(job_id)
|
|
200
|
+
# Find all SLURM info entries for this resource
|
|
201
|
+
running_jobs = await context.db.engine.find(
|
|
202
|
+
SlurmInfo, SlurmInfo.resource == resource
|
|
203
|
+
)
|
|
204
|
+
# logger.info(f"Found {running_jobs} slurm info entries for {resource}")
|
|
205
|
+
# logger.info(f"Active job IDs: {active_job_ids}")
|
|
206
|
+
# Delete entries for jobs that are no longer running
|
|
207
|
+
for job in running_jobs:
|
|
208
|
+
if job.job_id not in active_job_ids:
|
|
209
|
+
await context.db.delete(job)
|
|
210
|
+
logger.info(f"Deleted SLURM info for completed job {job.job_id}")
|
|
211
|
+
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.exception(f"Error cleaning slurm info for {resource}: {str(e)}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def run_nodes_for_resource(
|
|
217
|
+
resource_name, polling_interval=5, restart_minutes=None, max_concurrent=10
|
|
218
|
+
) -> str:
|
|
219
|
+
"""Continuously poll for and run nodes assigned to a specific resource"""
|
|
220
|
+
pid = os.getpid()
|
|
221
|
+
username = os.environ.get("USER", os.environ.get("USERNAME", "unknown"))
|
|
222
|
+
hostname = socket.gethostname()
|
|
223
|
+
resource = Resource(value=resource_name)
|
|
224
|
+
logger.info(
|
|
225
|
+
f"Starting node runner for resource: {resource_name} by user: {username}"
|
|
226
|
+
)
|
|
227
|
+
runner_event = RunnerEvent(
|
|
228
|
+
runner_type=RunnerType.RESOURCE_RUNNER,
|
|
229
|
+
event=RunnerEventEnum.RUNNER_STARTED,
|
|
230
|
+
resource=resource,
|
|
231
|
+
user=username,
|
|
232
|
+
hostname=hostname,
|
|
233
|
+
pid=pid,
|
|
234
|
+
git_status=make_git_list(),
|
|
235
|
+
)
|
|
236
|
+
await context.db.save(runner_event)
|
|
237
|
+
|
|
238
|
+
# Setup restart timer if needed
|
|
239
|
+
# if restart_minutes:
|
|
240
|
+
# restart_task = asyncio.create_task(schedule_restart(resource_name, restart_minutes))
|
|
241
|
+
|
|
242
|
+
# Create a semaphore to limit concurrency
|
|
243
|
+
semaphore = asyncio.Semaphore(max_concurrent)
|
|
244
|
+
running_tasks = set()
|
|
245
|
+
|
|
246
|
+
async def run_node_with_semaphore(registry_entry):
|
|
247
|
+
async with semaphore:
|
|
248
|
+
return await run_node(registry_entry)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
count = 0
|
|
252
|
+
while True:
|
|
253
|
+
try:
|
|
254
|
+
# Clean up the completed tasks
|
|
255
|
+
completed_tasks = {task for task in running_tasks if task.done()}
|
|
256
|
+
for task in completed_tasks:
|
|
257
|
+
try:
|
|
258
|
+
await task
|
|
259
|
+
except Exception as e:
|
|
260
|
+
logger.exception(f"Task completed with error: {e}")
|
|
261
|
+
running_tasks.remove(task)
|
|
262
|
+
|
|
263
|
+
# Check for STOP file in python path
|
|
264
|
+
for path in context.config.python_path:
|
|
265
|
+
stop_file = Path(path) / "STOP"
|
|
266
|
+
if stop_file.exists():
|
|
267
|
+
runner_event = RunnerEvent(
|
|
268
|
+
runner_type=RunnerType.RESOURCE_RUNNER,
|
|
269
|
+
event=RunnerEventEnum.SHUTDOWN,
|
|
270
|
+
hostname=hostname,
|
|
271
|
+
user=username,
|
|
272
|
+
pid=pid,
|
|
273
|
+
resource=resource,
|
|
274
|
+
message="STOP FILE FOUND",
|
|
275
|
+
)
|
|
276
|
+
await context.db.save(runner_event)
|
|
277
|
+
logger.info(f"STOP file found at {stop_file}, exiting...")
|
|
278
|
+
return "stop"
|
|
279
|
+
|
|
280
|
+
# Load tasks that are waiting for this resource
|
|
281
|
+
registry_entry_list = await context.db.load_waiting_tasks_for_resource(
|
|
282
|
+
resource_name
|
|
283
|
+
)
|
|
284
|
+
if registry_entry_list:
|
|
285
|
+
logger.info(
|
|
286
|
+
f"Retrieved {len(registry_entry_list)} tasks for resource {resource_name}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
for registry_entry in registry_entry_list:
|
|
290
|
+
logger.info(
|
|
291
|
+
f"Task task_id: {registry_entry.id} will be run on {resource_name}"
|
|
292
|
+
)
|
|
293
|
+
# Create the task with semaphore - don't await
|
|
294
|
+
task = asyncio.create_task(
|
|
295
|
+
run_node_with_semaphore(registry_entry)
|
|
296
|
+
)
|
|
297
|
+
running_tasks.add(task)
|
|
298
|
+
|
|
299
|
+
runner_event = await context.db.find_one(
|
|
300
|
+
RunnerEvent,
|
|
301
|
+
RunnerEvent.runner_type == RunnerType.RESOURCE_RUNNER
|
|
302
|
+
and RunnerEvent.resource == resource
|
|
303
|
+
and RunnerEvent.event == RunnerEventEnum.ALIVE
|
|
304
|
+
and RunnerEvent.pid == pid,
|
|
305
|
+
)
|
|
306
|
+
message = f"{count*polling_interval:6d}s"
|
|
307
|
+
if runner_event:
|
|
308
|
+
runner_event.message = message
|
|
309
|
+
runner_event.timestamp = datetime.now()
|
|
310
|
+
runner_event.event = RunnerEventEnum.ALIVE
|
|
311
|
+
runner_event.git_status = make_git_list()
|
|
312
|
+
|
|
313
|
+
else:
|
|
314
|
+
runner_event = RunnerEvent(
|
|
315
|
+
runner_type=RunnerType.RESOURCE_RUNNER,
|
|
316
|
+
event=RunnerEventEnum.ALIVE,
|
|
317
|
+
resource=resource,
|
|
318
|
+
user=username,
|
|
319
|
+
hostname=hostname,
|
|
320
|
+
pid=pid,
|
|
321
|
+
message=message,
|
|
322
|
+
git_status=make_git_list(),
|
|
323
|
+
)
|
|
324
|
+
await context.db.save(runner_event)
|
|
325
|
+
|
|
326
|
+
running_jobs = await context.db.engine.find(
|
|
327
|
+
NodeRegistry,
|
|
328
|
+
(NodeRegistry.status == TaskStatus.RUNNING)
|
|
329
|
+
& (NodeRegistry.parameters.resource == resource),
|
|
330
|
+
)
|
|
331
|
+
for job in running_jobs:
|
|
332
|
+
if job.job_id is not None:
|
|
333
|
+
slurm_info = get_job_info(
|
|
334
|
+
job.job_id, job.id, Resource(value=resource_name)
|
|
335
|
+
)
|
|
336
|
+
slurm_entry = await context.db.find_one(
|
|
337
|
+
SlurmInfo, SlurmInfo.job_id == job.job_id
|
|
338
|
+
)
|
|
339
|
+
logger.info(
|
|
340
|
+
f"Found running job {job} with slurm info: {slurm_info} {slurm_entry is not None}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
if slurm_info:
|
|
344
|
+
if slurm_entry:
|
|
345
|
+
slurm_entry.code = slurm_info.code
|
|
346
|
+
slurm_entry.time = slurm_info.time
|
|
347
|
+
slurm_entry.updated = datetime.now()
|
|
348
|
+
await context.db.save(slurm_entry)
|
|
349
|
+
else:
|
|
350
|
+
await context.db.save(slurm_info)
|
|
351
|
+
else:
|
|
352
|
+
# if the job had started but is no longer running, delete the entry from the database
|
|
353
|
+
await asyncio.sleep(polling_interval)
|
|
354
|
+
check_job = await context.db.engine.find_one(
|
|
355
|
+
NodeRegistry, NodeRegistry.id == job.id
|
|
356
|
+
)
|
|
357
|
+
logger.warning(
|
|
358
|
+
f"Job task_id: {job.id} {job.id} is no longer running {check_job.status}"
|
|
359
|
+
)
|
|
360
|
+
if slurm_entry:
|
|
361
|
+
await context.db.delete(slurm_entry)
|
|
362
|
+
if slurm_entry and check_job.status == TaskStatus.RUNNING:
|
|
363
|
+
job.job_id = None
|
|
364
|
+
job.status = TaskStatus.TIME_OUT
|
|
365
|
+
await context.db.save(job)
|
|
366
|
+
|
|
367
|
+
await clean_slurm_info(username, resource)
|
|
368
|
+
|
|
369
|
+
await asyncio.sleep(polling_interval)
|
|
370
|
+
count = count + 1
|
|
371
|
+
if restart_minutes and count > restart_minutes:
|
|
372
|
+
runner_event = RunnerEvent(
|
|
373
|
+
runner_type=RunnerType.RESOURCE_RUNNER,
|
|
374
|
+
event=RunnerEventEnum.RESTART,
|
|
375
|
+
user=username,
|
|
376
|
+
hostname=hostname,
|
|
377
|
+
pid=pid,
|
|
378
|
+
resource=Resource(value=resource_name),
|
|
379
|
+
message="restart by count",
|
|
380
|
+
git_status=make_git_list(),
|
|
381
|
+
)
|
|
382
|
+
await context.db.save(runner_event)
|
|
383
|
+
return "restart"
|
|
384
|
+
except Exception as e:
|
|
385
|
+
logger.exception(f"Error in run_nodes_for_resource: {str(e)}")
|
|
386
|
+
if restart_minutes and count > restart_minutes:
|
|
387
|
+
return "restart"
|
|
388
|
+
await asyncio.sleep(polling_interval)
|
|
389
|
+
except asyncio.CancelledError:
|
|
390
|
+
logger.info("Node runner task was cancelled")
|
|
391
|
+
raise
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
async def async_main(args):
|
|
395
|
+
"""Async entry point"""
|
|
396
|
+
context.initialize(resource=args.resource, db_name=args.db_name)
|
|
397
|
+
|
|
398
|
+
if args.resource:
|
|
399
|
+
context.config.resource = Resource(value=args.resource)
|
|
400
|
+
logger.info(f"Setting resource for runner to {args.resource}")
|
|
401
|
+
await run_nodes_for_resource(args.resource, args.polling_interval, None)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def runner_main():
|
|
405
|
+
parser = argparse.ArgumentParser(description="Run nodes for a specific resource")
|
|
406
|
+
parser.add_argument(
|
|
407
|
+
"--resource",
|
|
408
|
+
type=str,
|
|
409
|
+
default="local",
|
|
410
|
+
help="Resource name to process tasks for",
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
parser.add_argument(
|
|
414
|
+
"--db-name",
|
|
415
|
+
type=str,
|
|
416
|
+
help="Specify a non-standard database",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
parser.add_argument(
|
|
420
|
+
"--polling-interval",
|
|
421
|
+
type=int,
|
|
422
|
+
default=20,
|
|
423
|
+
help="Interval in seconds between polling for new tasks",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
args = parser.parse_args()
|
|
427
|
+
|
|
428
|
+
# Run the async main function
|
|
429
|
+
asyncio.run(async_main(args))
|
|
430
|
+
pid = os.getpid()
|
|
431
|
+
logger.info(f"runner with pid {pid} shutting down normally")
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if __name__ == "__main__":
|
|
435
|
+
runner_main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Optional, List
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from simstack.core.definitions import TaskStatus
|
|
6
|
+
from simstack.models.files import FileStack
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class SimstackResult(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
Represents the results from a Simstack operation.
|
|
12
|
+
|
|
13
|
+
This class serves to encapsulate the status, messages, and files resulting
|
|
14
|
+
from a Simstack operation. It provides structured attributes for the error
|
|
15
|
+
message, operation message, and categorized files, ensuring clarity in the
|
|
16
|
+
representation of these aspects. It also supports additional fields through
|
|
17
|
+
flexible configuration.
|
|
18
|
+
|
|
19
|
+
:ivar status: Indicates the status of the task, defaulting to `TaskStatus.COMPLETED`.
|
|
20
|
+
:type status: TaskStatus
|
|
21
|
+
:ivar error_message: Optionally stores an error message if the task encountered issues.
|
|
22
|
+
:type error_message: Optional[str]
|
|
23
|
+
:ivar message: An optional general message providing additional information about the task.
|
|
24
|
+
:type message: Optional[str]
|
|
25
|
+
:ivar files: A list of `FileStack` objects that represent the primary files involved in
|
|
26
|
+
the operation. Defaults to an empty list.
|
|
27
|
+
:type files: List[FileStack]
|
|
28
|
+
:ivar info_files: A list of `FileStack` objects that provide additional or informational
|
|
29
|
+
files pertaining to the operation. Defaults to an empty list. These files are shown in the results
|
|
30
|
+
but not passed to the calling function
|
|
31
|
+
:type info_files: List[FileStack]
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
status: TaskStatus = TaskStatus.COMPLETED
|
|
35
|
+
error_message: Optional[str] = None
|
|
36
|
+
message: Optional[str] = None
|
|
37
|
+
files: List[FileStack] = Field(default_factory=list)
|
|
38
|
+
info_files: List[FileStack] = Field(default_factory=list)
|
|
39
|
+
|
|
40
|
+
class Config:
|
|
41
|
+
extra = "allow" # Allow extra fields without validation
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import stat
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from simstack.core.context import context
|
|
7
|
+
from simstack.core.definitions import TaskStatus
|
|
8
|
+
from simstack.models import NodeRegistry
|
|
9
|
+
from simstack.util.project_root_finder import find_project_root
|
|
10
|
+
from simstack.util.submit_to_watchdog import submit_to_watchdog
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("submit_node")
|
|
14
|
+
|
|
15
|
+
def make_executable(file_path):
|
|
16
|
+
# Get current permissions
|
|
17
|
+
current_permissions = os.stat(file_path).st_mode
|
|
18
|
+
|
|
19
|
+
# Add executable bit for user, group and others
|
|
20
|
+
executable_mode = current_permissions | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
|
|
21
|
+
|
|
22
|
+
# Apply new permissions
|
|
23
|
+
os.chmod(file_path, executable_mode)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def submit_node(registry_entry: NodeRegistry):
|
|
27
|
+
"""Submit a node to the SLURM queue"""
|
|
28
|
+
task_id = registry_entry.id
|
|
29
|
+
try:
|
|
30
|
+
logger.info(f"Submitting task_id: {task_id} to SLURM queue")
|
|
31
|
+
# Implement SLURM submission logic here
|
|
32
|
+
base_path = find_project_root()
|
|
33
|
+
|
|
34
|
+
python_path = ":".join(context.config.python_path)
|
|
35
|
+
work_dir = os.path.join(context.config.workdir, registry_entry.name, str(registry_entry.id))
|
|
36
|
+
job_name = registry_entry.name + "." + str(registry_entry.id)
|
|
37
|
+
|
|
38
|
+
logger.info(f"task_id: {task_id} workdir {work_dir} python path {python_path}")
|
|
39
|
+
# write a slurm script that starts a python script "run_node.py" with the node id as an argument
|
|
40
|
+
|
|
41
|
+
slurm_parameters = registry_entry.parameters.slurm_parameters
|
|
42
|
+
if slurm_parameters is None:
|
|
43
|
+
logger.error("Task task_id: {task_id} has no slurm parameters -- failing")
|
|
44
|
+
registry_entry.status = TaskStatus.FAILED
|
|
45
|
+
await context.db.save(registry_entry)
|
|
46
|
+
return
|
|
47
|
+
slurm_parameters.output = f"{work_dir}/%j.out"
|
|
48
|
+
slurm_parameters.error = f"{work_dir}/%j.err"
|
|
49
|
+
slurm_parameters.job_name = f"{job_name}"
|
|
50
|
+
slurm_parameters.chdir = work_dir
|
|
51
|
+
|
|
52
|
+
slurm_parameters.startup_commands.append("source ~/.bashrc")
|
|
53
|
+
slurm_parameters.startup_commands.append(f"{context.config.environment_start}")
|
|
54
|
+
slurm_parameters.startup_commands.append(f"export PYTHONPATH={python_path}:$PYTHONPATH")
|
|
55
|
+
|
|
56
|
+
if context.config.docker:
|
|
57
|
+
external_work_dir = context.config.external_workdir / registry_entry.name / str(registry_entry.id)
|
|
58
|
+
|
|
59
|
+
watcher_file = find_project_root() / "src" / "simstack" / "util" / "queue_watcher.py"
|
|
60
|
+
with open(watcher_file, 'r') as f:
|
|
61
|
+
watcher_content = f.read()
|
|
62
|
+
slurm_parameters.startup_commands.append(f"cat > watcher.py <<'EOF'\n{watcher_content}\nEOF")
|
|
63
|
+
slurm_parameters.startup_commands.append(f"python watcher.py &")
|
|
64
|
+
slurm_parameters.startup_commands.append(f"WATCHER_PID=$!")
|
|
65
|
+
|
|
66
|
+
slurm_parameters.startup_commands.append("set -euo pipefail")
|
|
67
|
+
slurm_parameters.startup_commands.append(
|
|
68
|
+
'cleanup() { '
|
|
69
|
+
'if [ -n "${WATCHER_PID:-}" ] && kill -0 "$WATCHER_PID" 2>/dev/null; then '
|
|
70
|
+
' kill "$WATCHER_PID" 2>/dev/null || true; '
|
|
71
|
+
' sleep 2; '
|
|
72
|
+
' kill -9 "$WATCHER_PID" 2>/dev/null || true; '
|
|
73
|
+
'fi; '
|
|
74
|
+
'}'
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
slurm_parameters.startup_commands.append("trap cleanup EXIT INT TERM")
|
|
78
|
+
toml_path = find_project_root() / "simstack_docker.toml"
|
|
79
|
+
if not toml_path.exists():
|
|
80
|
+
logger.error(f"Task task_id: {task_id} has no simstack.toml file -- failing")
|
|
81
|
+
registry_entry.status = TaskStatus.FAILED
|
|
82
|
+
await context.db.save(registry_entry)
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
docker_start = "udocker run "
|
|
86
|
+
docker_start += "-e GIT_TOKEN=XXX"
|
|
87
|
+
docker_start += f"-e NODE_ID={registry_entry.id} "
|
|
88
|
+
docker_start += f"-e RESOURCE={str(context.config.resource)} "
|
|
89
|
+
docker_start += f"-v {external_work_dir}:/home/appuser/simstack "
|
|
90
|
+
docker_start += f"-v {context.config.external_source_dir}:/app/simstack-model "
|
|
91
|
+
docker_start += f"-v {toml_path}:/app/simstack-model/simstack.toml "
|
|
92
|
+
docker_start += " simstack-runner"
|
|
93
|
+
slurm_parameters.startup_commands.append(docker_start)
|
|
94
|
+
else:
|
|
95
|
+
slurm_parameters.startup_commands.append(
|
|
96
|
+
f"uv run --directory {base_path} run_node --node-id {registry_entry.id}")
|
|
97
|
+
|
|
98
|
+
slurm_script = slurm_parameters.to_sbatch_header()
|
|
99
|
+
|
|
100
|
+
# write the script to a file in the work_dir
|
|
101
|
+
os.makedirs(work_dir, exist_ok=True)
|
|
102
|
+
logger.info(f"task_id: {task_id} workdir {work_dir} python path {python_path}")
|
|
103
|
+
script_path = os.path.join(work_dir, "slurm_script.sh")
|
|
104
|
+
with open(script_path, "w") as f:
|
|
105
|
+
f.write(slurm_script)
|
|
106
|
+
|
|
107
|
+
make_executable(script_path)
|
|
108
|
+
# submit the script to the slurm queue
|
|
109
|
+
if context.config.docker:
|
|
110
|
+
job_id = "slurm_" + str(registry_entry.id)
|
|
111
|
+
queue_dir = context.config.workdir / "queue"
|
|
112
|
+
result = submit_to_watchdog(f"/usr/bin/sbatch {os.path.join(external_work_dir, 'slurm_script.sh')}", job_id, queue_dir)
|
|
113
|
+
else:
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
f"/usr/bin/sbatch {os.path.join(work_dir, 'slurm_script.sh')}",
|
|
116
|
+
shell=True,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
timeout=30 # Add timeout to prevent hanging
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
logger.info(f"submitting job task_id: {task_id} returns: {result.returncode} {result.stdout} {result.stderr}")
|
|
123
|
+
if result.returncode == 0:
|
|
124
|
+
# Extract job ID using a regex pattern
|
|
125
|
+
match = re.search(r'Submitted batch job (\d+)', result.stdout)
|
|
126
|
+
if match:
|
|
127
|
+
job_id = match.group(1)
|
|
128
|
+
logger.info(f"task_id: {task_id} job successfully submitted with job_id: {job_id}")
|
|
129
|
+
registry_entry.job_id = job_id
|
|
130
|
+
else:
|
|
131
|
+
logger.warning(
|
|
132
|
+
f"task_id: {task_id} job submitted but could not extract job_id from output: {result.stdout}")
|
|
133
|
+
else:
|
|
134
|
+
logger.error(
|
|
135
|
+
f"error submitting job for task_id: {task_id} return code: {result.returncode} stdout: {result.stdout}")
|
|
136
|
+
logger.error(f"submitting job for task_id: {task_id} stderr: {result.stderr}")
|
|
137
|
+
registry_entry.status = TaskStatus.FAILED
|
|
138
|
+
await context.db.save(registry_entry)
|
|
139
|
+
return
|
|
140
|
+
registry_entry.status = TaskStatus.SLURM_QUEUED
|
|
141
|
+
await context.db.save(registry_entry)
|
|
142
|
+
except Exception as e:
|
|
143
|
+
logger.exception(f"fatal error in submitting task_id: {task_id} {str(e)}")
|
simstack/core/task_id.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from contextvars import ContextVar
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from bson.objectid import ObjectId
|
|
5
|
+
|
|
6
|
+
# this variable is meant to give access to the task id to user functions that do not have access to it (e.g. for logging)
|
|
7
|
+
_task_id: ContextVar[Optional[ObjectId]] = ContextVar("task_id", default=None)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_task_id() -> Optional[ObjectId]:
|
|
11
|
+
"""Get the current task ID."""
|
|
12
|
+
return _task_id.get()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def set_task_id(task_id: Optional[ObjectId]) -> None:
|
|
16
|
+
"""Set the current task ID."""
|
|
17
|
+
_task_id.set(task_id)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def clear_task_id() -> None:
|
|
21
|
+
"""Clear the current task ID."""
|
|
22
|
+
_task_id.set(None)
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from simstack.core.node import node
|
|
4
|
+
from simstack.models.files import FileStack
|
|
5
|
+
from simstack.models.parameters import Resource
|
|
6
|
+
|
|
7
|
+
@node
|
|
8
|
+
def get_file(source: FileStack, local_resource: Resource, target_path: Path) -> Path:
|
|
9
|
+
raise NotImplementedError("get_file is not implemented yet.")
|
|
10
|
+
return target_path
|