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.
Files changed (91) hide show
  1. simstack/__init__.py +0 -0
  2. simstack/_version.py +34 -0
  3. simstack/core/__init__.py +0 -0
  4. simstack/core/artifacts.py +447 -0
  5. simstack/core/asnyc_helper.py +14 -0
  6. simstack/core/config_file.py +60 -0
  7. simstack/core/context.py +167 -0
  8. simstack/core/data.py +139 -0
  9. simstack/core/definitions.py +21 -0
  10. simstack/core/engine.py +116 -0
  11. simstack/core/find_simstack_modules.py +43 -0
  12. simstack/core/hash.py +177 -0
  13. simstack/core/model_table.py +199 -0
  14. simstack/core/node.py +911 -0
  15. simstack/core/node_runner.py +254 -0
  16. simstack/core/node_table.py +380 -0
  17. simstack/core/recompute_artifacts.py +120 -0
  18. simstack/core/reset_db.py +12 -0
  19. simstack/core/resources.py +101 -0
  20. simstack/core/run_node.py +69 -0
  21. simstack/core/runner.py +435 -0
  22. simstack/core/simstack_result.py +41 -0
  23. simstack/core/submit_node.py +143 -0
  24. simstack/core/task_id.py +22 -0
  25. simstack/data/__init__.py +0 -0
  26. simstack/methods/__init__.py +0 -0
  27. simstack/methods/get_file.py +10 -0
  28. simstack/methods/multiple_executor.py +307 -0
  29. simstack/models/__init__.py +37 -0
  30. simstack/models/array_list.py +48 -0
  31. simstack/models/array_storage.py +54 -0
  32. simstack/models/artifact_models.py +127 -0
  33. simstack/models/base_types.py +208 -0
  34. simstack/models/charts_artifact.py +411 -0
  35. simstack/models/dataset.py +432 -0
  36. simstack/models/dataset_metadata.py +303 -0
  37. simstack/models/file_instance.py +77 -0
  38. simstack/models/file_list.py +321 -0
  39. simstack/models/files.py +229 -0
  40. simstack/models/log_entry_model.py +37 -0
  41. simstack/models/models.py +40 -0
  42. simstack/models/node_registry.py +105 -0
  43. simstack/models/pandas_model.py +210 -0
  44. simstack/models/parameters.py +388 -0
  45. simstack/models/pickle_models.py +311 -0
  46. simstack/models/runner_model.py +39 -0
  47. simstack/models/simple_table.py +32 -0
  48. simstack/models/simstack_model.py +97 -0
  49. simstack/models/slurm_info.py +17 -0
  50. simstack/models/table_artifact.py +87 -0
  51. simstack/util/__init__.py +0 -0
  52. simstack/util/async_zip_utils.py +18 -0
  53. simstack/util/cleaned_json_schema.py +20 -0
  54. simstack/util/config_reader.py +273 -0
  55. simstack/util/custom_model_dump.py +75 -0
  56. simstack/util/db.py +457 -0
  57. simstack/util/db_logger.py +117 -0
  58. simstack/util/default_from_dict.py +57 -0
  59. simstack/util/directory_iterator.py +216 -0
  60. simstack/util/file_hashing.py +68 -0
  61. simstack/util/files.py +396 -0
  62. simstack/util/find_python_files.py +33 -0
  63. simstack/util/generate_ui_schema.py +80 -0
  64. simstack/util/geometry_utils.py +118 -0
  65. simstack/util/get_module_path.py +26 -0
  66. simstack/util/git_pull.py +65 -0
  67. simstack/util/git_repository_status.py +93 -0
  68. simstack/util/import_module.py +65 -0
  69. simstack/util/importer.py +163 -0
  70. simstack/util/make_graph.py +107 -0
  71. simstack/util/make_table.py +827 -0
  72. simstack/util/minimal_route_finder.py +72 -0
  73. simstack/util/mock_db.py +144 -0
  74. simstack/util/module_path_checker.py +38 -0
  75. simstack/util/path_manager.py +234 -0
  76. simstack/util/project_root_finder.py +36 -0
  77. simstack/util/queue_watcher.py +262 -0
  78. simstack/util/route_finder.py +74 -0
  79. simstack/util/runner_util.py +162 -0
  80. simstack/util/safe_code_executor.py +194 -0
  81. simstack/util/sample_config.py +98 -0
  82. simstack/util/sensitive_content_filter.py +48 -0
  83. simstack/util/setup_logging.py +49 -0
  84. simstack/util/submit_to_watchdog.py +106 -0
  85. simstack/util/ui_tools.py +213 -0
  86. simstack-0.2.0.dist-info/METADATA +361 -0
  87. simstack-0.2.0.dist-info/RECORD +91 -0
  88. simstack-0.2.0.dist-info/WHEEL +5 -0
  89. simstack-0.2.0.dist-info/entry_points.txt +10 -0
  90. simstack-0.2.0.dist-info/licenses/LICENSE +21 -0
  91. simstack-0.2.0.dist-info/top_level.txt +1 -0
@@ -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)}")
@@ -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