Skip to content

Commit 16aeab3

Browse files
authored
Merge pull request #529 from MerginMaps/dev-r84-concurrent-push
Dev r84 concurrent push
2 parents 417fbcd + 1f8ed4a commit 16aeab3

26 files changed

+1890
-453
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
projects*/
33
data/
44
mergin_db
5+
diagnostic_logs
56

67
logs
78
*.log

deployment/common/set_permissions.sh

100644100755
File mode changed.

development.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,6 @@ cd deployment/community/
7171
# Create .prod.env file from .env.template
7272
cp .env.template .prod.env
7373

74-
# Run the docker composition with the current Dockerfiles
75-
cp .env.template .prod.env
7674
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
7775

7876
# Give ownership of the ./projects folder to user that is running the gunicorn container

server/application.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
remove_projects_archives,
2828
remove_temp_files,
2929
remove_projects_backups,
30+
remove_unused_chunks,
3031
)
3132
from mergin.celery import celery, configure_celery
3233
from mergin.stats.config import Configuration
@@ -47,6 +48,7 @@
4748
"GLOBAL_WRITE",
4849
"ENABLE_SUPERADMIN_ASSIGNMENT",
4950
"DIAGNOSTIC_LOGS_URL",
51+
"V2_PUSH_ENABLED",
5052
]
5153
)
5254
register_stats(application)
@@ -85,4 +87,9 @@ def setup_periodic_tasks(sender, **kwargs):
8587
crontab(hour=3, minute=0),
8688
remove_projects_archives,
8789
name="remove old project archives",
90+
),
91+
sender.add_periodic_task(
92+
crontab(hour="*/4", minute=0),
93+
remove_unused_chunks,
94+
name="clean up of outdated chunks",
8895
)

server/mergin/app.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,17 @@
1212
from sqlalchemy.schema import MetaData
1313
from flask_sqlalchemy import SQLAlchemy
1414
from flask_marshmallow import Marshmallow
15-
from flask import json, jsonify, request, abort, current_app, Flask, Request, Response
15+
from flask import (
16+
json,
17+
jsonify,
18+
make_response,
19+
request,
20+
abort,
21+
current_app,
22+
Flask,
23+
Request,
24+
Response,
25+
)
1626
from flask_login import current_user, LoginManager
1727
from flask_wtf.csrf import generate_csrf, CSRFProtect
1828
from flask_migrate import Migrate
@@ -25,7 +35,7 @@
2535
import time
2636
import traceback
2737
from werkzeug.exceptions import HTTPException
28-
from typing import List, Dict, Optional
38+
from typing import List, Dict, Optional, Tuple
2939

3040
from .sync.utils import get_blacklisted_dirs, get_blacklisted_files
3141
from .config import Configuration
@@ -347,6 +357,16 @@ def ping(): # pylint: disable=W0612
347357
)
348358
return status, 200
349359

360+
# reading raw input stream not supported in connexion so far
361+
# https://github.com/zalando/connexion/issues/592
362+
# and as workaround we use custom Flask endpoint in create_app function
363+
@app.route("/v2/projects/<id>/chunks", methods=["POST"])
364+
@auth_required
365+
def upload_chunk_v2(id: str):
366+
from .sync import public_api_v2_controller
367+
368+
return public_api_v2_controller.upload_chunk(id)
369+
350370
# reading raw input stream not supported in connexion so far
351371
# https://github.com/zalando/connexion/issues/592
352372
# and as workaround we use custom Flask endpoint in create_app function
@@ -485,6 +505,12 @@ class ResponseError:
485505
def to_dict(self) -> Dict:
486506
return dict(code=self.code, detail=self.detail + f" ({self.code})")
487507

508+
def response(self, status_code: int) -> Tuple[Response, int]:
509+
"""Returns a custom error response with the given code."""
510+
response = make_response(jsonify(self.to_dict()), status_code)
511+
response.headers["Content-Type"] = "application/problem+json"
512+
return response, status_code
513+
488514

489515
def whitespace_filter(obj):
490516
return obj.strip() if isinstance(obj, str) else obj

server/mergin/sync/commands.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from flask import Flask, current_app
1212
from sqlalchemy import func
1313

