22#
33# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
44from __future__ import annotations
5+ from contextlib import contextmanager
56import json
67import logging
78import os
9+ import threading
810import time
911import uuid
10- from datetime import datetime , timedelta
12+ from datetime import datetime , timedelta , timezone
1113from enum import Enum
1214from typing import Optional , List , Dict , Set , Tuple
1315from dataclasses import dataclass , asdict
1719from flask_login import current_user
1820from pygeodiff import GeoDiff
1921from 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
2123from sqlalchemy .types import String
2224from sqlalchemy .ext .hybrid import hybrid_property
2325from pygeodiff .geodifflib import GeoDiffLibError , GeoDiffLibConflictError
24- from flask import current_app
26+ from flask import Flask , current_app
2527
2628from .files import (
2729 DeltaChangeMerged ,
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
0 commit comments