Skip to content

Commit a6fd672

Browse files
authored
updates to filesystem warehouse worker-based execution (#2153)
* only use protocolunit as key * add specific protocol dag store * add get_protocol_dags * fix protocol dag store deduplication order * type checking and require a name * default task db to use warehouse name * update docstrings * update type hint for python 3.12
1 parent a85734f commit a6fd672

6 files changed

Lines changed: 123 additions & 46 deletions

File tree

src/openfe/orchestration/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,12 +160,12 @@ def _checkout_task(self) -> tuple[TaskStatusDB, str, ProtocolUnit] | None:
160160
"""
161161

162162
db: TaskStatusDB = TaskStatusDB.from_filename(self.task_db_path)
163-
# The format for the taskid is "Transformation-<HASH>:ProtocolUnit-<HASH>"
163+
# The format for the taskid is "ProtocolUnit-<HASH>"
164164
taskid = db.check_out_task()
165165
if taskid is None:
166166
return None
167167

168-
_, protocol_unit_key = taskid.split(":", maxsplit=1)
168+
protocol_unit_key = taskid
169169
unit = self.warehouse.load_task(GufeKey(protocol_unit_key))
170170
return db, taskid, unit
171171

src/openfe/orchestration/exorcist_utils.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import exorcist
1010
import networkx as nx
11+
import pandas as pd
1112
from gufe import AlchemicalNetwork
1213

1314
from openfe.storage.warehouse import WarehouseBaseClass
@@ -43,14 +44,15 @@ def alchemical_network_to_task_graph(
4344
for transformation in alchemical_network.edges:
4445
dag = transformation.create()
4546
for unit in dag.protocol_units:
46-
node_id = f"{str(transformation.key)}:{str(unit.key)}"
47-
global_dag.add_node(
48-
node_id,
49-
)
47+
node_id = str(unit.key)
48+
global_dag.add_node(node_id)
5049
warehouse.store_task(unit)
50+
# store the protocol_dag as a shallow dict, since all its units are
51+
# already written to disk
52+
warehouse.store_protocol_dag(dag)
5153
for dependent_unit, dependency_unit in dag.graph.edges:
52-
upstream_id = f"{str(transformation.key)}:{str(dependency_unit.key)}"
53-
downstream_id = f"{str(transformation.key)}:{str(dependent_unit.key)}"
54+
upstream_id = str(dependency_unit.key)
55+
downstream_id = str(dependent_unit.key)
5456
global_dag.add_edge(upstream_id, downstream_id)
5557

5658
if not nx.is_directed_acyclic_graph(global_dag):
@@ -59,6 +61,7 @@ def alchemical_network_to_task_graph(
5961
return global_dag
6062

6163

64+
# TODO: do we test adding a multiple alchemical networks to the same task graph?
6265
def build_task_db_from_alchemical_network(
6366
alchemical_network: AlchemicalNetwork,
6467
warehouse: WarehouseBaseClass,
@@ -75,7 +78,7 @@ def build_task_db_from_alchemical_network(
7578
Warehouse used to persist protocol units while building the task DAG.
7679
db_path : pathlib.Path or None, optional
7780
Location of the SQLite-backed Exorcist database. If ``None``, defaults
78-
to ``Path("tasks.db")`` in the current working directory.
81+
to {warehouse.name}.db in the current working directory.
7982
max_tries : int, default=1
8083
Maximum number of retries for each task before Exorcist marks it as
8184
``TOO_MANY_RETRIES``.
@@ -87,9 +90,9 @@ def build_task_db_from_alchemical_network(
8790
edges derived from ``alchemical_network``.
8891
"""
8992
if db_path is None:
90-
db_path = Path("tasks.db")
93+
db_path = Path(f"{warehouse.name}.db")
9194

92-
global_dag = alchemical_network_to_task_graph(alchemical_network, warehouse)
95+
global_dag: nx.DiGraph = alchemical_network_to_task_graph(alchemical_network, warehouse)
9396
db = exorcist.TaskStatusDB.from_filename(db_path)
9497
db.add_task_network(global_dag, max_tries)
9598
return db

src/openfe/storage/warehouse.py

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# This code is part of OpenFE and is licensed under the MIT license.
22
# For details, see https://github.com/OpenFreeEnergy/gufe
33
import json
4+
import pathlib
45
import re
5-
from typing import Literal, TypedDict
6+
from typing import Generator, Literal, TypedDict
67

78
from gufe.protocols.protocoldag import ProtocolDAG
89
from gufe.protocols.protocolunit import ProtocolUnit
@@ -28,6 +29,12 @@ class WarehouseStores(TypedDict):
2829
Storage location for setup-related objects and configurations.
2930
result : ExternalStorage
3031
Storage location for result-related object.
32+
shared : ExternalStorage
33+
Storage location for non-permanent shared data.
34+
tasks: ExternalStorage
35+
Storage location for execution tasks.
36+
protocol_dags: ExternalStorage
37+
Storage location for ProtocolDAGs that correspond to the ProtocolUnits stored in 'tasks'.
3138
3239
Notes
3340
-----
@@ -38,6 +45,7 @@ class WarehouseStores(TypedDict):
3845
result: ExternalStorage
3946
shared: ExternalStorage
4047
tasks: ExternalStorage
48+
protocol_dags: ExternalStorage
4149

4250

4351
class WarehouseBaseClass:
@@ -58,8 +66,11 @@ class WarehouseBaseClass:
5866
The storage locations managed by this warehouse instance.
5967
"""
6068

61-
def __init__(self, stores: WarehouseStores):
69+
def __init__(self, stores: WarehouseStores, name: str):
6270
self.stores = stores
71+
if not isinstance(name, str) or len(name) == 0:
72+
raise ValueError("Warehouse name must be a string.")
73+
self.name = name
6374

6475
def __eq__(self, other):
6576
return isinstance(other, self.__class__) and self.stores == other.stores
@@ -106,6 +117,7 @@ def store_setup_tokenizable(self, obj: GufeTokenizable):
106117
self._store_gufe_tokenizable("setup", obj)
107118

108119
def load_setup_tokenizable(self, obj: GufeKey) -> GufeTokenizable:
120+
# TODO: this doesn't actually look specifically in the setup store, which is misleading
109121
"""Load a GufeTokenizable object from the setup store.
110122
111123
Parameters
@@ -131,6 +143,7 @@ def store_result_tokenizable(self, obj: GufeTokenizable):
131143
return self._store_gufe_tokenizable("result", obj)
132144

133145
def load_result_tokenizable(self, obj: GufeKey) -> GufeTokenizable:
146+
# TODO: this doesn't actually look specifically in the result store, which is misleading
134147
"""Load a GufeTokenizable object from the result store.
135148
136149
Parameters
@@ -145,6 +158,38 @@ def load_result_tokenizable(self, obj: GufeKey) -> GufeTokenizable:
145158
"""
146159
return self._load_gufe_tokenizable(gufe_key=obj)
147160

161+
def store_protocol_dag(self, dag: ProtocolDAG):
162+
"""Store a ProtocolDAG in the "protocol_dags" store of this warehouse.
163+
Parameters
164+
----------
165+
dag : ProtocolDAG
166+
The ProtocolDAG object to store.
167+
168+
Raises
169+
------
170+
ValueError
171+
If `dag` is not a ProtocolDAG instance.
172+
"""
173+
if not isinstance(dag, ProtocolDAG):
174+
raise ValueError("Only ProtocolDAGs may be written to the 'protocol_dags' store.")
175+
self._store_gufe_tokenizable("protocol_dags", dag)
176+
177+
def load_protocol_dag(self, gufe_key=GufeKey) -> GufeTokenizable:
178+
"""Load a GufeTokenizable object from the protocol_dag store.
179+
180+
Parameters
181+
----------
182+
obj : GufeKey
183+
The key of the protocoldag to load.
184+
185+
Returns
186+
-------
187+
GufeTokenizable
188+
The loaded object.
189+
"""
190+
# TODO: type check that it is a protocol dag before returning?
191+
return self._load_gufe_tokenizable(gufe_key=gufe_key)
192+
148193
def exists(self, key: GufeKey) -> bool:
149194
"""Check if an object with the given key exists in any store that holds tokenizables.
150195
@@ -188,7 +233,7 @@ def _get_store_for_key(self, key: GufeKey) -> ExternalStorage:
188233

189234
def _store_gufe_tokenizable(
190235
self,
191-
store_name: Literal["setup", "result", "tasks"],
236+
store_name: Literal["setup", "result", "tasks", "protocol_dags"],
192237
obj: GufeTokenizable,
193238
name: str | None = None,
194239
):
@@ -294,6 +339,23 @@ def recursive_build_object_cache(key: GufeKey) -> GufeTokenizable:
294339

295340
return recursive_build_object_cache(gufe_key)
296341

342+
def get_protocol_dags(self) -> Generator[ProtocolDAG, None, None]:
343+
"""Yield the protocol dags present in the Warehouse's 'protocol_dags' store.
344+
345+
Note that this requires the name of the item to start with 'ProtocolDAG'.
346+
347+
Yields
348+
------
349+
Generator[ProtocolDAG]
350+
The ProtocolDAGs found in this Warehouse's 'protocol_dags' store.
351+
"""
352+
# NOTE: this can be made more robust (but slower) by using isinstance(obj, openfe.ProtocolDAG)
353+
# _after_ loading each item, rather than filtering by name
354+
for item in self.stores["protocol_dags"]:
355+
if item.startswith("ProtocolDAG"):
356+
dag = self.load_protocol_dag(item)
357+
yield dag
358+
297359
@property
298360
def setup_store(self):
299361
"""Get the setup store
@@ -346,13 +408,20 @@ class FileSystemWarehouse(WarehouseBaseClass):
346408
for results and other data types.
347409
"""
348410

349-
def __init__(self, root_dir: str = "warehouse"):
350-
self.root_dir = root_dir
351-
setup_store = FileStorage(f"{root_dir}/setup")
352-
result_store = FileStorage(f"{root_dir}/result")
353-
shared_store = FileStorage(f"{root_dir}/shared")
354-
tasks_store = FileStorage(f"{root_dir}/tasks")
411+
def __init__(self, name):
412+
# TODO: should name and location be different?
413+
self.root_dir = pathlib.Path(f"{name}")
414+
setup_store = FileStorage(f"{self.root_dir}/setup")
415+
result_store = FileStorage(f"{self.root_dir}/result")
416+
shared_store = FileStorage(f"{self.root_dir}/shared")
417+
tasks_store = FileStorage(f"{self.root_dir}/tasks")
418+
# TODO: we can store dags in setup if we have a performant way of accessing them
419+
protocol_dag_store = FileStorage(f"{self.root_dir}/protocol_dags")
355420
stores = WarehouseStores(
356-
setup=setup_store, result=result_store, shared=shared_store, tasks=tasks_store
421+
setup=setup_store,
422+
result=result_store,
423+
shared=shared_store,
424+
tasks=tasks_store,
425+
protocol_dags=protocol_dag_store,
357426
)
358-
super().__init__(stores)
427+
super().__init__(stores, name)

src/openfe/tests/orchestration/test_exorcist_utils.py

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ def __init__(self):
2222
def store_task(self, task):
2323
self.stored_tasks.append(task)
2424

25+
def store_setup_tokenizable(self, obj):
26+
# TODO: add tests for tokenizable storage?
27+
pass
28+
29+
def store_protocol_dag(self, dag):
30+
pass
31+
2532

2633
def _network_units(benzene_variants_star_map):
2734
units = []
@@ -35,7 +42,6 @@ def test_alchemical_network_to_task_graph_stores_all_units(request, fixture):
3542
warehouse = _RecordingWarehouse()
3643
network = request.getfixturevalue(fixture)
3744
expected_units = _network_units(network)
38-
3945
alchemical_network_to_task_graph(network, cast(WarehouseBaseClass, warehouse))
4046

4147
stored_unit_names = [str(unit.name) for unit in warehouse.stored_tasks]
@@ -52,13 +58,11 @@ def test_alchemical_network_to_task_graph_uses_canonical_task_ids(request, fixtu
5258

5359
graph = alchemical_network_to_task_graph(network, cast(WarehouseBaseClass, warehouse))
5460

55-
transformation_keys = {str(transformation.key) for transformation in network.edges}
5661
expected_protocol_unit_keys = sorted(str(unit.key) for unit in warehouse.stored_tasks)
5762
observed_protocol_unit_keys = []
5863

5964
for node in graph.nodes:
60-
transformation_key, protocol_unit_key = node.split(":", maxsplit=1)
61-
assert transformation_key in transformation_keys
65+
protocol_unit_key = node
6266
observed_protocol_unit_keys.append(protocol_unit_key)
6367

6468
assert sorted(observed_protocol_unit_keys) == expected_protocol_unit_keys
@@ -86,8 +90,9 @@ def test_alchemical_network_to_task_graph_edge_direction_matches_dependencies(re
8690
units_by_key = {str(unit.key): unit for unit in warehouse.stored_tasks}
8791

8892
for upstream_id, downstream_id in graph.edges:
89-
_, upstream_key = upstream_id.split(":", maxsplit=1)
90-
_, downstream_key = downstream_id.split(":", maxsplit=1)
93+
# as of now this is true, but 'node' may contain more info
94+
upstream_key = upstream_id
95+
downstream_key = downstream_id
9196
upstream_unit = units_by_key[upstream_key]
9297
downstream_unit = units_by_key[downstream_key]
9398
assert upstream_unit in downstream_unit.dependencies
@@ -148,7 +153,7 @@ def test_build_task_db_checkout_order_is_dependency_safe(tmp_path, request, fixt
148153
break
149154

150155
checkout_order.append(taskid)
151-
_, protocol_unit_key = taskid.split(":", maxsplit=1)
156+
protocol_unit_key = taskid
152157
loaded_unit = warehouse.load_task(GufeKey(protocol_unit_key))
153158
assert str(loaded_unit.key) == protocol_unit_key
154159
db.mark_task_completed(taskid, success=True)
@@ -190,7 +195,7 @@ def test_build_task_db_default_path(request, fixture):
190195
result = build_task_db_from_alchemical_network(network, warehouse)
191196

192197
task_graph_mock.assert_called_once_with(network, warehouse)
193-
db_ctor.assert_called_once_with(Path("tasks.db"))
198+
db_ctor.assert_called_once_with(Path(f"{warehouse.name}.db"))
194199
fake_db.add_task_network.assert_called_once_with(fake_graph, 1)
195200
assert result is fake_db
196201

src/openfe/tests/orchestration/test_worker.py

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def worker_with_executable_task_db(tmp_path, absolute_transformation):
7171
unit = _get_dependency_free_unit(absolute_transformation)
7272
warehouse.store_task(unit)
7373

74-
taskid = f"{absolute_transformation.key}:{unit.key}"
74+
taskid = unit.key
7575
task_graph = nx.DiGraph()
7676
task_graph.add_node(taskid)
7777

@@ -94,24 +94,24 @@ def test_get_task_uses_default_db_path_without_patching(
9494
worker = Worker(warehouse=warehouse)
9595
taskid, loaded = worker._get_task()
9696

97-
expected_keys = {task_row.taskid.split(":", maxsplit=1)[1] for task_row in db.get_all_tasks()}
97+
expected_keys = {task_row.taskid for task_row in db.get_all_tasks()}
9898
assert worker.task_db_path == Path("./warehouse/tasks.db")
9999
assert str(loaded.key) in expected_keys
100-
assert taskid.endswith(f":{loaded.key}")
100+
assert taskid == loaded.key
101101

102102

103103
def test_get_task_returns_task_with_canonical_protocol_unit_suffix(worker_with_real_db):
104104
worker, warehouse, db = worker_with_real_db
105105

106106
task_ids = [row.taskid for row in db.get_all_tasks()]
107-
expected_protocol_unit_keys = {task_id.split(":", maxsplit=1)[1] for task_id in task_ids}
107+
expected_protocol_unit_keys = {task_id for task_id in task_ids}
108108

109109
taskid, loaded = worker._get_task()
110110
reloaded = warehouse.load_task(loaded.key)
111111

112112
assert str(loaded.key) in expected_protocol_unit_keys
113113
assert loaded == reloaded
114-
assert taskid.endswith(f":{loaded.key}")
114+
assert taskid == loaded.key
115115

116116

117117
def test_execute_unit_stores_real_result(worker_with_executable_task_db, tmp_path):
@@ -185,9 +185,8 @@ def test_execute_unit_resolves_dependency_results(tmp_path):
185185
warehouse.store_task(first_unit)
186186
warehouse.store_task(second_unit)
187187

188-
transformation_key = "Transformation-toy"
189-
first_taskid = f"{transformation_key}:{first_unit.key}"
190-
second_taskid = f"{transformation_key}:{second_unit.key}"
188+
first_taskid = first_unit.key
189+
second_taskid = second_unit.key
191190

192191
task_graph = nx.DiGraph()
193192
task_graph.add_edge(first_taskid, second_taskid)
@@ -220,7 +219,7 @@ def test_execute_unit_marks_missing_dependency_as_failed(tmp_path):
220219
dependent_unit = _ToyProtocolUnit(name="dependent", upstream=missing_upstream, increment=2)
221220
warehouse.store_task(dependent_unit)
222221

223-
taskid = f"Transformation-toy:{dependent_unit.key}"
222+
taskid = dependent_unit.key
224223
task_graph = nx.DiGraph()
225224
task_graph.add_node(taskid)
226225

@@ -247,8 +246,8 @@ def test_execute_unit_uses_isolated_shared_workspace_per_task(tmp_path):
247246
warehouse.store_task(first_unit)
248247
warehouse.store_task(second_unit)
249248

250-
first_taskid = f"Transformation-toy:{first_unit.key}"
251-
second_taskid = f"Transformation-toy:{second_unit.key}"
249+
first_taskid = first_unit.key
250+
second_taskid = second_unit.key
252251

253252
task_graph = nx.DiGraph()
254253
task_graph.add_node(first_taskid)

0 commit comments

Comments
 (0)