Skip to content

Commit 71bf07a

Browse files
MultiBackendJobManager: Dynamically add all usage metrics in job DB. … (#947)
* MultiBackendJobManager: Dynamically add all usage metrics in job DB. Remove hardcoded usage metrics from required columns * Update job manager unit tests to no longer require costs and duration metrics * Inintialize non-existing columns in job DB as None * Further clean-up of MBJM unit tests * Ensure backwards compatibility of MBJM for legacy job databases + add unit test for it
1 parent a0773ce commit 71bf07a

7 files changed

Lines changed: 152 additions & 49 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- Make `_DerivedFrom._from_url` more resilient against unresolvable/unparsable `derived_from` links ([#928](https://github.com/Open-EO/openeo-python-client/issues/928), eu-cdse/openeo-cdse-infra#1338)
1616
- `download_url()`: favor explicit HEAD status check over unhelpful auto-check ([#939](https://github.com/Open-EO/openeo-python-client/issues/939))
1717
- Modernize Xarray version support: remove `<2025.0.2` upper limit but require at least `2024.07.0` ([#721](https://github.com/Open-EO/openeo-python-client/issues/721))
18+
- `MultiBackendJobManager`: all fields reported under "usage" in the job metadata are now dynamically included as columns in the job database, instead of only the hardcoded "cpu", "memory" and "duration" fields (which are no longer predefined columns)
1819

1920
### Removed
2021

‎openeo/extra/job_management/_job_db.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,16 @@ def _merge_into_df(self, df: pd.DataFrame):
8787
unknown_indices = set(df.index).difference(df.index)
8888
if unknown_indices:
8989
_log.warning(f"Merging DataFrame with {unknown_indices=} which will be lost.")
90+
# `DataFrame.update` ignores columns that don't exist yet in `self._df`, so add them first
91+
# (e.g. dynamically added columns for job usage metadata).
92+
new_columns = [c for c in df.columns if c not in self._df.columns]
93+
for column in new_columns:
94+
self._df[column] = None
95+
# Align dtypes to avoid incompatible-dtype warnings/errors on update
96+
# (e.g. empty legacy columns loaded as float64, updated with strings).
97+
for column in df.columns.intersection(self._df.columns):
98+
if df[column].dtype == object and self._df[column].dtype != object:
99+
self._df[column] = self._df[column].astype(object)
90100
self._df.update(df, overwrite=True)
91101
else:
92102
self._df = df

‎openeo/extra/job_management/_manager.py‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ class _ColumnRequirements:
8585
def __init__(self, requirements: Mapping[str, _ColumnProperties]):
8686
self._requirements = dict(requirements)
8787

88+
def __contains__(self, column: str) -> bool:
89+
return column in self._requirements
90+
8891
def normalize_df(self, df: pd.DataFrame) -> pd.DataFrame:
8992
"""
9093
Normalize given pandas dataframe (creating a new one):
@@ -190,12 +193,9 @@ def start_job(
190193
# TODO: use proper date/time dtype instead of legacy str for start times?
191194
"start_time": _ColumnProperties(dtype="str"),
192195
"running_start_time": _ColumnProperties(dtype="str"),
193-
# TODO: these columns "cpu", "memory", "duration" are not referenced explicitly from MultiBackendJobManager,
194-
# but are indirectly coupled through handling of VITO-specific "usage" metadata in `_track_statuses`.
195-
# Since bfd99e34 they are not really required to be present anymore, can we make that more explicit?
196-
"cpu": _ColumnProperties(dtype="str"),
197-
"memory": _ColumnProperties(dtype="str"),
198-
"duration": _ColumnProperties(dtype="str"),
196+
# Note: columns for job usage metadata (e.g. "cpu", "memory", "duration", ...)
197+
# are not predefined here, but are added dynamically in `_track_statuses`
198+
# based on the "usage" fields reported by the backend in the job metadata.
199199
"costs": _ColumnProperties(dtype="float64"),
200200
}
201201
)
@@ -903,10 +903,20 @@ def _track_statuses(self, job_db: JobDatabaseInterface, stats: Optional[dict] =
903903

904904
active.loc[i, "status"] = new_status
905905

906-
# TODO: there is well hidden coupling here with "cpu", "memory" and "duration" from `_normalize_df`
906+
# Dynamically add all "usage" metadata fields as columns.
907907
for key in job_metadata.get("usage", {}).keys():
908-
if key in active.columns:
909-
active.loc[i, key] = _format_usage_stat(job_metadata, key)
908+
if key in self._column_requirements:
909+
_log.warning(
910+
f"Skipping usage field {key!r} of job {job_id!r}: collides with existing job db column"
911+
)
912+
continue
913+
if key not in active.columns:
914+
# Initialize new column with None (instead of NaN) to keep it JSON-serializable
915+
active[key] = None
916+
elif active[key].dtype != object:
917+
# Legacy job dbs may have empty usage columns loaded as float64, which can't hold strings
918+
active[key] = active[key].astype(object)
919+
active.loc[i, key] = _format_usage_stat(job_metadata, key)
910920
if "costs" in job_metadata.keys():
911921
active.loc[i, "costs"] = job_metadata.get("costs")
912922

‎openeo/rest/_testing.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ class DummyBackend:
5353
"job_status_updater",
5454
"job_id_generator",
5555
"extra_job_metadata_fields",
56+
"next_batch_job_usage",
5657
)
5758

5859
# Default result (can serve both as JSON or binary data)
@@ -77,6 +78,11 @@ def __init__(
7778
self._forced_job_status: Dict[str, str] = {}
7879
self._fail_on_job_start = {}
7980

81+
# "usage" metadata to report on finished batch jobs
82+
self.next_batch_job_usage: dict = {
83+
"cpu": {"unit": "cpu-seconds", "value": 1234.5},
84+
}
85+
8086
# Job status update hook:
8187
# callable that is called on starting a job, and getting job metadata
8288
# allows to dynamically change how the status of a job evolves
@@ -297,11 +303,7 @@ def _handle_get_job(self, request, context):
297303
}
298304
if self.batch_jobs[job_id]["status"] == "finished": # HACK some realistic values for a small job
299305
result["costs"] = 123
300-
result["usage"] = {
301-
"cpu": {"unit": "cpu-seconds", "value": 1234.5},
302-
"memory": {"unit": "mb-seconds", "value": 34567.89},
303-
"duration": {"unit": "seconds", "value": 2345},
304-
}
306+
result["usage"] = self.next_batch_job_usage
305307
return result
306308

307309
def _handle_get_job_results(self, request, context):

‎tests/extra/job_management/test_job_db.py‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,6 @@ def test_initialize_from_df(self, tmp_path, db_class):
5252
"id",
5353
"start_time",
5454
"running_start_time",
55-
"cpu",
56-
"memory",
57-
"duration",
5855
"backend_name",
5956
"costs",
6057
}
@@ -90,6 +87,21 @@ def test_initialize_from_df_on_exists_skip(self, tmp_path, db_class):
9087
)
9188
assert set(db.read()["some_number"]) == {1, 2, 3}
9289

90+
@pytest.mark.parametrize("db_class", [CsvJobDatabase, ParquetJobDatabase])
91+
def test_persist_with_new_columns(self, tmp_path, db_class):
92+
"""Persisting an update with new columns (e.g. dynamically added usage columns) should preserve them."""
93+
path = tmp_path / "jobs.db"
94+
db = db_class(path).initialize_from_df(pd.DataFrame({"some_number": [3, 2, 1]}))
95+
96+
update = db.df.iloc[[0, 1]].copy()
97+
update["cpu"] = ["123 cpu-seconds", "456 cpu-seconds"]
98+
db.persist(update)
99+
100+
result = db_class(path).read()
101+
assert "cpu" in result.columns
102+
assert list(result["cpu"].fillna("")) == ["123 cpu-seconds", "456 cpu-seconds", ""]
103+
assert list(result["some_number"]) == [3, 2, 1]
104+
93105
@pytest.mark.parametrize("db_class", [CsvJobDatabase, ParquetJobDatabase])
94106
def test_count_by_status(self, tmp_path, db_class):
95107
path = tmp_path / "jobs.db"
@@ -231,9 +243,6 @@ def test_initialize_from_df(self, tmp_path):
231243
"id",
232244
"start_time",
233245
"running_start_time",
234-
"cpu",
235-
"memory",
236-
"duration",
237246
"backend_name",
238247
"costs",
239248
}
@@ -329,9 +338,6 @@ def test_initialize_from_df(self, tmp_path):
329338
"id",
330339
"start_time",
331340
"running_start_time",
332-
"cpu",
333-
"memory",
334-
"duration",
335341
"backend_name",
336342
"costs",
337343
}

‎tests/extra/job_management/test_manager.py‎

Lines changed: 100 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -178,14 +178,14 @@ def test_basic(self, tmp_path, job_manager, job_manager_root_dir, sleep_mock):
178178
)
179179

180180
assert [
181-
(r.id, r.status, r.backend_name, r.cpu, r.memory, r.duration, r.costs)
181+
(r.id, r.status, r.backend_name, r.cpu, r.costs)
182182
for r in pd.read_csv(job_db_path).itertuples()
183183
] == [
184-
("job-2018", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
185-
("job-2019", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
186-
("job-2020", "finished", "bar", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
187-
("job-2021", "finished", "bar", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
188-
("job-2022", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
184+
("job-2018", "finished", "foo", "1234.5 cpu-seconds", 123),
185+
("job-2019", "finished", "foo", "1234.5 cpu-seconds", 123),
186+
("job-2020", "finished", "bar", "1234.5 cpu-seconds", 123),
187+
("job-2021", "finished", "bar", "1234.5 cpu-seconds", 123),
188+
("job-2022", "finished", "foo", "1234.5 cpu-seconds", 123),
189189
]
190190

191191
# Check downloaded results and metadata.
@@ -219,9 +219,89 @@ def test_db_class(self, tmp_path, job_manager, job_manager_root_dir, sleep_mock,
219219
assert set(result.status) == {"finished"}
220220
assert set(result.backend_name) == {"foo", "bar"}
221221
assert set(result.cpu) == {"1234.5 cpu-seconds"}
222+
assert set(result.costs) == {123}
223+
224+
@pytest.mark.parametrize("db_class", [CsvJobDatabase, ParquetJobDatabase])
225+
def test_usage_fields_dynamic_columns(
226+
self, tmp_path, job_manager, dummy_backend_foo, dummy_backend_bar, sleep_mock, db_class
227+
):
228+
"""
229+
All fields reported under "usage" in the job metadata
230+
should dynamically be included as columns in the job db.
231+
"""
232+
usage = {
233+
"cpu": {"unit": "cpu-seconds", "value": 1234.5},
234+
"memory": {"unit": "mb-seconds", "value": 34567.89},
235+
"duration": {"unit": "seconds", "value": 2345},
236+
"network": {"unit": "b", "value": 1000},
237+
"sentinelhub": {"unit": "sentinelhub_processing_unit", "value": 7.5},
238+
}
239+
dummy_backend_foo.next_batch_job_usage = usage
240+
dummy_backend_bar.next_batch_job_usage = usage
241+
242+
df = pd.DataFrame({"year": [2018, 2019, 2020, 2021, 2022]})
243+
output_file = tmp_path / "jobs.db"
244+
job_db = db_class(output_file).initialize_from_df(df)
245+
246+
run_stats = job_manager.run_jobs(job_db=job_db, start_job=self._create_year_job)
247+
assert run_stats == dirty_equals.IsPartialDict({"job finished": 5})
248+
249+
result = db_class(output_file).read()
250+
assert len(result) == 5
251+
assert set(result.status) == {"finished"}
252+
assert set(result.cpu) == {"1234.5 cpu-seconds"}
222253
assert set(result.memory) == {"34567.89 mb-seconds"}
223254
assert set(result.duration) == {"2345 seconds"}
224-
assert set(result.costs) == {123}
255+
assert set(result.network) == {"1000 b"}
256+
assert set(result.sentinelhub) == {"7.5 sentinelhub_processing_unit"}
257+
258+
def test_usage_fields_reserved_column_collision(
259+
self, tmp_path, job_manager, dummy_backend_foo, dummy_backend_bar, sleep_mock, caplog
260+
):
261+
"""
262+
Usage fields that collide with reserved job db columns (e.g. "status")
263+
should be skipped instead of overwriting the column.
264+
"""
265+
usage = {
266+
"status": {"unit": "sneaky", "value": 666},
267+
"network": {"unit": "b", "value": 1000},
268+
}
269+
dummy_backend_foo.next_batch_job_usage = usage
270+
dummy_backend_bar.next_batch_job_usage = usage
271+
272+
df = pd.DataFrame({"year": [2018, 2019]})
273+
output_file = tmp_path / "jobs.csv"
274+
job_db = CsvJobDatabase(output_file).initialize_from_df(df)
275+
276+
with caplog.at_level(logging.WARNING):
277+
job_manager.run_jobs(job_db=job_db, start_job=self._create_year_job)
278+
279+
result = CsvJobDatabase(output_file).read()
280+
assert set(result.status) == {"finished"}
281+
assert set(result.network) == {"1000 b"}
282+
assert "Skipping usage field 'status'" in caplog.text
283+
284+
def test_usage_fields_legacy_job_db(self, tmp_path, job_manager, sleep_mock, recwarn):
285+
"""
286+
Resuming from a legacy job db that still has (empty) predefined usage columns
287+
(e.g. "cpu", "memory", "duration") should work without warnings/errors.
288+
"""
289+
job_db_path = tmp_path / "jobs.csv"
290+
job_db_path.write_text(
291+
"year,id,backend_name,status,start_time,running_start_time,cpu,memory,duration,costs\n"
292+
"2021,,,not_started,,,,,,\n"
293+
"2022,,,not_started,,,,,,\n"
294+
)
295+
296+
job_db = CsvJobDatabase(job_db_path)
297+
run_stats = job_manager.run_jobs(job_db=job_db, start_job=self._create_year_job)
298+
assert run_stats == dirty_equals.IsPartialDict({"start_job call": 2, "job finished": 2})
299+
300+
result = pd.read_csv(job_db_path)
301+
assert set(result.status) == {"finished"}
302+
assert set(result.cpu) == {"1234.5 cpu-seconds"}
303+
304+
assert [(w.category, w.message, str(w)) for w in recwarn.list] == []
225305

226306
@pytest.mark.parametrize(
227307
["filename", "expected_db_class"],
@@ -274,14 +354,14 @@ def test_start_job_thread_basic(self, tmp_path, job_manager, job_manager_root_di
274354
assert sleep_mock.call_count > 10
275355

276356
assert [
277-
(r.id, r.status, r.backend_name, r.cpu, r.memory, r.duration, r.costs)
357+
(r.id, r.status, r.backend_name, r.cpu, r.costs)
278358
for r in pd.read_csv(job_db_path).itertuples()
279359
] == [
280-
("job-2018", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
281-
("job-2019", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
282-
("job-2020", "finished", "bar", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
283-
("job-2021", "finished", "bar", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
284-
("job-2022", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
360+
("job-2018", "finished", "foo", "1234.5 cpu-seconds", 123),
361+
("job-2019", "finished", "foo", "1234.5 cpu-seconds", 123),
362+
("job-2020", "finished", "bar", "1234.5 cpu-seconds", 123),
363+
("job-2021", "finished", "bar", "1234.5 cpu-seconds", 123),
364+
("job-2022", "finished", "foo", "1234.5 cpu-seconds", 123),
285365
]
286366

287367
# Check downloaded results and metadata.
@@ -301,9 +381,6 @@ def test_normalize_df(self):
301381
"id",
302382
"start_time",
303383
"running_start_time",
304-
"cpu",
305-
"memory",
306-
"duration",
307384
"backend_name",
308385
"costs",
309386
]
@@ -358,13 +435,13 @@ def start_worker_thread():
358435
# Also check that we got sensible end results in the job db.
359436
results = pd.read_csv(job_db_path).replace({np.nan: None}) # np.nan's are replaced by None for easy comparison
360437
assert [
361-
(r.id, r.status, r.backend_name, r.cpu, r.memory, r.duration, r.costs) for r in results.itertuples()
438+
(r.id, r.status, r.backend_name, r.cpu, r.costs) for r in results.itertuples()
362439
] == [
363-
("job-2018", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
364-
("job-2019", "finished", "foo", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
365-
("job-2020", "finished", "bar", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
366-
("job-2021", "finished", "bar", "1234.5 cpu-seconds", "34567.89 mb-seconds", "2345 seconds", 123),
367-
("job-2022", "error", "foo", None, None, None, None),
440+
("job-2018", "finished", "foo", "1234.5 cpu-seconds", 123),
441+
("job-2019", "finished", "foo", "1234.5 cpu-seconds", 123),
442+
("job-2020", "finished", "bar", "1234.5 cpu-seconds", 123),
443+
("job-2021", "finished", "bar", "1234.5 cpu-seconds", 123),
444+
("job-2022", "error", "foo", None, None),
368445
]
369446

370447
# Check downloaded results and metadata.
@@ -627,7 +704,7 @@ def test_empty_csv_handling(self, tmp_path, sleep_mock, recwarn, job_manager):
627704

628705
assert job_db_path.exists()
629706
# Simple check for empty columns in the CSV file
630-
assert ",,,,," in job_db_path.read_text()
707+
assert "2021,,,not_started,,,\n" in job_db_path.read_text()
631708

632709
# Start over with existing file
633710
job_db = CsvJobDatabase(job_db_path)

‎tests/extra/job_management/test_stac_job_db.py‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,6 @@ def _common_normalized_df_data(rows: int = 1) -> dict:
6464
"status": ["not_started"] * rows,
6565
"start_time": None,
6666
"running_start_time": None,
67-
"cpu": None,
68-
"memory": None,
69-
"duration": None,
7067
"costs": None,
7168
}
7269

0 commit comments

Comments
 (0)