Repository: watts-dev/watts (language: python)

Issue:
Crash with asynchronous WATTS run
Running asynchronous WATTS calculations was found potentially lead to crash potentially due to conflicts in the database. Path forward should be:

- Make the database names more robust to avoid such conflicts from occurring
- Alternatively, provide examples where the name of the database is user-selected

```
Traceback (most recent call last):
  File "watts_workflow.py", line 261, in <module>
    pyarc_result1 = pyarc_plugin1(params)
  File "/home/nstauff/watts/src/watts/plugin.py", line 86, in __call__
    workflow_path.mkdir()
  File "/software/openmc/miniconda3/envs/omc_v0.12.2/lib/python3.8/pathlib.py", line 1288, in mkdir
    self._accessor.mkdir(self, mode)
FileExistsError: [Errno 17] File exists: '/home/nstauff/.local/share/watts/Workflow_647'
Traceback (most recent call last):
  File "dakota_driver.py", line 81, in <module>
    r.function = retval['fns'][i]
IndexError: list index out of range
```


Discussion / hints:

Fix patch:
diff --git a/src/watts/plugin.py b/src/watts/plugin.py
index 7231bf79..b659a598 100644
--- a/src/watts/plugin.py
+++ b/src/watts/plugin.py
@@ -3,6 +3,7 @@
 
 from abc import ABC, abstractmethod
 from datetime import datetime
+import uuid
 from pathlib import Path
 import shutil
 from typing import Optional, List
@@ -40,19 +41,6 @@ def run(self):
     def postrun(self, params) -> Results:
         ...
 
-    @staticmethod
-    def _get_unique_dir(path: Path, name: str) -> Path:
-        if not (path / name).exists():
-            return path / name
-
-        # Try adding number as suffix
-        i = 1
-        while True:
-            unique_name = f"{name}_{i}"
-            if not (path / unique_name).exists():
-                return path / unique_name
-            i += 1
-
     def __call__(self, params: Parameters, name: str = 'Workflow', **kwargs) -> Results:
         """Run the complete workflow for the plugin
 
@@ -61,7 +49,7 @@ def __call__(self, params: Parameters, name: str = 'Workflow', **kwargs) -> Resu
         params
             Parameters used in generating inputs
         name
-            Unique name for workflow
+            Name for workflow
         **kwargs
             Keyword arguments passed to the `run` method
 
@@ -80,10 +68,10 @@ def __call__(self, params: Parameters, name: str = 'Workflow', **kwargs) -> Resu
             # Run workflow in temporary directory
             self.prerun(params)
             self.run(**kwargs)
-            result = self.postrun(params)
+            result = self.postrun(params, name)
 
             # Create new directory for results and move files there
-            workflow_path = self._get_unique_dir(db.path, name)
+            workflow_path = db.path / uuid.uuid4().hex
             workflow_path.mkdir()
             try:
                 result.move_files(workflow_path)
diff --git a/src/watts/plugin_moose.py b/src/watts/plugin_moose.py
index 7659cf07..12706d6f 100644
--- a/src/watts/plugin_moose.py
+++ b/src/watts/plugin_moose.py
@@ -24,6 +24,8 @@ class ResultsMOOSE(Results):
     ----------
     params
         Parameters used to generate inputs
+    name
+        Name of workflow producing results
     time
         Time at which workflow was run
     inputs
@@ -38,9 +40,9 @@ class ResultsMOOSE(Results):
     csv_data
         Dictionary with data from .csv files
     """
-    def __init__(self, params: Parameters, time: datetime,
+    def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[PathLike], outputs: List[PathLike]):
-        super().__init__('MOOSE', params, time, inputs, outputs)
+        super().__init__('MOOSE', params, name, time, inputs, outputs)
         self.csv_data = self._save_MOOSE_csv()
 
     @property
@@ -171,13 +173,15 @@ def run(self):
                 run_proc(["mpiexec", "-n", str(self.n_cpu), self.moose_exec,
                           "-i", self.moose_inp_name])
 
