diff --git a/deployment/community/docker-compose.yml b/deployment/community/docker-compose.yml index 3a5b2665..e983b218 100644 --- a/deployment/community/docker-compose.yml +++ b/deployment/community/docker-compose.yml @@ -23,7 +23,7 @@ services: networks: - merginmaps server: - image: lutraconsulting/merginmaps-backend:2025.2.2 + image: lutraconsulting/merginmaps-backend:2025.7.3 container_name: merginmaps-server restart: always user: 901:999 @@ -39,7 +39,7 @@ services: networks: - merginmaps celery-beat: - image: lutraconsulting/merginmaps-backend:2025.2.2 + image: lutraconsulting/merginmaps-backend:2025.7.3 container_name: celery-beat restart: always env_file: @@ -54,7 +54,7 @@ services: networks: - merginmaps celery-worker: - image: lutraconsulting/merginmaps-backend:2025.2.2 + image: lutraconsulting/merginmaps-backend:2025.7.3 container_name: celery-worker restart: always user: 901:999 @@ -73,7 +73,7 @@ services: networks: - merginmaps web: - image: lutraconsulting/merginmaps-frontend:2025.2.2 + image: lutraconsulting/merginmaps-frontend:2025.7.3 container_name: merginmaps-web restart: always depends_on: diff --git a/deployment/enterprise/.env.template b/deployment/enterprise/.env.template index 663ba5ac..b66eb37c 100644 --- a/deployment/enterprise/.env.template +++ b/deployment/enterprise/.env.template @@ -117,7 +117,7 @@ LOCAL_PROJECTS=/data # data download -#MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 # max total files size in bytes for archive download +#MAX_DOWNLOAD_ARCHIVE_SIZE=1024 * 1024 * 1024 * 10 # max total files size in bytes for archive download #USE_X_ACCEL=False # use nginx (in front of gunicorn) to serve files (https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/) USE_X_ACCEL=1 @@ -214,12 +214,8 @@ VECTOR_TILES_URL=https://tiles-ee.merginmaps.com/data/default/{z}/{x}/{y}.pbf VECTOR_TILES_STYLE_URL=https://tiles-ee.merginmaps.com//styles/default.json -#QGIS_EXTRACTOR_API_URL=http://mergin-qgis-extractor:8000 - #WMTS_SERVER_URL=http://mergin-qgis-nginx:80 -#QGIS_EXTRACTOR_TIMEOUT=60 - #OVERVIEW_MAX_FILE_SIZE=1048576 # 1MB ### Diagnostic logs from Mobile and QGIS Plugin diff --git a/deployment/enterprise/docker-compose.maps.yml b/deployment/enterprise/docker-compose.maps.yml index 24cacb43..47d8cdc0 100644 --- a/deployment/enterprise/docker-compose.maps.yml +++ b/deployment/enterprise/docker-compose.maps.yml @@ -30,7 +30,7 @@ services: - ./qgis_nginx.conf:/etc/nginx/conf.d/default.conf qgis_extractor: container_name: mergin-qgis-extractor - image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/qgis-extractor-ee:2025.1.0 + image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/qgis-extractor-ee:2025.3.0 user: 901:999 networks: - mergin-net @@ -38,5 +38,8 @@ services: - OVERVIEWS_DATA_DIR=/data - MM_WMS_TILE_BUFFER=100 - MM_WMS_AVOID_ARTIFACTS=1 + - BROKER_URL=redis://merginmaps-redis:6379/0 + - CELERY_RESULT_BACKEND=redis://merginmaps-redis:6379/0 + - 'CELERY_TASK_ROUTES={"src.maps.tasks.finish_overview": {"queue": "celery"}}' volumes: - ./map_data:/data diff --git a/deployment/enterprise/docker-compose.yml b/deployment/enterprise/docker-compose.yml index 902a66ef..a9f33023 100644 --- a/deployment/enterprise/docker-compose.yml +++ b/deployment/enterprise/docker-compose.yml @@ -5,7 +5,7 @@ networks: services: server: - image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-back:2025.5.1 + image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-back:2025.7.3 container_name: merginmaps-server restart: always user: 901:999 @@ -22,7 +22,7 @@ services: networks: - mergin web: - image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-front:2025.5.1 + image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-front:2025.7.3 container_name: merginmaps-web restart: always depends_on: @@ -52,7 +52,7 @@ services: - server celery-beat: - image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-back:2025.5.1 + image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-back:2025.7.3 container_name: merginmaps-celery-beat restart: always user: 901:999 @@ -66,7 +66,7 @@ services: - mergin celery-worker: - image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-back:2025.5.1 + image: 433835555346.dkr.ecr.eu-west-1.amazonaws.com/mergin/mergin-ee-back:2025.7.3 container_name: merginmaps-celery-worker restart: always user: 901:999 @@ -76,6 +76,8 @@ services: - ./map_data:/overviews env_file: - .prod.env + environment: + - CELERY_ROUTES={ 'qgis-extractor.*':{'queue':'extractor'} } depends_on: - db - redis diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py index 62e4c8b1..ec0568a6 100644 --- a/server/mergin/sync/models.py +++ b/server/mergin/sync/models.py @@ -1042,9 +1042,12 @@ def clear(self): """Clean up pending upload. Uploaded files and table records are removed, and another upload can start. """ - move_to_tmp(self.upload_dir, self.id) - db.session.delete(self) - db.session.commit() + try: + move_to_tmp(self.upload_dir, self.id) + db.session.delete(self) + db.session.commit() + except Exception: + logging.exception(f"Failed to clear upload.") def process_chunks( self, use_shared_chunk_dir: bool diff --git a/server/mergin/sync/public_api_v2_controller.py b/server/mergin/sync/public_api_v2_controller.py index 3e28aa40..2b7f124e 100644 --- a/server/mergin/sync/public_api_v2_controller.py +++ b/server/mergin/sync/public_api_v2_controller.py @@ -13,6 +13,7 @@ from flask_login import current_user from marshmallow import ValidationError from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.exc import ObjectDeletedError from mergin.sync.tasks import remove_transaction_chunks @@ -301,6 +302,7 @@ def create_project_version(id): upload.clear() return DataSyncError(failed_files=errors).response(422) + upload_deleted = False try: pv = ProjectVersion( project, @@ -329,15 +331,20 @@ def create_project_version(id): remove_transaction_chunks.delay(chunks_ids) logging.info( - f"Push finished for project: {project.id}, project version: {v_next_version}, upload id: {upload.id}." + f"Push finished for project: {project.id}, project version: {v_next_version}." ) project_version_created.send(pv) push_finished.send(pv) - except (psycopg2.Error, FileNotFoundError, IntegrityError) as err: + except ( + psycopg2.Error, + FileNotFoundError, + IntegrityError, + ObjectDeletedError, + ) as err: db.session.rollback() + upload_deleted = isinstance(err, ObjectDeletedError) logging.exception( - f"Failed to finish push for project: {project.id}, project version: {v_next_version}, " - f"upload id: {upload.id}.: {str(err)}" + f"Failed to finish push for project: {project.id}, project version: {v_next_version}: {str(err)}" ) if ( os.path.exists(version_dir) @@ -359,8 +366,9 @@ def create_project_version(id): move_to_tmp(version_dir) raise finally: - # remove artifacts - upload.clear() + # remove artifacts only if upload object is still valid + if not upload_deleted: + upload.clear() result = ProjectSchemaV2().dump(project) result["files"] = ProjectFileSchema( diff --git a/server/mergin/tests/test_public_api_v2.py b/server/mergin/tests/test_public_api_v2.py index e058c589..6e702f31 100644 --- a/server/mergin/tests/test_public_api_v2.py +++ b/server/mergin/tests/test_public_api_v2.py @@ -20,6 +20,7 @@ import shutil from unittest.mock import patch from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm.exc import ObjectDeletedError import pytest from datetime import datetime, timedelta, timezone import json @@ -485,6 +486,46 @@ def test_create_version_failures(client): assert response.status_code == 409 +def test_create_version_object_deleted_error(client): + """Test that ObjectDeletedError during push returns 422 without secondary exception""" + project = Project.query.filter_by( + workspace_id=test_workspace_id, name=test_project + ).first() + + data = { + "version": "v1", + "changes": { + "added": [], + "removed": [ + file_info(test_project_dir, "base.gpkg"), + ], + "updated": [], + }, + } + + # Create a real ObjectDeletedError by using internal SQLAlchemy state + def raise_object_deleted(*args, **kwargs): + # Create a minimal state-like object that ObjectDeletedError can use + class FakeState: + class_ = Upload + + def obj(self): + return None + + raise ObjectDeletedError(FakeState()) + + with patch.object( + ProjectVersion, + "__init__", + side_effect=raise_object_deleted, + ): + response = client.post(f"v2/projects/{project.id}/versions", json=data) + + # Should return 422 UploadError, not 500 from secondary exception + assert response.status_code == 422 + assert response.json["code"] == UploadError.code + + def test_upload_chunk(client): """Test pushing a chunk to a project""" project = Project.query.filter_by( diff --git a/server/mergin/version.py b/server/mergin/version.py index 10d74e1e..5a787c90 100644 --- a/server/mergin/version.py +++ b/server/mergin/version.py @@ -4,4 +4,4 @@ def get_version(): - return "2025.8.2" + return "2026.1.0" diff --git a/server/setup.py b/server/setup.py index 7a3d1939..9638a370 100644 --- a/server/setup.py +++ b/server/setup.py @@ -6,7 +6,7 @@ setup( name="mergin", - version="2025.8.2", + version="2026.1.0", url="https://github.com/MerginMaps/mergin", license="AGPL-3.0-only", author="Lutra Consulting Limited",