Skip to content

Commit 04553d5

Browse files
authored
Merge pull request #24 from gdcc/include-tab-ingest
Add `tabIngest` field and `500` error workaround
2 parents 7ca980c + f3f8153 commit 04553d5

14 files changed

Lines changed: 584 additions & 29 deletions

File tree

.github/workflows/test.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ jobs:
2020
env:
2121
PORT: 8080
2222
steps:
23-
- name: "Checkout"
24-
uses: "actions/checkout@v4"
23+
- name: 'Checkout'
24+
uses: 'actions/checkout@v4'
2525
- name: Run Dataverse Action
2626
id: dataverse
2727
uses: gdcc/dataverse-action@main
@@ -38,6 +38,7 @@ jobs:
3838
env:
3939
API_TOKEN: ${{ steps.dataverse.outputs.api_token }}
4040
BASE_URL: ${{ steps.dataverse.outputs.base_url }}
41-
DVUPLOADER_TESTING: "true"
41+
DVUPLOADER_TESTING: 'true'
42+
TEST_ROWS: 100000
4243
run: |
4344
python3 -m poetry run pytest

README.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Alternatively, you can also supply a `config` file that contains all necessary i
9393
* `mimetype`: Mimetype of the file.
9494
* `categories`: Optional list of categories to assign to the file.
9595
* `restrict`: Boolean to indicate that this is a restricted file. Defaults to False.
96+
* `tabIngest`: Boolean to indicate that the file should be ingested as a tab-separated file. Defaults to True.
9697

9798
In the following example, we upload three files to a Dataverse instance. The first file is uploaded to the root directory of the dataset, while the other two files are uploaded to the directory `some/dir`.
9899

