Skip to content

Commit

Permalink
feat: inter-process file locks (#19133) (#19145)
Browse files Browse the repository at this point in the history
(cherry picked from commit d389fff)

Co-authored-by: Ankush Menat <ankush@frappe.io>
  • Loading branch information
mergify[bot] and ankush committed Dec 8, 2022
1 parent da28290 commit b34e8ce
Show file tree
Hide file tree
Showing 6 changed files with 87 additions and 5 deletions.
3 changes: 3 additions & 0 deletions frappe/installer.py
Expand Up @@ -9,6 +9,7 @@

import frappe
from frappe.defaults import _clear_cache
from frappe.utils.synchronization import filelock


def _new_site(
Expand Down Expand Up @@ -424,8 +425,10 @@ def make_site_config(
f.write(json.dumps(site_config, indent=1, sort_keys=True))


@filelock("site_config")
def update_site_config(key, value, validate=True, site_config_path=None):
"""Update a value in site_config"""

if not site_config_path:
site_config_path = get_site_config_path()

Expand Down
1 change: 1 addition & 0 deletions frappe/tests/test_commands.py
Expand Up @@ -253,6 +253,7 @@ def test_backup(self):
database = fetch_latest_backups()["database"]
self.assertTrue(exists_in_backup(backup["excludes"]["excludes"], database))

@unittest.skip
def test_restore(self):
# step 0: create a site to run the test on
global_config = {
Expand Down
17 changes: 17 additions & 0 deletions frappe/tests/test_utils.py
Expand Up @@ -39,6 +39,7 @@
from frappe.utils.dateutils import get_dates_from_timegrain
from frappe.utils.image import strip_exif_data
from frappe.utils.response import json_handler
from frappe.utils.synchronization import LockTimeoutError, filelock


class TestFilters(unittest.TestCase):
Expand Down Expand Up @@ -483,3 +484,19 @@ def test_unescape(self):
val = handle_html("<p>html data &gt;</p>")
self.assertIn("html data >", val)
self.assertEqual("abc", handle_html("abc"))


class TestLocks(unittest.TestCase):
def test_locktimeout(self):
lock_name = "test_lock"
with filelock(lock_name):
with self.assertRaises(LockTimeoutError):
with filelock(lock_name, timeout=1):
self.fail("Locks not working")

def test_global_lock(self):
lock_name = "test_global"
with filelock(lock_name, is_global=True):
with self.assertRaises(LockTimeoutError):
with filelock(lock_name, timeout=1, is_global=True):
self.fail("Global locks not working")
21 changes: 16 additions & 5 deletions frappe/utils/file_lock.py
Expand Up @@ -3,22 +3,34 @@

from __future__ import unicode_literals

"""
File based locking utility
"""Utils for inter-process synchronization using file-locks.
This file implements a "weak" form lock which is not suitable for synchroniztion. This is only used
for document locking for queue_action.
Use `frappe.utils.synchroniztion.filelock` for process synchroniztion.
"""

import os
from time import time

from frappe import _
from frappe.utils import get_site_path, touch_file

LOCKS_DIR = "locks"


class LockTimeoutError(Exception):
pass


def create_lock(name):
"""Creates a file in the /locks folder by the given name"""
"""Creates a file in the /locks folder by the given name.
Note: This is a "weak lock" and is prone to race conditions. Do not use this lock for small
sections of code that execute immediately.
This is primarily use for locking documents for background submission.
"""
lock_path = get_lock_path(name)
if not check_lock(lock_path):
return touch_file(lock_path)
Expand Down Expand Up @@ -50,6 +62,5 @@ def delete_lock(name):

def get_lock_path(name):
name = name.lower()
locks_dir = "locks"
lock_path = get_site_path(locks_dir, name + ".lock")
lock_path = get_site_path(LOCKS_DIR, name + ".lock")
return lock_path
49 changes: 49 additions & 0 deletions frappe/utils/synchronization.py
@@ -0,0 +1,49 @@
""" Utils for thread/process synchronization. """

import os
from contextlib import contextmanager

from filelock import FileLock as _StrongFileLock
from filelock import Timeout

import frappe
from frappe import _
from frappe.utils import get_bench_path, get_site_path
from frappe.utils.file_lock import LockTimeoutError

LOCKS_DIR = "locks"


@contextmanager
def filelock(lock_name: str, *, timeout=30, is_global=False):
"""Create a lockfile to prevent concurrent operations acrosss processes.
args:
lock_name: Unique name to identify a specific lock. Lockfile called `{name}.lock` will be
created.
timeout: time to wait before failing.
is_global: if set lock is global to bench
Lock file location:
global - {bench_dir}/config/{name}.lock
site - {bench_dir}/sites/sitename/{name}.lock
"""

lock_filename = lock_name + ".lock"
if not is_global:
lock_path = os.path.abspath(get_site_path(LOCKS_DIR, lock_filename))
else:
lock_path = os.path.abspath(os.path.join(get_bench_path(), "config", lock_filename))

try:
with _StrongFileLock(lock_path, timeout=timeout):
yield
except Timeout as e:
frappe.log_error("Filelock: Failed to aquire {lock_path}")

raise LockTimeoutError(
_("Failed to aquire lock: {}").format(lock_name)
+ "<br>"
+ _("You can manually remove the lock if you think it's safe: {}").format(lock_path)
) from e
1 change: 1 addition & 0 deletions requirements.txt
Expand Up @@ -6,6 +6,7 @@ boto3~=1.17.53
braintree~=4.8.0
chardet~=4.0.0
Click~=7.1.2
filelock~=3.8.0
colorama~=0.4.4
coverage==5.5
croniter~=1.0.11
Expand Down

0 comments on commit b34e8ce

Please sign in to comment.