Skip to content

Commit 372df7a

Browse files
authored
Merge pull request #814 from simvue-io/kzscisoft/781-uploads-silently-fail-resulting-in-0b-files
Kzscisoft/781 uploads silently fail resulting in 0b files
2 parents 868e492 + a4963f1 commit 372df7a

File tree

7 files changed

+88
-9
lines changed

7 files changed

+88
-9
lines changed

simvue/api/objects/alert/fetch.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,4 +152,6 @@ def get(
152152
),
153153
)
154154
else:
155-
raise RuntimeError(f"Unrecognised alert source '{_entry['source']}'")
155+
raise RuntimeError(
156+
f"Unrecognised alert source '{_entry['source']}' with data '{_entry}'"
157+
)

simvue/api/objects/artifact/base.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@
3131

3232
Category = typing.Literal["code", "input", "output"]
3333

34-
UPLOAD_TIMEOUT: int = 30
35-
DOWNLOAD_TIMEOUT: int = 30
34+
UPLOAD_TIMEOUT_PER_MB: int = 1
35+
DOWNLOAD_TIMEOUT_PER_MB: int = 1
3636
DOWNLOAD_CHUNK_SIZE: int = 8192
3737

3838

@@ -108,21 +108,29 @@ def on_reconnect(self, id_mapping: dict[str, str]) -> None:
108108
for id, category in _offline_staging.items():
109109
self.attach_to_run(run_id=id_mapping[id], category=category)
110110

111-
def _upload(self, file: io.BytesIO) -> None:
111+
def _upload(self, file: io.BytesIO, timeout: int, file_size: int) -> None:
112112
if self._offline:
113113
super().commit()
114114
return
115115

116116
if not (_url := self._staging.get("url")):
117117
return
118118

119+
if not timeout:
120+
timeout = UPLOAD_TIMEOUT_PER_MB * file_size / 1024 / 1024
121+
122+
self._logger.debug(
123+
f"Will wait for a period of {timeout}s for upload of file to complete."
124+
)
125+
119126
_name = self._staging["name"]
120127

121128
_response = sv_post(
122129
url=_url,
123130
headers={},
124131
params={},
125132
is_json=False,
133+
timeout=timeout,
126134
files={"file": file},
127135
data=self._init_data.get("fields"),
128136
)
@@ -325,7 +333,9 @@ def download_content(self) -> typing.Generator[bytes, None, None]:
325333
f"Could not retrieve URL for artifact '{self._identifier}'"
326334
)
327335
_response = sv_get(
328-
f"{self.download_url}", timeout=DOWNLOAD_TIMEOUT, headers=None
336+
f"{self.download_url}",
337+
timeout=DOWNLOAD_TIMEOUT_PER_MB * self.size / 1024 / 1024,
338+
headers=None,
329339
)
330340

