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
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,8 @@ 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 )
18081811
18091812 user = db .relationship ("User" )
18101813 project = db .relationship (
@@ -1827,17 +1830,67 @@ def __init__(self, project: Project, version: int, changes: dict, user_id: int):
18271830 def upload_dir (self ):
18281831 return os .path .join (self .project .storage .project_dir , "tmp" , self .id )
18291832
1830- @property
1831- def lockfile (self ):
1832- return os .path .join (self .upload_dir , "lockfile" )
1833-
18341833 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" ]
1834+ """Check if upload is still active because there was a ping from underlying process"""
1835+ return datetime .now (tz = timezone .utc ) < self .last_ping .replace (
1836+ tzinfo = timezone .utc
1837+ ) + timedelta (seconds = current_app .config ["LOCKFILE_EXPIRATION" ])
1838+
1839+ def _heartbeat_task (self , app : Flask , stop_event : threading .Event , timeout : int ):
1840+ """
1841+ Background task: Runs as a Thread (Sync) or Greenlet (Gevent) based on worker type.
1842+ Uses a fresh engine connection to stay pool-efficient.
1843+ """
1844+ # manual context push is required for background execution
1845+ with app .app_context ():
1846+ while not stop_event .is_set ():
1847+ try :
1848+ # db.engine.begin() is efficient and isolated, it immediately returns a connection to the pool
1849+ with db .engine .begin () as conn :
1850+ conn .execute (
1851+ db .text (
1852+ "UPDATE upload SET last_ping = NOW() WHERE id = :id"
1853+ ),
1854+ {"id" : self .id },
1855+ )
1856+ except Exception as e :
1857+ logging .exception (
1858+ f"Upload heartbeat failed for ID { self .project_id } and version { self .version } : { e } "
1859+ )
1860+
1861+ # wait for x seconds, but wake up immediately if stop_event is set
1862+ stop_event .wait (timeout )
1863+
1864+ @contextmanager
1865+ def heartbeat (self , timeout : int = 5 ):
1866+ """
1867+ Context manager to be used inside a Flask route.
1868+
1869+ Example of usage:
1870+ -----------------
1871+ with upload.heartbeat(interval):
1872+ do_something_slow
1873+ """
1874+ # we need to pass a real Flask app object to the thread
1875+ app = current_app ._get_current_object ()
1876+ stop_event = threading .Event ()
1877+
1878+ bg = threading .Thread (
1879+ target = self ._heartbeat_task , args = (app , stop_event , timeout ), daemon = True
18391880 )
18401881
1882+ bg .start ()
1883+ try :
1884+ yield
1885+ finally :
1886+ # signal the loop to stop
1887+ stop_event .set ()
1888+
1889+ # wait for the task to finish its last SQL call.
1890+ # in Gevent, this yields to other requests (non-blocking), while in Sync, this blocks the current thread for up to 2s
1891+ # this is to protect main thread / greenlet from zombie bg processes
1892+ bg .join (timeout = 2 )
1893+
18411894 def clear (self ):
18421895 """Clean up pending upload.
18431896 Uploaded files and table records are removed, and another upload can start.
@@ -1864,7 +1917,7 @@ def process_chunks(
18641917 to_remove = [i .path for i in file_changes if i .change == PushChangeType .DELETE ]
18651918 current_files = [f for f in self .project .files if f .path not in to_remove ]
18661919
1867- with Toucher ( self .lockfile , 5 ):
1920+ with self .heartbeat ( 5 ):
18681921 for f in file_changes :
18691922 if f .change == PushChangeType .DELETE :
18701923 continue
0 commit comments