Skip to content

Commit 1642bfc

Browse files
authored
Merge pull request #497 from MerginMaps/fix_download_project_archive
Improve download endpoint
2 parents aa88f8e + 03f62a3 commit 1642bfc

4 files changed

Lines changed: 137 additions & 43 deletions

File tree

server/mergin/sync/private_api_controller.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import os
55
from datetime import datetime, timedelta, timezone
66
from urllib.parse import quote
7-
from blinker import signal
87
from connexion import NoContent
98
from flask import (
109
render_template,
@@ -353,17 +352,17 @@ def download_project(id: str, version=None): # noqa: E501 # pylint: disable=W06
353352
f"attachment; filename*=UTF-8''{file_name}"
354353
)
355354
return resp
356-
357-
temp_zip_path = project_version.zip_path + ".partial"
358-
# to be safe we are not in vicious circle remove inactive partial zip
359-
if os.path.exists(temp_zip_path) and datetime.fromtimestamp(
360-
os.path.getmtime(temp_zip_path), tz=timezone.utc
361-
) < datetime.now(timezone.utc) - timedelta(
362-
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"]
363-
):
364-
move_to_tmp(temp_zip_path)
365-
366-
if not os.path.exists(temp_zip_path):
367-
create_project_version_zip.delay(project_version.id)
355+
# GET request triggers background job if no partial zip or expired one
356+
if request.method == "GET":
357+
temp_zip_path = project_version.zip_path + ".partial"
358+
# create zip if it does not exist yet or has expired
359+
partial_exists = os.path.exists(temp_zip_path)
360+
is_expired = partial_exists and datetime.fromtimestamp(
361+
os.path.getmtime(temp_zip_path), tz=timezone.utc
362+
) < datetime.now(timezone.utc) - timedelta(
363+
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"]
364+
)
365+
if not partial_exists or is_expired:
366+
create_project_version_zip.delay(project_version.id)
368367

369368
return "Project zip being prepared, please try again later", 202

server/mergin/sync/tasks.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,17 @@ def create_project_version_zip(version_id: int):
114114
return
115115

116116
zip_path = project_version.zip_path + ".partial"
117-
# already running job
118117
if os.path.exists(zip_path):
119-
return
118+
mtime = datetime.fromtimestamp(os.path.getmtime(zip_path), tz=timezone.utc)
119+
expiration_time = datetime.now(timezone.utc) - timedelta(
120+
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"]
121+
)
122+
if mtime > expiration_time:
123+
# partial zip is recent -> another job is likely running
124+
return
125+
else:
126+
# partial zip is too old -> remove and creating new one
127+
os.remove(zip_path)
120128

121129
os.makedirs(os.path.dirname(zip_path), exist_ok=True)
122130
try:

server/mergin/tests/test_celery.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import os
66
from datetime import datetime, timedelta
7+
from pathlib import Path
8+
79
from flask import current_app
810
from flask_mail import Mail
911
from unittest.mock import patch
@@ -144,16 +146,44 @@ def test_remove_deleted_project_backups(client):
144146

145147

