Skip to content

Commit ad99574

Browse files
authored
Merge pull request #610 from MerginMaps/rework_concurrent_upload
rework concurrent upload
2 parents a0ac896 + 683eaf7 commit ad99574

12 files changed

Lines changed: 455 additions & 330 deletions

server/mergin/sync/models.py

Lines changed: 167 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
#
33
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
44
from __future__ import annotations
5+
from contextlib import contextmanager
56
import json
67
import logging
78
import os
9+
import threading
810
import time
911
import uuid
10-
from datetime import datetime, timedelta
12+
from datetime import datetime, timedelta, timezone
1113
from enum import Enum
1214
from typing import Optional, List, Dict, Set, Tuple
1315
from dataclasses import dataclass, asdict
@@ -17,11 +19,11 @@
1719
from flask_login import current_user
1820
from pygeodiff import GeoDiff
1921
from sqlalchemy import text, null, desc, nullslast, tuple_
20-
from sqlalchemy.dialects.postgresql import ARRAY, BIGINT, UUID, JSONB, ENUM
22+
from sqlalchemy.dialects.postgresql import ARRAY, BIGINT, UUID, JSONB, ENUM, insert
2123
from sqlalchemy.types import String
2224
from sqlalchemy.ext.hybrid import hybrid_property
2325
from pygeodiff.geodifflib import GeoDiffLibError, GeoDiffLibConflictError
24-
from flask import current_app
26+
from flask import Flask, current_app
2527

2628
from .files import (
2729
DeltaChangeMerged,
@@ -44,7 +46,6 @@
4446
LOG_BASE,
4547
Checkpoint,
4648
generate_checksum,
47-
Toucher,
4849
get_chunk_location,
4950
get_project_path,
5051
is_supported_type,
@@ -1805,6 +1806,11 @@ class Upload(db.Model):
18051806
db.Integer, db.ForeignKey("user.id", ondelete="CASCADE"), nullable=True
18061807
)
18071808
created = db.Column(db.DateTime, default=datetime.utcnow)
1809+
# last ping time to determine if upload is still active
1810+
last_ping = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
1811+
transaction_id = db.Column(
1812+
UUID(as_uuid=True), unique=True, nullable=False, index=True
1813+
)
18081814

