-
Notifications
You must be signed in to change notification settings - Fork 46
fix: avoid 500 errors when job parameters cannot be stored #1007
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -5,10 +5,11 @@ | |||||||||||||||
|
|
||||||||||||||||
| from __future__ import annotations | ||||||||||||||||
|
|
||||||||||||||||
| import math | ||||||||||||||||
| from enum import StrEnum | ||||||||||||||||
| from typing import Literal | ||||||||||||||||
| from typing import Any, Literal | ||||||||||||||||
|
|
||||||||||||||||
| from pydantic import BaseModel, Field, field_validator | ||||||||||||||||
| from pydantic import BaseModel, Field, field_validator, model_validator | ||||||||||||||||
|
|
||||||||||||||||
| from .types import UTCDatetime | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -21,12 +22,16 @@ class InsertedJob(BaseModel): | |||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class HeartbeatData(BaseModel, extra="forbid"): | ||||||||||||||||
| load_average: float | None = Field(None, alias="LoadAverage") | ||||||||||||||||
| memory_used: float | None = Field(None, alias="MemoryUsed") | ||||||||||||||||
| vsize: float | None = Field(None, alias="Vsize") | ||||||||||||||||
| available_disk_space: float | None = Field(None, alias="AvailableDiskSpace") | ||||||||||||||||
| cpu_consumed: float | None = Field(None, alias="CPUConsumed") | ||||||||||||||||
| wall_clock_time: float | None = Field(None, alias="WallClockTime") | ||||||||||||||||
| load_average: float | None = Field(None, alias="LoadAverage", allow_inf_nan=False) | ||||||||||||||||
| memory_used: float | None = Field(None, alias="MemoryUsed", allow_inf_nan=False) | ||||||||||||||||
| vsize: float | None = Field(None, alias="Vsize", allow_inf_nan=False) | ||||||||||||||||
| available_disk_space: float | None = Field( | ||||||||||||||||
| None, alias="AvailableDiskSpace", allow_inf_nan=False | ||||||||||||||||
| ) | ||||||||||||||||
| cpu_consumed: float | None = Field(None, alias="CPUConsumed", allow_inf_nan=False) | ||||||||||||||||
| wall_clock_time: float | None = Field( | ||||||||||||||||
| None, alias="WallClockTime", allow_inf_nan=False | ||||||||||||||||
| ) | ||||||||||||||||
| standard_output: str | None = Field(None, alias="StandardOutput") | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -36,6 +41,18 @@ class JobCommand(BaseModel): | |||||||||||||||
| arguments: str | None = None | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| def _ensure_finite_numbers(value: Any, path: str) -> None: | ||||||||||||||||
| """Raise ValueError if a (possibly nested) value contains NaN or infinity.""" | ||||||||||||||||
| if isinstance(value, float) and not math.isfinite(value): | ||||||||||||||||
| raise ValueError(f"{path}: non-finite numbers are not supported") | ||||||||||||||||
| if isinstance(value, dict): | ||||||||||||||||
| for key, item in value.items(): | ||||||||||||||||
| _ensure_finite_numbers(item, f"{path}.{key}") | ||||||||||||||||
| elif isinstance(value, (list, tuple)): | ||||||||||||||||
| for i, item in enumerate(value): | ||||||||||||||||
| _ensure_finite_numbers(item, f"{path}[{i}]") | ||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class JobParameters(BaseModel, populate_by_name=True, extra="allow"): | ||||||||||||||||
| """Some of the most important parameters that can be set for a job.""" | ||||||||||||||||
|
|
||||||||||||||||
|
|
@@ -73,6 +90,17 @@ def convert_cpu_fields_to_int(cls, v): | |||||||||||||||
| return int(v) | ||||||||||||||||
| return v | ||||||||||||||||
|
|
||||||||||||||||
| @model_validator(mode="after") | ||||||||||||||||
| def validate_extra_fields_are_json_safe(self): | ||||||||||||||||
| """Reject extra field values which cannot be represented in strict JSON. | ||||||||||||||||
|
|
||||||||||||||||
| Python's JSON parser accepts NaN and (-)Infinity so such values survive | ||||||||||||||||
| request parsing, but OpenSearch rejects documents containing them. | ||||||||||||||||
| """ | ||||||||||||||||
| for name, value in (self.model_extra or {}).items(): | ||||||||||||||||
| _ensure_finite_numbers(value, name) | ||||||||||||||||
| return self | ||||||||||||||||
|
Comment on lines
+100
to
+102
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| class JobAttributes(BaseModel, populate_by_name=True, extra="forbid"): | ||||||||||||||||
| """All the attributes that can be set for a job.""" | ||||||||||||||||
|
|
||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,7 +67,10 @@ class PilotMetadata(BaseModel, populate_by_name=True, extra="forbid"): | |
| None, alias="Status", description="Current pilot status." | ||
| ) | ||
| benchmark: float | None = Field( | ||
| None, alias="BenchMark", description="Pilot benchmark value." | ||
| None, | ||
| alias="BenchMark", | ||
| allow_inf_nan=False, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As I commented in the related DIRAC PR, I do not see how this can ever happen |
||
| description="Pilot benchmark value.", | ||
| ) | ||
| destination_site: str | None = Field( | ||
| None, alias="DestinationSite", max_length=128, description="Destination site." | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import pytest | ||
|
|
||
| from diracx.core.exceptions import DocumentUpsertError | ||
| from diracx.testing.osdb import DummyOSDB | ||
|
|
||
|
|
||
| async def test_upsert_valid_document(dummy_opensearch_db: DummyOSDB): | ||
| """Sanity check that a well-formed document can be upserted.""" | ||
| await dummy_opensearch_db.upsert("dummyvo", 1, {"IntField": 1234}) | ||
| await dummy_opensearch_db.client.indices.refresh( | ||
| index=f"{dummy_opensearch_db.index_prefix}*" | ||
| ) | ||
| results = await dummy_opensearch_db.search( | ||
| None, [{"parameter": "IntField", "operator": "eq", "value": "1234"}], [] | ||
| ) | ||
| assert len(results) == 1 | ||
|
|
||
|
|
||
| async def test_upsert_unparsable_document_raises(dummy_opensearch_db: DummyOSDB): | ||
| """NaN survives Python JSON serialization but OpenSearch rejects it. | ||
|
|
||
| This must surface as a DocumentUpsertError rather than an unhandled | ||
| RequestError, and the offending document must be logged. | ||
| """ | ||
| with pytest.raises(DocumentUpsertError, match="Failed to upsert document"): | ||
| await dummy_opensearch_db.upsert("dummyvo", 2, {"IntField": float("nan")}) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test also against |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
with
from typing_extensions import Self