-    def postrun(self, params: Parameters) -> ResultsMOOSE:
+    def postrun(self, params: Parameters, name: str) -> ResultsMOOSE:
         """Read MOOSE results and create results object
 
         Parameters
         ----------
         params
             Parameters used to create MOOSE model
+        name
+            Name of the workflow
 
         Returns
         -------
@@ -186,4 +190,4 @@ def postrun(self, params: Parameters) -> ResultsMOOSE:
         print("Post-run for MOOSE Plugin")
 
         time, inputs, outputs = self._get_result_input(self.moose_inp_name)
-        return ResultsMOOSE(params, time, inputs, outputs)
+        return ResultsMOOSE(params, name, time, inputs, outputs)
diff --git a/src/watts/plugin_openmc.py b/src/watts/plugin_openmc.py
index 9f406d18..ad69473d 100644
--- a/src/watts/plugin_openmc.py
+++ b/src/watts/plugin_openmc.py
@@ -21,6 +21,8 @@ class ResultsOpenMC(Results):
     ----------
     params
         Parameters used to generate inputs
+    name
+        Name of workflow producing results
     time
         Time at which workflow was run
     inputs
@@ -40,9 +42,9 @@ class ResultsOpenMC(Results):
         List of OpenMC tally objects
     """
 
-    def __init__(self, params: Parameters, time: datetime,
+    def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[Path], outputs: List[Path]):
-        super().__init__('OpenMC', params, time, inputs, outputs)
+        super().__init__('OpenMC', params, name, time, inputs, outputs)
 
     @property
     def statepoints(self) -> List[Path]:
@@ -127,13 +129,15 @@ def run(self, **kwargs: Mapping):
             with func_stdout(f), func_stderr(f):
                 openmc.run(**kwargs)
 
-    def postrun(self, params: Parameters) -> ResultsOpenMC:
+    def postrun(self, params: Parameters, name: str) -> ResultsOpenMC:
         """Collect information from OpenMC simulation and create results object
 
         Parameters
         ----------
         params
             Parameters used to create OpenMC model
+        name
+            Name of the workflow
 
         Returns
         -------
@@ -168,4 +172,4 @@ def files_since(pattern, time):
         outputs.extend(files_since('statepoint.*.h5', self._run_time))
 
         time = datetime.fromtimestamp(self._run_time * 1e-9)
-        return ResultsOpenMC(params, time, inputs, outputs)
+        return ResultsOpenMC(params, name, time, inputs, outputs)
diff --git a/src/watts/plugin_pyarc.py b/src/watts/plugin_pyarc.py
index f3f2bc6d..5329746e 100644
--- a/src/watts/plugin_pyarc.py
+++ b/src/watts/plugin_pyarc.py
@@ -23,6 +23,8 @@ class ResultsPyARC(Results):
     ----------
     params
         Parameters used to generate inputs
+    name
+        Name of workflow producing results
     time
         Time at which workflow was run
     inputs
@@ -34,9 +36,9 @@ class ResultsPyARC(Results):
 
     """
 
-    def __init__(self, params: Parameters, time: datetime,
+    def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[Path], outputs: List[Path], results_data: dict):
-        super().__init__('PyARC', params, time, inputs, outputs)
+        super().__init__('PyARC', params, name, time, inputs, outputs)
         self.results_data = results_data
 
     @property
@@ -130,13 +132,15 @@ def run(self, **kwargs: Mapping):
         sys.path.pop(0)  # Restore sys.path to original state
         os.chdir(od)  # TODO: I don't know why but I keep going to self._pyarc_exec after execution - this is very wierd!
 