18091815
user = db.relationship("User")
18101816
project = db.relationship(
@@ -1822,28 +1828,173 @@ def __init__(self, project: Project, version: int, changes: dict, user_id: int):
18221828
self.version = version
18231829
self.changes = ChangesSchema().dump(changes)
18241830
self.user_id = user_id
1831+
self.transaction_id = str(uuid.uuid4())
1832+
1833+
@classmethod
1834+
def create_upload(
1835+
cls, project_id: str, version: int, changes: dict, user_id: int
1836+
) -> Upload | None:
1837+
"""Create upload session, it can either create a new record or handover an existing one but with new transaction id
1838+
Old transaction folder is removed and new one is created.
1839+
"""
1840+
now = datetime.now(timezone.utc).replace(tzinfo=None)
1841+
expiration = current_app.config["LOCKFILE_EXPIRATION"]
1842+
new_tx_id = str(uuid.uuid4())
1843+
1844+
# CTE captures the existing row's transaction_id BEFORE the upsert (pre-statement snapshot)
1845+
# NULL in RETURNING means fresh INSERT, non-NULL means we took over a stale upload
1846+
existing_cte = (
1847+
db.select(Upload.transaction_id)
1848+
.where(
1849+
Upload.project_id == project_id,
1850+
Upload.version == version,
1851+
)
1852+
.cte("existing")
1853+
)
1854+
1855+
stmt = (
1856+
insert(Upload)
1857+
.values(
1858+
id=str(uuid.uuid4()),
1859+
transaction_id=new_tx_id,
1860+
project_id=project_id,
1861+
version=version,
1862+
user_id=user_id,
1863+
last_ping=now,
1864+
changes=ChangesSchema().dump(changes),
1865+
)
1866+
.add_cte(existing_cte)
1867+
)
1868+
1869+
upsert_stmt = stmt.on_conflict_do_update(
1870+
constraint="uq_upload_project_id",
1871+
set_={
1872+
"transaction_id": new_tx_id,
1873+
"user_id": user_id,
1874+
"last_ping": now,
1875+
"changes": ChangesSchema().dump(changes),
1876+
},
1877+
# ONLY update if the existing row is stale
1878+
where=(Upload.last_ping < (now - timedelta(seconds=expiration))),
1879+
)
1880+
1881+
upsert_stmt = upsert_stmt.returning(
1882+
Upload,
1883+
db.select(existing_cte.c.transaction_id)
1884+
.scalar_subquery()
1885+
.label("old_transaction_id"),
1886+
)
1887+
1888+
result = db.session.execute(upsert_stmt).fetchone()
1889+
db.session.commit()
1890+
1891+
# if nothing returned, it means the WHERE clause failed (active upload)
1892+
if not result:
1893+
return
1894+
1895+
upload = result.Upload
1896+
old_transaction_id = result.old_transaction_id
1897+
1898+
try:
1899+
os.makedirs(upload.upload_dir)
1900+
1901+
# old_transaction_id is NULL on fresh INSERT, set to old UUID when taking over a stale upload
1902+
if old_transaction_id:
1903+
upload.project.sync_failed(
1904+
"", "push_lost", "Push artefact removed by subsequent push", user_id
1905+
)
1906+
if os.path.exists(
1907+
os.path.join(
1908+
upload.project.storage.project_dir,
1909+
"tmp",
1910+
str(old_transaction_id),
1911+
)
1912+
):
1913+
move_to_tmp(
1914+
os.path.join(
1915+
upload.project.storage.project_dir,
1916+
"tmp",
1917+
str(old_transaction_id),
1918+
),
1919+
str(old_transaction_id),
1920+
)
1921+
except OSError as err:
1922+
# filesystem setup failed after the DB row was already committed.
1923+
# delete the row immediately so the next attempt isn't blocked until expiration.
1924+
db.session.delete(upload)
1925+
db.session.commit()
1926+
logging.error(f"Failed to create upload directory: {err}")
1927+
return
1928+
1929+
return upload
18251930

18261931
@property
18271932
def upload_dir(self):
1828-
return os.path.join(self.project.storage.project_dir, "tmp", self.id)
1933+
return os.path.join(
1934+
self.project.storage.project_dir, "tmp", str(self.transaction_id)
1935+
)
18291936

1830-
@property
1831-
def lockfile(self):
1832-
return os.path.join(self.upload_dir, "lockfile")
1833-
1834-
def is_active(self):
1835-
"""Check if upload is still active because there was a ping (lockfile update) from underlying process"""
1836-
return os.path.exists(self.lockfile) and (
1837-
time.time() - os.path.getmtime(self.lockfile)
1838-
< current_app.config["LOCKFILE_EXPIRATION"]
1937+
def _heartbeat_task(self, app: Flask, stop_event: threading.Event, timeout: int):
1938+
"""
1939+
Background task: Runs as a Thread, it is compatible with Sync (direct) or Gevent (monkey-patch) worker type.
1940+
Uses a fresh engine connection to stay pool-efficient.
1941+
"""
1942+
# manual context push is required for background execution
1943+
with app.app_context():
1944+
while not stop_event.is_set():
1945+
try:
1946+
# db.engine.begin() is efficient and isolated, it immediately returns a connection to the pool
1947+
with db.engine.begin() as conn:
1948+
conn.execute(
1949+
db.text(
1950+
"UPDATE upload SET last_ping = NOW() WHERE id = :id"
1951+
),
1952+
{"id": self.id},
1953+
)
1954+
except Exception as e:
1955+
logging.exception(
1956+
f"Upload heartbeat failed for ID {self.project_id} and version {self.version}: {e}"
1957+
)
1958+
1959+
# wait for x seconds, but wake up immediately if stop_event is set
1960+
stop_event.wait(timeout)
1961+
1962+
@contextmanager
1963+
def heartbeat(self, timeout: int = 5):
1964+
"""
1965+
Context manager to be used inside a Flask route.
1966+
1967+
Example of usage:
1968+
-----------------
1969+
with upload.heartbeat(interval):
1970+
do_something_slow
1971+
"""
1972+
# we need to pass a real Flask app object to the thread
1973+
app = current_app._get_current_object()
1974+
stop_event = threading.Event()
1975+
1976+
bg = threading.Thread(
1977+
target=self._heartbeat_task, args=(app, stop_event, timeout), daemon=True
18391978
)
18401979

1980+
bg.start()
1981+
try:
1982+
yield
1983+
finally:
1984+
# signal the loop to stop
1985+
stop_event.set()
1986+
1987+
# wait for the task to finish its last SQL call.
1988+
# in Gevent, this yields to other requests (non-blocking), while in Sync, this blocks the current thread for up to 2s
1989+
# this is to protect main thread / greenlet from zombie bg processes
1990+
bg.join(timeout=2)
1991+
18411992
def clear(self):
18421993
"""Clean up pending upload.
18431994
Uploaded files and table records are removed, and another upload can start.
18441995
"""
18451996
try:
1846-
move_to_tmp(self.upload_dir, self.id)
1997+
move_to_tmp(self.upload_dir, str(self.transaction_id))
18471998
db.session.delete(self)
18481999
db.session.commit()
18492000
except Exception:
@@ -1864,7 +2015,7 @@ def process_chunks(
18642015
to_remove = [i.path for i in file_changes if i.change == PushChangeType.DELETE]
18652016
current_files = [f for f in self.project.files if f.path not in to_remove]
18662017

1867-
with Toucher(self.lockfile, 5):
2018+
with self.heartbeat(5):
18682019
for f in file_changes:
18692020
if f.change == PushChangeType.DELETE:
18702021
continue

server/mergin/sync/permissions.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -271,17 +271,18 @@ def check_project_permissions(
271271
return None
272272

273273

274-
def get_upload(transaction_id):
275-
upload = Upload.query.get_or_404(transaction_id)
274+
def get_upload_or_fail(transaction_id: str) -> Upload:
275+
if not is_valid_uuid(transaction_id):
276+
abort(404)
277+
upload = Upload.query.filter_by(transaction_id=transaction_id).first_or_404()
276278
# upload to 'removed' projects is forbidden
277279
if upload.project.removed_at:
278280
abort(404)
279281

280282
if upload.user_id != current_user.id:
281283
abort(403, "You do not have permissions for ongoing upload")
282284

283-
upload_dir = os.path.join(upload.project.storage.project_dir, "tmp", transaction_id)
284-
return upload, upload_dir
285+
return upload
285286

286287

287288
def projects_query(permission, as_admin=True, public=True):

server/mergin/sync/public_api.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -699,7 +699,7 @@ paths:
699699
- do integrity check comparing uploaded file sizes with what was expected
700700
- move uploaded files to new version dir and applying sync changes (e.g. geodiff apply_changeset)
701701
- bump up version in database
702-
- remove artifacts (chunks, lockfile) by moving them to tmp directory"
702+
- remove artifacts (chunks) by moving them to tmp directory"
703703
operationId: push_finish
704704
parameters:
705705
- name: transaction_id

0 commit comments

Comments
 (0)