Skip to content

Commit

Permalink
feat: operation level locking for CLI commands
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 7, 2022
1 parent d389fff commit 710d603
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 710d603

Please sign in to comment.