331341
get_json_from_response(

simvue/api/objects/artifact/file.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def new(
3737
file_path: pydantic.FilePath,
3838
mime_type: str | None,
3939
metadata: dict[str, typing.Any] | None,
40+
upload_timeout: int | None = None,
4041
offline: bool = False,
4142
**kwargs,
4243
) -> Self:
@@ -56,6 +57,8 @@ def new(
5657
the mime type for this file, else this is determined
5758
metadata : dict[str, Any] | None
5859
supply metadata information for this artifact
60+
upload_timeout : int | None, optional
61+
specify the timeout in seconds for upload
5962
offline : bool, optional
6063
whether to define this artifact locally, default is False
6164
@@ -100,6 +103,6 @@ def new(
100103
return _artifact
101104

102105
with open(_file_orig_path, "rb") as out_f:
103-
_artifact._upload(file=out_f)
106+
_artifact._upload(file=out_f, timeout=upload_timeout, file_size=_file_size)
104107

105108
return _artifact

simvue/api/objects/artifact/object.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def new(
3939
storage: str | None,
4040
obj: typing.Any,
4141
metadata: dict[str, typing.Any] | None,
42+
upload_timeout: int | None = None,
4243
allow_pickling: bool = True,
4344
offline: bool = False,
4445
**kwargs,
@@ -57,6 +58,8 @@ def new(
5758
object to serialize and upload
5859
metadata : dict[str, Any] | None
5960
supply metadata information for this artifact
61+
upload_timeout : int | None, optional
62+
specify the timeout in seconds for upload
6063
allow_pickling : bool, optional
6164
whether to allow the object to be pickled if no other
6265
serialization found. Default is True
@@ -119,5 +122,11 @@ def new(
119122
if offline:
120123
return _artifact
121124

122-
_artifact._upload(file=io.BytesIO(_serialized))
125+
_file_data = io.BytesIO(_serialized)
126+
127+
_artifact._upload(
128+
file=_file_data,
129+
timeout=upload_timeout,
130+
file_size=len(_file_data.getbuffer()),
131+
)
123132
return _artifact

simvue/api/request.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ def post(
6868
params: dict[str, str],
6969
data: typing.Any,
7070
is_json: bool = True,
71+
timeout: int | None = None,
7172
files: dict[str, typing.Any] | None = None,
7273
) -> requests.Response:
7374
"""HTTP POST with retries
@@ -102,7 +103,7 @@ def post(
102103
headers=headers,
103104
params=params,
104105
data=data_sent,
105-
timeout=DEFAULT_API_TIMEOUT,
106+
timeout=timeout,
106107
files=files,
107108
)
108109

simvue/run.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,6 @@
7070
if typing.TYPE_CHECKING:
7171
from .factory.dispatch import DispatcherBaseClass
7272

73-
UPLOAD_TIMEOUT: int = 30
7473
HEARTBEAT_INTERVAL: int = 60
7574
RESOURCES_METRIC_PREFIX: str = "resources"
7675

tests/functional/test_scenarios.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,66 @@
1+
import pathlib
12
import pytest
23
import simvue
34
import time
45
import contextlib
56
import random
7+
import tempfile
68
import threading
79
from multiprocessing import Process, Manager
810

11+
from simvue.api.objects.artifact.fetch import Artifact
12+
13+
14+
@pytest.mark.scenario
15+
@pytest.mark.parametrize(
16+
"file_size", (1, 10, 100)
17+
)
18+
def test_large_file_upload(file_size: int, create_plain_run: tuple[simvue.Run, dict]) -> None:
19+
FILE_SIZE_MB: int = file_size
20+
run, _ = create_plain_run
21+
run.update_metadata({"file_size_mb": file_size})
22+
_file = None
23+
_temp_file_name = None
24+
25+
try:
26+
with tempfile.NamedTemporaryFile(mode="w+b", delete=False) as temp_f:
27+
temp_f.seek(FILE_SIZE_MB * 1024 * 1024 - 1)
28+
temp_f.write(b'\0')
29+
temp_f.flush()
30+
temp_f.seek(0)
31+
temp_f.close()
32+
_temp_file_name = temp_f.name
33+
_input_file_size = pathlib.Path(f"{_temp_file_name}").stat().st_size
34+
run.save_file(file_path=f"{temp_f.name}", category="output", name="test_large_file_artifact")
35+
36+
run.close()
37+
38+
client = simvue.Client()
39+
40+
with tempfile.TemporaryDirectory() as tempd:
41+
client.get_artifact_as_file(
42+
run_id=run.id,
43+
name="test_large_file_artifact",
44+
output_dir=tempd
45+
)
46+
47+
_file = next(pathlib.Path(tempd).glob("*"))
48+
49+
# Assert the returned file size
50+
assert _file.stat().st_size == _input_file_size
51+
except Exception as e:
52+
_run = simvue.Run()
53+
_run.reconnect(run.id)
54+
_run.set_status("failed")
55+
raise e
56+
finally:
57+
if _file and _file.exists():
58+
_file.unlink()
59+
if _temp_file_name and (_src := pathlib.Path(_temp_file_name)).exists():
60+
_src.unlink()
61+
with contextlib.suppress(Exception):
62+
Artifact.from_name("test_large_file_artifact", run_id=run.id).delete()
63+
964

1065
@pytest.mark.scenario
1166
def test_time_multi_run_create_threshold() -> None:

0 commit comments

Comments
 (0)