Skip to content

Commit

Permalink
feat: operation level locking for CLI commands (#19162)
Browse files Browse the repository at this point in the history
This prevents mistakenly issuing same commands twice which can be
dangerous.

added global lock(s):
- [x] bench build

added site level lock(s):
- [x] bench new-site sitename
- [x] bench --site sitename migrate
- [x] bench install-app appname
- [x] bench build
- [x] bench restore (the code is just meh, needs some cleanup)

closes #13215
  • Loading branch information
ankush committed Dec 8, 2022
1 parent dbc5a79 commit 49437f5
Show file tree
Hide file tree
Showing 7 changed files with 127 additions and 80 deletions.
4 changes: 4 additions & 0 deletions frappe/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2214,6 +2214,10 @@ def log_error(title=None, message=None, reference_doctype=None, reference_name=N
title = title or "Error"
traceback = as_unicode(traceback or get_traceback(with_context=True))

if not db:
print(f"Failed to log error in db: {title}")
return

error_log = get_doc(
doctype="Error Log",
error=traceback,
Expand Down
79 changes: 59 additions & 20 deletions frappe/commands/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,43 @@ def restore(
with_private_files=None,
):
"Restore site database from an sql file"

from frappe.utils.synchronization import filelock

site = get_site(context)
frappe.init(site=site)

with filelock("site_restore", timeout=1):
_restore(
site=site,
sql_file_path=sql_file_path,
encryption_key=encryption_key,
db_root_username=db_root_username,
db_root_password=db_root_password,
verbose=context.verbose or verbose,
install_app=install_app,
admin_password=admin_password,
force=context.force or force,
with_public_files=with_public_files,
with_private_files=with_private_files,
)


def _restore(
*,
site=None,
sql_file_path=None,
encryption_key=None,
db_root_username=None,
db_root_password=None,
verbose=None,
install_app=None,
admin_password=None,
force=None,
with_public_files=None,
with_private_files=None,
):

from frappe.installer import (
_new_site,
extract_files,
Expand All @@ -146,10 +183,6 @@ def restore(

_backup = Backup(sql_file_path)

site = get_site(context)
frappe.init(site=site)
force = context.force or force

try:
decompressed_file_name = extract_sql_from_archive(sql_file_path)
if is_partial(decompressed_file_name):
Expand Down Expand Up @@ -211,7 +244,7 @@ def restore(
db_root_username=db_root_username,
db_root_password=db_root_password,
admin_password=admin_password,
verbose=context.verbose,
verbose=verbose,
install_apps=install_app,
source_sql=decompressed_file_name,
force=True,
Expand Down Expand Up @@ -361,6 +394,7 @@ def _reinstall(
site, admin_password=None, db_root_username=None, db_root_password=None, yes=False, verbose=False
):
from frappe.installer import _new_site
from frappe.utils.synchronization import filelock

if not yes:
click.confirm("This will wipe your database. Are you sure you want to reinstall?", abort=True)
Expand All @@ -378,6 +412,7 @@ def _reinstall(
frappe.destroy()

frappe.init(site=site)

_new_site(
frappe.conf.db_name,
site,
Expand All @@ -398,6 +433,7 @@ def _reinstall(
def install_app(context, apps, force=False):
"Install a new app to site, supports multiple apps"
from frappe.installer import install_app as _install_app
from frappe.utils.synchronization import filelock

exit_code = 0

Expand All @@ -408,20 +444,21 @@ def install_app(context, apps, force=False):
frappe.init(site=site)
frappe.connect()

for app in apps:
try:
_install_app(app, verbose=context.verbose, force=force)
except frappe.IncompatibleApp as err:
err_msg = f":\n{err}" if str(err) else ""
print(f"App {app} is Incompatible with Site {site}{err_msg}")
exit_code = 1
except Exception as err:
err_msg = f": {str(err)}\n{frappe.get_traceback()}"
print(f"An error occurred while installing {app}{err_msg}")
exit_code = 1

if not exit_code:
frappe.db.commit()
with filelock("install_app", timeout=1):
for app in apps:
try:
_install_app(app, verbose=context.verbose, force=force)
except frappe.IncompatibleApp as err:
err_msg = f":\n{err}" if str(err) else ""
print(f"App {app} is Incompatible with Site {site}{err_msg}")
exit_code = 1
except Exception as err:
err_msg = f": {str(err)}\n{frappe.get_traceback()}"
print(f"An error occurred while installing {app}{err_msg}")
exit_code = 1

if not exit_code:
frappe.db.commit()

frappe.destroy()

Expand Down Expand Up @@ -791,12 +828,14 @@ def remove_from_installed_apps(context, app):
def uninstall(context, app, dry_run, yes, no_backup, force):
"Remove app and linked modules from site"
from frappe.installer import remove_app
from frappe.utils.synchronization import filelock

for site in context.sites:
try:
frappe.init(site=site)
frappe.connect()
remove_app(app_name=app, dry_run=dry_run, yes=yes, no_backup=no_backup, force=force)
with filelock("uninstall_app"):
remove_app(app_name=app, dry_run=dry_run, yes=yes, no_backup=no_backup, force=force)
finally:
frappe.destroy()
if not context.sites:
Expand Down
42 changes: 22 additions & 20 deletions frappe/commands/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from frappe.coverage import CodeCoverage
from frappe.exceptions import SiteNotSpecifiedError
from frappe.utils import cint, update_progress_bar
from frappe.utils.synchronization import filelock

find_executable = which # backwards compatibility
DATA_IMPORT_DEPRECATION = (
Expand Down Expand Up @@ -60,27 +61,28 @@ def build(
if not apps and app:
apps = app

# dont try downloading assets if force used, app specified or running via CI
if not (force or apps or os.environ.get("CI")):
# skip building frappe if assets exist remotely
skip_frappe = download_frappe_assets(verbose=verbose)
else:
skip_frappe = False

# don't minify in developer_mode for faster builds
development = frappe.local.conf.developer_mode or frappe.local.dev_server
mode = "development" if development else "production"
if production:
mode = "production"

if make_copy or restore:
hard_link = make_copy or restore
click.secho(
"bench build: --make-copy and --restore options are deprecated in favour of --hard-link",
fg="yellow",
)
with filelock("bench_build", is_global=True, timeout=10):
# dont try downloading assets if force used, app specified or running via CI
if not (force or apps or os.environ.get("CI")):
# skip building frappe if assets exist remotely
skip_frappe = download_frappe_assets(verbose=verbose)
else:
skip_frappe = False

# don't minify in developer_mode for faster builds
development = frappe.local.conf.developer_mode or frappe.local.dev_server
mode = "development" if development else "production"
if production:
mode = "production"

if make_copy or restore:
hard_link = make_copy or restore
click.secho(
"bench build: --make-copy and --restore options are deprecated in favour of --hard-link",
fg="yellow",
)

bundle(mode, apps=apps, hard_link=hard_link, verbose=verbose, skip_frappe=skip_frappe)
bundle(mode, apps=apps, hard_link=hard_link, verbose=verbose, skip_frappe=skip_frappe)


@click.command("watch")
Expand Down
60 changes: 29 additions & 31 deletions frappe/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,40 +76,38 @@ def _new_site(

make_site_dirs()

installing = touch_file(get_site_path("locks", "installing.lock"))

install_db(
root_login=db_root_username,
root_password=db_root_password,
db_name=db_name,
admin_password=admin_password,
verbose=verbose,
source_sql=source_sql,
force=force,
reinstall=reinstall,
db_password=db_password,
db_type=db_type,
db_host=db_host,
db_port=db_port,
no_mariadb_socket=no_mariadb_socket,
)
apps_to_install = (
["frappe"] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or [])
)
with filelock("bench_new_site", timeout=1):
install_db(
root_login=db_root_username,
root_password=db_root_password,
db_name=db_name,
admin_password=admin_password,
verbose=verbose,
source_sql=source_sql,
force=force,
reinstall=reinstall,
db_password=db_password,
db_type=db_type,
db_host=db_host,
db_port=db_port,
no_mariadb_socket=no_mariadb_socket,
)

for app in apps_to_install:
# NOTE: not using force here for 2 reasons:
# 1. It's not really needed here as we've freshly installed a new db
# 2. If someone uses a sql file to do restore and that file already had
# installed_apps then it might cause problems as that sql file can be of any previous version(s)
# which might be incompatible with the current version and using force might cause problems.
# Example: the DocType DocType might not have `migration_hash` column which will cause failure in the restore.
install_app(app, verbose=verbose, set_as_patched=not source_sql, force=False)
apps_to_install = (
["frappe"] + (frappe.conf.get("install_apps") or []) + (list(install_apps) or [])
)

os.remove(installing)
for app in apps_to_install:
# NOTE: not using force here for 2 reasons:
# 1. It's not really needed here as we've freshly installed a new db
# 2. If someone uses a sql file to do restore and that file already had
# installed_apps then it might cause problems as that sql file can be of any previous version(s)
# which might be incompatible with the current version and using force might cause problems.
# Example: the DocType DocType might not have `migration_hash` column which will cause failure in the restore.
install_app(app, verbose=verbose, set_as_patched=not source_sql, force=False)

scheduler.toggle_scheduler(enable_scheduler)
frappe.db.commit()
scheduler.toggle_scheduler(enable_scheduler)
frappe.db.commit()

scheduler_status = "disabled" if frappe.utils.scheduler.is_scheduler_disabled() else "enabled"
print("*** Scheduler is", scheduler_status, "***")
Expand Down
18 changes: 10 additions & 8 deletions frappe/migrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from frappe.utils.connections import check_connection
from frappe.utils.dashboard import sync_dashboards
from frappe.utils.fixtures import sync_fixtures
from frappe.utils.synchronization import filelock
from frappe.website.utils import clear_website_cache

BENCH_START_MESSAGE = dedent(
Expand Down Expand Up @@ -169,11 +170,12 @@ def run(self, site: str):
if not self.required_services_running():
raise SystemExit(1)

self.setUp()
try:
self.pre_schema_updates()
self.run_schema_updates()
self.post_schema_updates()
finally:
self.tearDown()
frappe.destroy()
with filelock("bench_migrate", timeout=1):
self.setUp()
try:
self.pre_schema_updates()
self.run_schema_updates()
self.post_schema_updates()
finally:
self.tearDown()
frappe.destroy()
2 changes: 2 additions & 0 deletions frappe/search/website_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import frappe
from frappe.search.full_text_search import FullTextSearch
from frappe.utils import set_request, update_progress_bar
from frappe.utils.synchronization import filelock
from frappe.website.serve import get_response_content

INDEX_NAME = "web_routes"
Expand Down Expand Up @@ -140,6 +141,7 @@ def remove_document_from_index(path):
return ws.remove_document_from_index(path)


@filelock("building_website_search")
def build_index_for_all_routes():
ws = WebsiteSearch(INDEX_NAME)
return ws.build()
2 changes: 1 addition & 1 deletion frappe/utils/synchronization.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ def filelock(lock_name: str, *, timeout=30, is_global=False):
frappe.log_error("Filelock: Failed to aquire {lock_path}")

raise LockTimeoutError(
_("Failed to aquire lock: {}").format(lock_name)
_("Failed to aquire lock: {}. Lock may be held by another process.").format(lock_name)
+ "<br>"
+ _("You can manually remove the lock if you think it's safe: {}").format(lock_path)
) from e

0 comments on commit 49437f5

Please sign in to comment.