-    def postrun(self, params: Parameters) -> ResultsPyARC:
+    def postrun(self, params: Parameters, name: str) -> ResultsPyARC:
         """Collect information from PyARC and create results object
 
         Parameters
         ----------
         params
             Parameters used to create PyARC model
+        name
+            Name of the workflow
 
         Returns
         -------
@@ -145,4 +149,4 @@ def postrun(self, params: Parameters) -> ResultsPyARC:
         print("Post-run for PyARC Plugin")
 
         time, inputs, outputs = self._get_result_input(self.pyarc_inp_name)
-        return ResultsPyARC(params, time, inputs, outputs, self.pyarc.user_object.results)
+        return ResultsPyARC(params, name, time, inputs, outputs, self.pyarc.user_object.results)
diff --git a/src/watts/plugin_sas.py b/src/watts/plugin_sas.py
index 12617bf8..58a0bcfe 100644
--- a/src/watts/plugin_sas.py
+++ b/src/watts/plugin_sas.py
@@ -28,6 +28,8 @@ class ResultsSAS(Results):
     ----------
     params
         Parameters used to generate inputs
+    name
+        Name of workflow producing results
     time
         Time at which workflow was run
     inputs
@@ -42,9 +44,9 @@ class ResultsSAS(Results):
     csv_data
         Dictionary with data from .csv files
     """