@@ -115,6 +116,61 @@ The `config` file can then be used as follows:
115116
dvuploader --config-path config.yml
116117
```
117118

119+
### Environment variables
120+
121+
DVUploader provides several environment variables that allow you to control retry logic and upload size limits. These can be set either through environment variables directly or programmatically using the `config` function.
122+
123+
**Available Environment Variables:**
124+
- `DVUPLOADER_MAX_RETRIES`: Maximum number of retry attempts (default: 15)
125+
- `DVUPLOADER_MAX_RETRY_TIME`: Maximum wait time between retries in seconds (default: 240)
126+
- `DVUPLOADER_MIN_RETRY_TIME`: Minimum wait time between retries in seconds (default: 1)
127+
- `DVUPLOADER_RETRY_MULTIPLIER`: Multiplier for exponential backoff (default: 0.1)
128+
- `DVUPLOADER_MAX_PKG_SIZE`: Maximum package size in bytes (default: 2GB)
129+
130+
**Setting via environment:**
131+
```bash
132+
export DVUPLOADER_MAX_RETRIES=20
133+
export DVUPLOADER_MAX_RETRY_TIME=300
134+
export DVUPLOADER_MIN_RETRY_TIME=2
135+
export DVUPLOADER_RETRY_MULTIPLIER=0.2
136+
export DVUPLOADER_MAX_PKG_SIZE=3221225472 # 3GB
137+
```
138+
139+
**Setting programmatically:**
140+
```python
141+
import dvuploader as dv
142+
143+
# Configure the uploader settings
144+
dv.config(
145+
max_retries=20,
146+
max_retry_time=300,
147+
min_retry_time=2,
148+
retry_multiplier=0.2,
149+
max_package_size=3 * 1024**3 # 3GB
150+
)
151+
152+
# Continue with your upload as normal
153+
files = [dv.File(filepath="./data.csv")]
154+
dvuploader = dv.DVUploader(files=files)
155+
# ... rest of your upload code
156+
```
157+
158+
The retry logic uses exponential backoff which ensures that subsequent retries will be longer, but won't exceed exceed `max_retry_time`. This is particularly useful when dealing with native uploads that may be subject to intermediate locks on the Dataverse side.
159+
160+
## Troubleshooting
161+
162+
#### `500` error and `OptimisticLockException`
163+
164+
When uploading multiple tabular files, you might encounter a `500` error and a `OptimisticLockException` upon the file registration step. This has been discussed in https://github.com/IQSS/dataverse/issues/11265 and is due to the fact that intermediate locks prevent the file registration step from completing.
165+
166+
A workaround is to set the `tabIngest` flag to `False` for all files that are to be uploaded. This will cause the files not be ingested but will avoid the intermediate locks.
167+
168+
```python
169+
dv.File(filepath="tab_file.csv", tab_ingest=False)
170+
```
171+
172+
Please be aware that your tabular files will not be ingested as such but will be uploaded in their native format. You can utilize [pyDataverse](https://github.com/gdcc/pyDataverse/blob/693d0ff8d2849eccc32f9e66228ee8976109881a/pyDataverse/api.py#L2475) to ingest the files after they have been uploaded.
173+
118174
## Development
119175

120176
To install the development dependencies, run the following command:

dvuploader/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .dvuploader import DVUploader # noqa: F401
22
from .file import File # noqa: F401
33
from .utils import add_directory # noqa: F401
4+
from .config import config # noqa: F401
45

56
import nest_asyncio
67

dvuploader/config.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import os
2+
3+
4+
def config(
5+
max_retries: int = 15,
6+
max_retry_time: int = 240,
7+
min_retry_time: int = 1,
8+
retry_multiplier: float = 0.1,
9+
max_package_size: int = 2 * 1024**3,
10+
):
11+
"""This function sets the environment variables for the dvuploader package.
12+
13+
Use this function to set the environment variables for the dvuploader package,
14+
which controls the behavior of the package. This is particularly useful when
15+
you want to be more loose on the handling of the retry logic and upload size.
16+
17+
Retry logic:
18+
Native uploads in particular may be subject to intermediate locks
19+
on the Dataverse side, which may cause the upload to fail. We provide
20+
and exponential backoff mechanism to deal with this.
21+
22+
The exponential backoff is controlled by the following environment variables:
23+
- DVUPLOADER_MAX_RETRIES: The maximum number of retries.
24+
- DVUPLOADER_MAX_RETRY_TIME: The maximum retry time.
25+
- DVUPLOADER_MIN_RETRY_TIME: The minimum retry time.
26+
- DVUPLOADER_RETRY_MULTIPLIER: The retry multiplier.
27+
28+
The recursive formula for the wait time is:
29+
wait_time = min_retry_time * retry_multiplier^n
30+
where n is the number of retries.
31+
32+
The wait time will not exceed max_retry_time.
33+
34+
Upload size:
35+
The maximum upload size is controlled by the following environment variable:
36+
- DVUPLOADER_MAX_PKG_SIZE: The maximum package size.
37+
38+
The default maximum package size is 2GB, but this can be changed by
39+
setting the DVUPLOADER_MAX_PKG_SIZE environment variable.
40+
41+
We recommend not to exceed 2GB, as this is the maximum size supported
42+
by Dataverse and beyond that the risk of failure increases.
43+
44+
Args:
45+
max_retries (int): The maximum number of retries.
46+
max_retry_time (int): The maximum retry time.
47+
min_retry_time (int): The minimum retry time.
48+
retry_multiplier (float): The retry multiplier.
49+
max_package_size (int): The maximum package size.
50+
"""
51+
52+
os.environ["DVUPLOADER_MAX_RETRIES"] = str(max_retries)
53+
os.environ["DVUPLOADER_MAX_RETRY_TIME"] = str(max_retry_time)
54+
os.environ["DVUPLOADER_MIN_RETRY_TIME"] = str(min_retry_time)
55+
os.environ["DVUPLOADER_RETRY_MULTIPLIER"] = str(retry_multiplier)
56+
os.environ["DVUPLOADER_MAX_PKG_SIZE"] = str(max_package_size)

dvuploader/directupload.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,6 @@ async def _upload_multipart(
337337
)
338338

339339
file.apply_checksum()
340-
print(file.checksum)
341340

342341
return True, storage_identifier
343342

@@ -556,17 +555,21 @@ async def _add_files_to_ds(
556555
novel_json_data = _prepare_registration(files, use_replace=False)
557556
replace_json_data = _prepare_registration(files, use_replace=True)
558557

559-
await _multipart_json_data_request(
560-
session=session,
561-
json_data=novel_json_data,
562-
url=novel_url,
563-
)
558+
if novel_json_data:
559+
# Register new files, if any
560+
await _multipart_json_data_request(
561+
session=session,
562+
json_data=novel_json_data,
563+
url=novel_url,
564+
)
564565

565-
await _multipart_json_data_request(
566-
session=session,
567-
json_data=replace_json_data,
568-
url=replace_url,
569-
)
566+
if replace_json_data:
567+
# Register replacement files, if any
568+
await _multipart_json_data_request(
569+
session=session,
570+
json_data=replace_json_data,
571+
url=replace_url,
572+
)
570573

571574
progress.update(pbar, advance=1)
572575

dvuploader/file.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ class File(BaseModel):
4545
handler: Union[BytesIO, StringIO, IO, None] = Field(default=None, exclude=True)
4646
description: str = ""
4747
directory_label: str = Field(default="", alias="directoryLabel")
48-
mimeType: str = "text/plain"
48+
mimeType: str = "application/octet-stream"
4949
categories: List[str] = ["DATA"]
5050
restrict: bool = False
5151
checksum_type: ChecksumTypes = Field(default=ChecksumTypes.MD5, exclude=True)
@@ -54,6 +54,7 @@ class File(BaseModel):
5454
checksum: Optional[Checksum] = None
5555
to_replace: bool = False
5656
file_id: Optional[Union[str, int]] = Field(default=None, alias="fileToReplaceId")
57+
tab_ingest: bool = Field(default=True, alias="tabIngest")
5758

5859
_size: int = PrivateAttr(default=0)
5960

dvuploader/nativeupload.py

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import asyncio
22
from io import BytesIO
3+
from pathlib import Path
34
import httpx
45
import json
56
import os
67
import tempfile
8+
import rich
79
import tenacity
810
from typing import List, Optional, Tuple, Dict
911

@@ -13,12 +15,44 @@
1315
from dvuploader.packaging import distribute_files, zip_files
1416
from dvuploader.utils import build_url, retrieve_dataset_files
1517

18+
##### CONFIGURATION #####
19+
20+
# Based on MAX_RETRIES, we will wait between 0.3 and 120 seconds between retries:
21+
# Exponential recursion: 0.1 * 2^n
22+
#
23+
# This will exponentially increase the wait time between retries.
24+
# The max wait time is 240 seconds per retry though.
1625
MAX_RETRIES = int(os.environ.get("DVUPLOADER_MAX_RETRIES", 15))
26+
MAX_RETRY_TIME = int(os.environ.get("DVUPLOADER_MAX_RETRY_TIME", 60))
27+
MIN_RETRY_TIME = int(os.environ.get("DVUPLOADER_MIN_RETRY_TIME", 1))
28+
RETRY_MULTIPLIER = float(os.environ.get("DVUPLOADER_RETRY_MULTIPLIER", 0.1))
29+
RETRY_STRAT = tenacity.wait_exponential(
30+
multiplier=RETRY_MULTIPLIER,
31+
min=MIN_RETRY_TIME,
32+
max=MAX_RETRY_TIME,
33+
)
34+
35+
assert isinstance(MAX_RETRIES, int), "DVUPLOADER_MAX_RETRIES must be an integer"
36+
assert isinstance(MAX_RETRY_TIME, int), "DVUPLOADER_MAX_RETRY_TIME must be an integer"
37+
assert isinstance(MIN_RETRY_TIME, int), "DVUPLOADER_MIN_RETRY_TIME must be an integer"
38+
assert isinstance(RETRY_MULTIPLIER, float), (
39+
"DVUPLOADER_RETRY_MULTIPLIER must be a float"
40+
)
41+
42+
##### END CONFIGURATION #####
43+
1744
NATIVE_UPLOAD_ENDPOINT = "/api/datasets/:persistentId/add"
1845
NATIVE_REPLACE_ENDPOINT = "/api/files/{FILE_ID}/replace"
1946
NATIVE_METADATA_ENDPOINT = "/api/files/{FILE_ID}/metadata"
2047

21-
assert isinstance(MAX_RETRIES, int), "DVUPLOADER_MAX_RETRIES must be an integer"
48+
TABULAR_EXTENSIONS = [
49+
"csv",
50+
"tsv",
51+
]
52+
53+
##### ERROR MESSAGES #####
54+
55+
ZIP_LIMIT_MESSAGE = "The number of files in the zip archive is over the limit"
2256

2357

2458
async def native_upload(
@@ -174,8 +208,9 @@ def _reset_progress(
174208

175209

176210
@tenacity.retry(
177-
wait=tenacity.wait_fixed(0.5),
211+
wait=RETRY_STRAT,
178212
stop=tenacity.stop_after_attempt(MAX_RETRIES),
213+
retry=tenacity.retry_if_exception_type((httpx.HTTPStatusError,)),
179214
)
180215
async def _single_native_upload(
181216
session: httpx.AsyncClient,
@@ -213,7 +248,11 @@ async def _single_native_upload(
213248
json_data = _get_json_data(file)
214249

215250
files = {
216-
"file": (file.file_name, file.handler, file.mimeType),
251+
"file": (
252+
file.file_name,
253+
file.handler,
254+
file.mimeType,
255+
),
217256
"jsonData": (
218257
None,
219258
BytesIO(json.dumps(json_data).encode()),
@@ -226,9 +265,20 @@ async def _single_native_upload(
226265
files=files, # type: ignore
227266
)
228267

268+
if response.status_code == 400 and response.json()["message"].startswith(
269+
ZIP_LIMIT_MESSAGE
270+
):
271+
# Explicitly handle the zip limit error, because otherwise we will run into
272+
# unnecessary retries.
273+
raise ValueError(
274+
f"Could not upload file '{file.file_name}' due to zip limit:\n{response.json()['message']}"
275+
)
276+
277+
# Any other error is re-raised and the error will be handled by the retry logic.
229278
response.raise_for_status()
230279

231280
if response.status_code == 200:
281+
# If we did well, update the progress bar.
232282
progress.update(pbar, advance=file._size, complete=file._size)
233283

234284
# Wait to avoid rate limiting
@@ -238,7 +288,6 @@ async def _single_native_upload(
238288

239289
# Wait to avoid rate limiting
240290
await asyncio.sleep(1.0)
241-
242291
return False, {"message": "Failed to upload file"}
243292

244293

@@ -294,14 +343,23 @@ async def _update_metadata(
294343
dv_path = os.path.join(file.directory_label, file.file_name) # type: ignore
295344

296345
try:
297-
file_id = file_mapping[dv_path]
346+
if _tab_extension(dv_path) in file_mapping:
347+
file_id = file_mapping[_tab_extension(dv_path)]
348+
elif file.file_name and _is_zip(file.file_name):
349+
# When the file is a zip it will be unpacked and thus
350+
# the expected file name of the zip will not be in the
351+
# dataset, since it has been unpacked.
352+
continue
353+
else:
354+
file_id = file_mapping[dv_path]
298355
except KeyError:
299-
raise ValueError(
356+
rich.print(
300357
(
301358
f"File {dv_path} not found in Dataverse repository.",
302-
"This may be due to the file not being uploaded to the repository.",
359+
"This may be due to the file not being uploaded to the repository:",
303360
)
304361
)
362+
continue
305363

306364
task = _update_single_metadata(
307365
session=session,
@@ -315,7 +373,7 @@ async def _update_metadata(
315373

316374

317375
@tenacity.retry(
318-
wait=tenacity.wait_fixed(0.3),
376+
wait=RETRY_STRAT,
319377
stop=tenacity.stop_after_attempt(MAX_RETRIES),
320378
)
321379
async def _update_single_metadata(
@@ -356,6 +414,7 @@ async def _update_single_metadata(
356414
if response.status_code == 200:
357415
return
358416
else:
417+
print(response.json())
359418
await asyncio.sleep(1.0)
360419

361420
raise ValueError(f"Failed to update metadata for file {file.file_name}.")
@@ -407,3 +466,17 @@ def _create_file_id_path_mapping(files):
407466
mapping[path] = file["id"]
408467

409468
return mapping
469+
470+
471+
def _tab_extension(path: str) -> str:
472+
"""
473+
Adds a tabular extension to the path if it is not already present.
474+
"""
475+
return str(Path(path).with_suffix(".tab"))
476+
477+
478+
def _is_zip(file_name: str) -> bool:
479+
"""
480+
Checks if a file name ends with a zip extension.
481+
"""
482+
return file_name.endswith(".zip")

0 commit comments

Comments
 (0)