146148
def test_create_project_version_zip(diff_project):
149+
"""Test celery tasks for creating project zip and cleaning them up"""
147150
latest_version = diff_project.get_latest_version()
148-
assert not os.path.exists(latest_version.zip_path)
151+
zip_path = Path(latest_version.zip_path)
152+
partial_zip_path = Path(latest_version.zip_path + ".partial")
153+
assert not zip_path.exists()
154+
create_project_version_zip(diff_project.latest_version)
155+
assert zip_path.exists()
156+
assert not partial_zip_path.exists()
157+
before_mtime = zip_path.stat().st_mtime
158+
# mock expired partial zip -> celery removes it and creates new zip
159+
partial_zip_path.parent.mkdir(parents=True, exist_ok=True)
160+
partial_zip_path.touch()
161+
new_time = datetime.now() - timedelta(
162+
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"] + 1
163+
)
164+
modify_file_times(partial_zip_path, new_time)
165+
create_project_version_zip(diff_project.latest_version)
166+
assert (
167+
not partial_zip_path.exists()
168+
) # after creating zip archive, celery remove partial zip
169+
after_mtime = zip_path.stat().st_mtime
170+
assert before_mtime < after_mtime
171+
# mock valid partial zip -> celery skips creating new zip and returns
172+
before_mtime = zip_path.stat().st_mtime
173+
partial_zip_path.parent.mkdir(parents=True, exist_ok=True)
174+
partial_zip_path.touch()
149175
create_project_version_zip(diff_project.latest_version)
150-
assert os.path.exists(latest_version.zip_path)
151-
assert not os.path.exists(latest_version.zip_path + ".partial")
152-
remove_projects_archives()
153-
assert os.path.exists(latest_version.zip_path)
176+
assert (
177+
partial_zip_path.exists()
178+
) # celery does not create zip archive and does not clean partial zip
179+
after_mtime = zip_path.stat().st_mtime
180+
assert before_mtime == after_mtime
181+
os.remove(partial_zip_path)
182+
remove_projects_archives() # zip is valid -> keep
183+
assert zip_path.exists()
154184
new_time = datetime.now() - timedelta(
155185
days=current_app.config["PROJECTS_ARCHIVES_EXPIRATION"] + 1
156186
)
157187
modify_file_times(latest_version.zip_path, new_time)
158-
remove_projects_archives()
188+
remove_projects_archives() # zip has expired -> remove
159189
assert not os.path.exists(latest_version.zip_path)

server/mergin/tests/test_private_project_api.py

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,16 @@
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
44

5-
import datetime
65
import json
76
import os
8-
from unittest.mock import patch
9-
7+
import shutil
108
import pytest
9+
from unittest.mock import patch
10+
from pathlib import Path
11+
from datetime import datetime, timedelta
1112
from flask import url_for
1213

13-
from ..app import db
14+
from ..app import db, current_app
1415
from ..sync.models import AccessRequest, Project, ProjectRole, RequestStatus
1516
from ..auth.models import User
1617
from ..config import Configuration
@@ -20,6 +21,7 @@
2021
login,
2122
create_project,
2223
create_workspace,
24+
modify_file_times,
2325
)
2426

2527

@@ -174,7 +176,7 @@ def test_project_access_request(client):
174176

175177
# try with inactive project
176178
rp = create_project("removed_project", test_workspace, user)
177-
rp.removed_at = datetime.datetime.utcnow()
179+
rp.removed_at = datetime.utcnow()
178180
db.session.commit()
179181