14-
from .files import UploadChanges
1514
from ..app import db
1615
from .models import Project, ProjectVersion
1716
from .utils import split_project_path
@@ -55,8 +54,8 @@ def create(name, namespace, username): # pylint: disable=W0612
5554
p = Project(**project_params)
5655
p.updated = datetime.utcnow()
5756
db.session.add(p)
58-
changes = UploadChanges(added=[], updated=[], removed=[])
59-
pv = ProjectVersion(p, 0, user.id, changes, "127.0.0.1")
57+
pv = ProjectVersion(p, 0, user.id, [], "127.0.0.1")
58+
pv.project = p
6059
db.session.add(pv)
6160
db.session.commit()
6261
os.makedirs(p.storage.project_dir, exist_ok=True)

server/mergin/sync/config.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,14 @@ class Configuration(object):
6464
)
6565
# in seconds, older unfinished zips are moved to temp
6666
PARTIAL_ZIP_EXPIRATION = config("PARTIAL_ZIP_EXPIRATION", default=600, cast=int)
67+
# whether new push is allowed
68+
V2_PUSH_ENABLED = config("V2_PUSH_ENABLED", default=True, cast=bool)
69+
# directory for file chunks
70+
UPLOAD_CHUNKS_DIR = config(
71+
"UPLOAD_CHUNKS_DIR",
72+
default=os.path.join(LOCAL_PROJECTS, "chunks"),
73+
)
74+
# time in seconds after chunks are permanently deleted (1 day)
75+
UPLOAD_CHUNKS_EXPIRATION = config(
76+
"UPLOAD_CHUNKS_EXPIRATION", default=86400, cast=int
77+
)

server/mergin/sync/db_events.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from flask import current_app, abort
77
from sqlalchemy import event
88

9+
from .models import ProjectVersion
10+
from .tasks import optimize_storage
911
from ..app import db
1012

1113

@@ -14,9 +16,17 @@ def check(session):
1416
abort(503, "Service unavailable due to maintenance, please try later")
1517

1618

19+
def optimize_gpgk_storage(mapper, connection, project_version):
20+
# do not optimize on every version, every 10th is just fine
21+
if not project_version.name % 10:
22+
optimize_storage.delay(project_version.project_id)
23+
24+
1725
def register_events():
1826
event.listen(db.session, "before_commit", check)
27+
event.listen(ProjectVersion, "after_insert", optimize_gpgk_storage)
1928

2029

2130
def remove_events():
2231
event.remove(db.session, "before_commit", check)
32+
event.listen(ProjectVersion, "after_insert", optimize_gpgk_storage)

server/mergin/sync/errors.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
44

55
from typing import List, Dict
6+
7+
from .config import Configuration
68
from ..app import ResponseError
79

10+
MAX_CHUNK_SIZE = Configuration.MAX_CHUNK_SIZE / 1024 / 1024
11+
812

913
class UpdateProjectAccessError(ResponseError):
1014
code = "UpdateProjectAccessError"
@@ -39,3 +43,55 @@ def to_dict(self) -> Dict:
3943
class ProjectLocked(ResponseError):
4044
code = "ProjectLocked"
4145
detail = "The project is currently locked and you cannot make changes to it"
46+
47+
48+
class DataSyncError(ResponseError):
49+
code = "DataSyncError"
50+
detail = "There are either corrupted files or it is not possible to create version with provided geopackage data"
51+
52+
def __init__(self, failed_files: Dict):
53+
self.failed_files = failed_files
54+
55+
def to_dict(self) -> Dict:
56+
data = super().to_dict()
57+
data["failed_files"] = self.failed_files
58+
return data
59+
60+
61+
class ProjectVersionExists(ResponseError):
62+
code = "ProjectVersionExists"
63+
detail = "Project version mismatch"
64+
65+
def __init__(self, client_version: int, server_version: int):
66+
self.client_version = client_version
67+
self.server_version = server_version
68+
69+
def to_dict(self) -> Dict:
70+
data = super().to_dict()
71+
data["client_version"] = f"v{self.client_version}"
72+
data["server_version"] = f"v{self.server_version}"
73+
return data
74+
75+
76+
class AnotherUploadRunning(ResponseError):
77+
code = "AnotherUploadRunning"
78+
detail = "Another process is running"
79+
80+
81+
class UploadError(ResponseError):
82+
code = "UploadError"
83+
detail = "Project version could not be created"
84+
85+
def __init__(self, error: str = None):
86+
self.error = error
87+
88+
def to_dict(self) -> Dict:
89+
data = super().to_dict()
90+
if self.error is not None:
91+
data["detail"] = self.error + f" ({self.code})"
92+
return data
93+
94+
95+
class BigChunkError(ResponseError):
96+
code = "BigChunkError"
97+
detail = f"Chunk size exceeds maximum allowed size {MAX_CHUNK_SIZE} MB"

0 commit comments

Comments
 (0)