-    def __init__(self, params: Parameters, time: datetime,
+    def __init__(self, params: Parameters, name: str, time: datetime,
                  inputs: List[PathLike], outputs: List[PathLike]):
-        super().__init__('SAS', params, time, inputs, outputs)
+        super().__init__('SAS', params, name, time, inputs, outputs)
         self.csv_data = self._get_sas_csv_data()
 
     @property
@@ -172,13 +174,15 @@ def run(self):
             with func_stdout(outfile), func_stderr(outfile):
                 run_proc([self.sas_exec, "-i", self.sas_inp_name, "-o", "out.txt"])
 
-    def postrun(self, params: Parameters) -> ResultsSAS:
+    def postrun(self, params: Parameters, name: str) -> ResultsSAS:
         """Read SAS results and create results object
 
         Parameters
         ----------
         params
             Parameters used to create SAS model
+        name
+            Name of the workflow
 
         Returnss
         -------
@@ -198,4 +202,4 @@ def postrun(self, params: Parameters) -> ResultsSAS:
                 subprocess.run(str(self.conv_primar4), stdin=file_in, stdout=file_out)
 
         time, inputs, outputs = self._get_result_input(self.sas_inp_name)
-        return ResultsSAS(params, time, inputs, outputs)
+        return ResultsSAS(params, name, time, inputs, outputs)
diff --git a/src/watts/results.py b/src/watts/results.py
index 49c365a8..b5d13542 100644
--- a/src/watts/results.py
+++ b/src/watts/results.py
@@ -29,10 +29,11 @@ class Results:
         List of output files
     """
 
-    def __init__(self, plugin: str, params: Parameters, time: datetime,
+    def __init__(self, plugin: str, params: Parameters, name: str, time: datetime,
                  inputs: List[PathLike], outputs: List[PathLike]):
         self.base_path = Path.cwd()
         self.plugin = plugin
+        self.name = name
         self.parameters = Parameters(params)
         self.time = time
         self.inputs = [Path(p) for p in inputs]


Test patch:
diff --git a/tests/test_database.py b/tests/test_database.py
index 7bce97d3..9bd988ff 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -18,6 +18,7 @@ def restore_database_path():
 def get_result():
     return watts.ResultsOpenMC(
         params=watts.Parameters(value=1, lab='Argonne'),
+        name='Workflow',
         time=datetime.now(),
         inputs=['geometry.xml'],
         outputs=['statepoint.50.h5'],
diff --git a/tests/test_plugin_openmc.py b/tests/test_plugin_openmc.py
index 9379e601..968fc02a 100644
--- a/tests/test_plugin_openmc.py
+++ b/tests/test_plugin_openmc.py
@@ -37,11 +37,12 @@ def test_openmc_plugin():
     assert plugin.model_builder == build_openmc_model
 
     params = watts.Parameters(radius=6.38)
-    result = plugin(params)
+    result = plugin(params, name="OpenMC run")
 
     # Sanity checks
     assert isinstance(result, watts.ResultsOpenMC)
     assert result.parameters['radius'] == 6.38
+    assert result.name == "OpenMC run"
     assert len(result.statepoints) == 5
     input_names = {p.name for p in result.inputs}
     assert input_names == {'geometry.xml', 'materials.xml', 'settings.xml', 'tallies.xml'}
@@ -65,6 +66,7 @@ def test_openmc_plugin():
     db = watts.Database()
     last_result = db[-1]
     assert last_result.parameters['radius'] == result.parameters['radius']
+    assert last_result.name == result.name
     assert last_result.inputs == result.inputs
     assert last_result.outputs == result.outputs
     assert last_result.keff.n == result.keff.n
diff --git a/tests/test_results.py b/tests/test_results.py
index 3b44e18f..e5cc0f77 100644
--- a/tests/test_results.py
+++ b/tests/test_results.py
@@ -10,6 +10,7 @@
 
 def test_results_openmc(run_in_tmpdir):
     params = watts.Parameters(city='Chicago', population=2.7e6)
+    name = "🎲"
     now = datetime.now()
 
     # Create some fake input files
@@ -27,11 +28,12 @@ def test_results_openmc(run_in_tmpdir):
     outputs = [sp, log_file]
 
 
-    results = watts.ResultsOpenMC(params, now, inputs, outputs)
+    results = watts.ResultsOpenMC(params, name, now, inputs, outputs)
 
     # Sanity checks
     assert results.plugin == 'OpenMC'
     assert results.parameters == params
+    assert results.name == name
     assert results.time == now
     assert results.inputs == inputs
     assert results.outputs == outputs
@@ -49,6 +51,7 @@ def test_results_openmc(run_in_tmpdir):
     new_results = watts.Results.from_pickle(p)
     assert isinstance(new_results, watts.ResultsOpenMC)
     assert new_results.parameters == results.parameters
+    assert new_results.name == results.name
     assert new_results.time == results.time
     assert new_results.inputs == results.inputs
     assert new_results.outputs == results.outputs
@@ -57,6 +60,7 @@ def test_results_openmc(run_in_tmpdir):
 
 def test_results_moose(run_in_tmpdir):
     params = watts.Parameters(city='Chicago', population=2.7e6)
+    name = "Elk"
     now = datetime.now()
 
     # Create some fake input files
@@ -76,11 +80,12 @@ def test_results_moose(run_in_tmpdir):
     stdout.write_text('MOOSE standard out\n')
     outputs = [csv, stdout]
 
-    results = watts.ResultsMOOSE(params, now, inputs, outputs)
+    results = watts.ResultsMOOSE(params, name, now, inputs, outputs)
 
     # Sanity checks
     assert results.plugin == 'MOOSE'
     assert results.parameters == params
+    assert results.name == name
     assert results.time == now
     assert results.inputs == inputs
     assert results.outputs == outputs
@@ -99,6 +104,7 @@ def test_results_moose(run_in_tmpdir):
     new_results = watts.Results.from_pickle(p)
     assert isinstance(new_results, watts.ResultsMOOSE)
     assert new_results.parameters == results.parameters
+    assert new_results.name == results.name
     assert new_results.time == results.time
     assert new_results.inputs == results.inputs
     assert new_results.outputs == results.outputs
@@ -107,6 +113,7 @@ def test_results_moose(run_in_tmpdir):
 
 def test_results_sas(run_in_tmpdir):
     params = watts.Parameters(city='Chicago', population=2.7e6)
+    name = 'Sassy!'
     now = datetime.now()
 
     # Create some fake input files
@@ -126,11 +133,12 @@ def test_results_sas(run_in_tmpdir):
     stdout.write_text('SAS standard out\n')
     outputs = [csv, stdout]
 
-    results = watts.ResultsSAS(params, now, inputs, outputs)
+    results = watts.ResultsSAS(params, name, now, inputs, outputs)
 
     # Sanity checks
     assert results.plugin == 'SAS'
     assert results.parameters == params
+    assert results.name == name
     assert results.time == now
     assert results.inputs == inputs
     assert results.outputs == outputs
@@ -149,6 +157,7 @@ def test_results_sas(run_in_tmpdir):
     new_results = watts.Results.from_pickle(p)
     assert isinstance(new_results, watts.ResultsSAS)
     assert new_results.parameters == results.parameters
+    assert new_results.name == results.name
     assert new_results.time == results.time
     assert new_results.inputs == results.inputs
     assert new_results.outputs == results.outputs