180182
resp = client.post(
@@ -193,7 +195,7 @@ def test_project_access_request(client):
193195
assert resp.status_code == 200
194196
resp = client.get("/app/project/access-requests?page=1&per_page=10")
195197
assert resp.json.get("count") == 1
196-
rp.removed_at = datetime.datetime.utcnow()
198+
rp.removed_at = datetime.utcnow()
197199
db.session.commit()
198200
access_request = AccessRequest.query.filter(
199201
AccessRequest.project_id == rp.id
@@ -375,7 +377,7 @@ def test_admin_project_list(client):
375377
assert resp.json.get("count") == 1
376378
# mark as inactive
377379
p = Project.query.get(resp.json["items"][0]["id"])
378-
p.removed_at = datetime.datetime.utcnow()
380+
p.removed_at = datetime.utcnow()
379381
p.removed_by = user.id
380382
db.session.commit()
381383

@@ -423,37 +425,72 @@ def test_admin_project_list(client):
423425

424426

425427
test_download_proj_data = [
426-
(None, 202),
427-
("v1", 202),
428-
("v100", 404),
429-
(None, 200),
428+
# zips do not exist, version not specified -> call celery task to create zip with latest version
429+
(0, 0, 0, None, 202, 1),
430+
# expired partial zip exists -> call celery task
431+
(0, 1, 1, None, 202, 1),
432+
# valid partial zip exists -> return, do not call celery
433+
(0, 1, 0, None, 202, 0),
434+
# zips do not exist, version specified -> call celery task with specified version
435+
(0, 0, 0, "v1", 202, 1),
436+
# specified version does not exist -> 404
437+
(0, 0, 0, "v100", 404, 0),
438+
# zip is ready to download
439+
(1, 0, 0, None, 200, 0),
430440
]
431441

432442

433-
@pytest.mark.parametrize("version,expected", test_download_proj_data)
443+
@pytest.mark.parametrize(
444+
"zipfile,partial,expired,version,exp_resp,exp_call", test_download_proj_data
445+
)
434446
@patch("mergin.sync.tasks.create_project_version_zip.delay")
435-
def test_download_project(mock_create_zip, client, version, expected, diff_project):
436-
if expected == 200:
437-
project_version = diff_project.get_latest_version()
438-
os.mknod(project_version.zip_path)
447+
def test_download_project(
448+
mock_create_zip,
449+
client,
450+
zipfile,
451+
partial,
452+
expired,
453+
version,
454+
exp_resp,
455+
exp_call,
456+
diff_project,
457+
):
458+
"""Test download endpoint responses and celery task calling"""
459+
# prepare initial state according to testcase
460+
project_version = diff_project.get_latest_version()
461+
if zipfile:
462+
zip_path = Path(project_version.zip_path)
463+
if zip_path.parent.exists():
464+
shutil.rmtree(zip_path.parent, ignore_errors=True)
465+
zip_path.parent.mkdir(parents=True, exist_ok=True)
466+
zip_path.touch()
467+
if partial:
468+
temp_zip_path = Path(project_version.zip_path + ".partial")
469+
shutil.rmtree(temp_zip_path.parent, ignore_errors=True)
470+
temp_zip_path.parent.mkdir(parents=True, exist_ok=True)
471+
temp_zip_path.touch()
472+
if expired:
473+
new_time = datetime.now() - timedelta(
474+
seconds=current_app.config["PARTIAL_ZIP_EXPIRATION"] + 1
475+
)
476+
modify_file_times(temp_zip_path, new_time)
439477
resp = client.get(
440478
url_for(
441479
"/app.mergin_sync_private_api_controller_download_project",
442480
id=diff_project.id,
443481
version=version if version else "",
444482
)
445483
)
446-
if expected == 200:
447-
os.remove(project_version.zip_path)
448-
assert resp.status_code == expected
449-
assert mock_create_zip.called if expected == 202 else not mock_create_zip.called
450-
if not version and expected != 200:
484+
assert resp.status_code == exp_resp
485+
assert mock_create_zip.called == exp_call
486+
if not version and exp_call:
451487
call_args, _ = mock_create_zip.call_args
452488
args = call_args[0]
453489
assert args == diff_project.latest_version
454490

455491

456492
def test_large_project_download_fail(client, diff_project):
493+
"""Test downloading too large project is refused"""
457494
resp = client.get(
458495
url_for(
459496
"/app.mergin_sync_private_api_controller_download_project",
@@ -494,4 +531,24 @@ def test_remove_abandoned_zip(mock_prepare_zip, client, diff_project):
494531
)
495532
assert mock_prepare_zip.called
496533
assert resp.status_code == 202
497-
assert not os.path.exists(partial_zip_path)
534+
535+
536+
@patch("mergin.sync.tasks.create_project_version_zip.delay")
537+
def test_download_project_request_method(mock_prepare_zip, client, diff_project):
538+
"""Test head request does not create a celery job"""
539+
resp = client.head(
540+
url_for(
541+
"/app.mergin_sync_private_api_controller_download_project",
542+
id=diff_project.id,
543+
)
544+
)
545+
assert not mock_prepare_zip.called
546+
assert resp.status_code == 202
547+
resp = client.get(
548+
url_for(
549+
"/app.mergin_sync_private_api_controller_download_project",
550+
id=diff_project.id,
551+
)
552+
)
553+
assert mock_prepare_zip.called
554+
assert resp.status_code == 202

0 commit comments

Comments
 (0)