diff --git a/frappe/__init__.py b/frappe/__init__.py index 68c64d4f43..9195ebe40b 100644 --- a/frappe/__init__.py +++ b/frappe/__init__.py @@ -203,6 +203,7 @@ def init(site: str, sites_path: str = ".", new_site: bool = False) -> None: "mute_emails": False, "has_dataurl": False, "new_site": new_site, + "read_only": False, } ) local.rollback_observers = [] @@ -284,9 +285,7 @@ def connect_replica(): user = local.conf.replica_db_name password = local.conf.replica_db_password - local.replica_db = get_db( - host=local.conf.replica_host, user=user, password=password, port=port, read_only=True - ) + local.replica_db = get_db(host=local.conf.replica_host, user=user, password=password, port=port) # swap db connections local.primary_db = local.db @@ -2225,13 +2224,18 @@ 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)) - return get_doc( + error_log = get_doc( doctype="Error Log", error=traceback, method=title, reference_doctype=reference_doctype, reference_name=reference_name, - ).insert(ignore_permissions=True) + ) + + if flags.read_only: + error_log.deferred_insert() + else: + return error_log.insert(ignore_permissions=True) def get_desk_link(doctype, name): diff --git a/frappe/app.py b/frappe/app.py index 2176a1bc5a..7f3ea9381f 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -115,9 +115,12 @@ def init_request(request): # site does not exist raise NotFound - if frappe.local.conf.get("maintenance_mode"): + if frappe.local.conf.maintenance_mode: frappe.connect() - raise frappe.SessionStopped("Session Stopped") + if frappe.local.conf.allow_reads_during_maintenance: + setup_read_only_mode() + else: + raise frappe.SessionStopped("Session Stopped") else: frappe.connect(set_admin_as_user=False) @@ -129,6 +132,24 @@ def init_request(request): frappe.local.http_request = frappe.auth.HTTPRequest() +def setup_read_only_mode(): + """During maintenance_mode reads to DB can still be performed to reduce downtime. This + function sets up read only mode + + - Setting global flag so other pages, desk and database can know that we are in read only mode. + - Setup read only database access either by: + - Connecting to read replica if one exists + - Or setting up read only SQL transactions. + """ + frappe.flags.read_only = True + + # If replica is available then just connect replica, else setup read only transaction. + if frappe.conf.read_from_replica: + frappe.connect_replica() + else: + frappe.db.begin(read_only=True) + + def log_request(request, response): if hasattr(frappe.local, "conf") and frappe.local.conf.enable_frappe_logger: frappe.logger("frappe.web", allow_site=frappe.local.site).info( @@ -230,6 +251,12 @@ def handle_exception(e): or (frappe.local.request.path.startswith("/api/") and not accept_header.startswith("text")) ) + if not frappe.session.user: + # If session creation fails then user won't be unset. This causes a lot of code that + # assumes presence of this to fail. Session creation fails => guest or expired login + # usually. + frappe.session.user = "Guest" + if respond_as_json: # handle ajax responses first # if the request is ajax, send back the trace or error message diff --git a/frappe/auth.py b/frappe/auth.py index 7ce2b17680..f7ff6f0fe5 100644 --- a/frappe/auth.py +++ b/frappe/auth.py @@ -6,9 +6,8 @@ import frappe import frappe.database import frappe.utils import frappe.utils.user -from frappe import _, conf +from frappe import _ from frappe.core.doctype.activity_log.activity_log import add_authentication_log -from frappe.modules.patch_handler import check_session_stopped from frappe.sessions import Session, clear_sessions, delete_session from frappe.translate import get_language from frappe.twofactor import ( @@ -30,9 +29,6 @@ class HTTPRequest: # load cookies self.set_cookies() - # set frappe.local.db - self.connect() - # login and start/resume user session self.set_session() @@ -45,9 +41,6 @@ class HTTPRequest: # write out latest cookies frappe.local.cookie_manager.init_cookies() - # check session status - check_session_stopped() - @property def domain(self): if not getattr(self, "_domain", None): @@ -97,16 +90,6 @@ class HTTPRequest: def set_lang(self): frappe.local.lang = get_language() - def get_db_name(self): - """get database name from conf""" - return conf.db_name - - def connect(self): - """connect to db, from ac_name or db_name""" - frappe.local.db = frappe.database.get_db( - user=self.get_db_name(), password=getattr(conf, "db_password", "") - ) - class LoginManager: diff --git a/frappe/core/doctype/error_log/error_log.py b/frappe/core/doctype/error_log/error_log.py index c7ab98e034..871ec8ebdd 100644 --- a/frappe/core/doctype/error_log/error_log.py +++ b/frappe/core/doctype/error_log/error_log.py @@ -9,7 +9,7 @@ from frappe.query_builder.functions import Now class ErrorLog(Document): def onload(self): - if not self.seen: + if not self.seen and not frappe.flags.read_only: self.db_set("seen", 1, update_modified=0) frappe.db.commit() diff --git a/frappe/database/__init__.py b/frappe/database/__init__.py index 423442d344..7de3fabf01 100644 --- a/frappe/database/__init__.py +++ b/frappe/database/__init__.py @@ -39,21 +39,17 @@ def drop_user_and_database(db_name, root_login=None, root_password=None): ) -def get_db(host=None, user=None, password=None, port=None, read_only=False): +def get_db(host=None, user=None, password=None, port=None): import frappe if frappe.conf.db_type == "postgres": import frappe.database.postgres.database - return frappe.database.postgres.database.PostgresDatabase( - host, user, password, port=port, read_only=read_only - ) + return frappe.database.postgres.database.PostgresDatabase(host, user, password, port=port) else: import frappe.database.mariadb.database - return frappe.database.mariadb.database.MariaDBDatabase( - host, user, password, port=port, read_only=read_only - ) + return frappe.database.mariadb.database.MariaDBDatabase(host, user, password, port=port) def setup_help_database(help_db_name): diff --git a/frappe/database/database.py b/frappe/database/database.py index b1cda25656..40117fc5fa 100644 --- a/frappe/database/database.py +++ b/frappe/database/database.py @@ -82,14 +82,12 @@ class Database: ac_name=None, use_default=0, port=None, - read_only=False, ): self.setup_type_map() self.host = host or frappe.conf.db_host or "127.0.0.1" self.port = port or frappe.conf.db_port or "" self.user = user or frappe.conf.db_name self.db_name = frappe.conf.db_name - self.read_only = read_only # Uses READ ONLY connection if set self._conn = None if ac_name: @@ -217,6 +215,15 @@ class Database: elif self.is_timedout(e): raise frappe.QueryTimeoutError(e) from e + elif self.is_read_only_mode_error(e): + frappe.throw( + _( + "Site is running in read only mode, this action can not be performed right now. Please try again later." + ), + title=_("In Read Only Mode"), + exc=frappe.InReadOnlyMode, + ) + # TODO: added temporarily elif self.db_type == "postgres": traceback.print_stack() @@ -957,8 +964,10 @@ class Database: return defaults.get(frappe.scrub(key)) - def begin(self): - self.sql("START TRANSACTION") + def begin(self, *, read_only=False): + read_only = read_only or frappe.flags.read_only + mode = "READ ONLY" if read_only else "" + self.sql(f"START TRANSACTION {mode}") def commit(self): """Commit current transaction. Calls SQL `COMMIT`.""" @@ -966,9 +975,7 @@ class Database: frappe.call(method[0], *(method[1] or []), **(method[2] or {})) self.sql("commit") - if self.db_type == "postgres": - # Postgres requires explicitly starting new transaction - self.begin() + self.begin() # explicitly start a new transaction frappe.local.rollback_observers = [] self.flush_realtime_log() diff --git a/frappe/database/mariadb/database.py b/frappe/database/mariadb/database.py index bad00d9723..3fc241454e 100644 --- a/frappe/database/mariadb/database.py +++ b/frappe/database/mariadb/database.py @@ -32,6 +32,10 @@ class MariaDBExceptionUtil: def is_timedout(e: pymysql.Error) -> bool: return e.args[0] == ER.LOCK_WAIT_TIMEOUT + @staticmethod + def is_read_only_mode_error(e: pymysql.Error) -> bool: + return e.args[0] == 1792 + @staticmethod def is_table_missing(e: pymysql.Error) -> bool: return e.args[0] == ER.NO_SUCH_TABLE diff --git a/frappe/database/postgres/database.py b/frappe/database/postgres/database.py index cb566736ad..3b3612c0e4 100644 --- a/frappe/database/postgres/database.py +++ b/frappe/database/postgres/database.py @@ -12,7 +12,7 @@ from psycopg2.errorcodes import ( UNDEFINED_TABLE, UNIQUE_VIOLATION, ) -from psycopg2.errors import SequenceGeneratorLimitExceeded, SyntaxError +from psycopg2.errors import ReadOnlySqlTransaction, SequenceGeneratorLimitExceeded, SyntaxError from psycopg2.extensions import ISOLATION_LEVEL_REPEATABLE_READ import frappe @@ -55,6 +55,10 @@ class PostgresExceptionUtil: # http://initd.org/psycopg/docs/extensions.html?highlight=datatype#psycopg2.extensions.QueryCanceledError return isinstance(e, psycopg2.extensions.QueryCanceledError) + @staticmethod + def is_read_only_mode_error(e) -> bool: + return isinstance(e, ReadOnlySqlTransaction) + @staticmethod def is_syntax_error(e): return isinstance(e, SyntaxError) diff --git a/frappe/exceptions.py b/frappe/exceptions.py index c3bb45caea..2fe9de6be9 100644 --- a/frappe/exceptions.py +++ b/frappe/exceptions.py @@ -236,6 +236,10 @@ class QueryDeadlockError(Exception): pass +class InReadOnlyMode(ValidationError): + http_status_code = 503 # temporarily not available + + class TooManyWritesError(Exception): pass diff --git a/frappe/migrate.py b/frappe/migrate.py index 1c249dfdb1..645d84c0ab 100644 --- a/frappe/migrate.py +++ b/frappe/migrate.py @@ -13,6 +13,7 @@ from frappe.cache_manager import clear_global_cache from frappe.core.doctype.language.language import sync_languages from frappe.core.doctype.scheduled_job_type.scheduled_job_type import sync_jobs from frappe.database.schema import add_column +from frappe.deferred_insert import save_to_db as flush_deferred_inserts from frappe.desk.notifications import clear_notifications from frappe.modules.patch_handler import PatchType from frappe.modules.utils import sync_customizations @@ -123,6 +124,7 @@ class SiteMigration: * Sync in-Desk Module Dashboards * Sync customizations: Custom Fields, Property Setters, Custom Permissions * Sync Frappe's internal language master + * Flush deferred inserts made during maintenance mode. * Sync Portal Menu Items * Sync Installed Applications Version History * Execute `after_migrate` hooks @@ -132,6 +134,7 @@ class SiteMigration: sync_dashboards() sync_customizations() sync_languages() + flush_deferred_inserts() frappe.get_single("Portal Settings").sync_menu() frappe.get_single("Installed Applications").update_versions() diff --git a/frappe/model/document.py b/frappe/model/document.py index 7539ff3899..2697777425 100644 --- a/frappe/model/document.py +++ b/frappe/model/document.py @@ -1365,7 +1365,7 @@ class Document(BaseDocument): if not user: user = frappe.session.user - if self.meta.track_seen: + if self.meta.track_seen and not frappe.flags.read_only: _seen = self.get("_seen") or [] _seen = frappe.parse_json(_seen) @@ -1380,15 +1380,19 @@ class Document(BaseDocument): user = frappe.session.user if hasattr(self.meta, "track_views") and self.meta.track_views: - frappe.get_doc( + view_log = frappe.get_doc( { "doctype": "View Log", "viewed_by": frappe.session.user, "reference_doctype": self.doctype, "reference_name": self.name, } - ).insert(ignore_permissions=True) - frappe.local.flags.commit = True + ) + if frappe.flags.read_only: + view_log.deferred_insert() + else: + view_log.insert(ignore_permissions=True) + frappe.local.flags.commit = True def log_error(self, title=None, message=None): """Helper function to create an Error Log""" @@ -1535,6 +1539,20 @@ class Document(BaseDocument): return DocTags(self.doctype).get_tags(self.name).split(",")[1:] + def deferred_insert(self) -> None: + """Push the document to redis temporarily and insert later. + + WARN: This doesn't guarantee insertion as redis can be restarted + before data is flushed to database. + """ + + from frappe.deferred_insert import deferred_insert + + self.set_user_and_timestamp() + + doc = self.get_valid_dict(convert_dates_to_str=True, ignore_virtual=True) + deferred_insert(doctype=self.doctype, records=doc) + def __repr__(self): name = self.name or "unsaved" doctype = self.__class__.__name__ diff --git a/frappe/model/sync.py b/frappe/model/sync.py index df3999054a..1eab050663 100644 --- a/frappe/model/sync.py +++ b/frappe/model/sync.py @@ -8,17 +8,17 @@ import os import frappe from frappe.modules.import_file import import_file_by_path -from frappe.modules.patch_handler import block_user +from frappe.modules.patch_handler import _patch_mode from frappe.utils import update_progress_bar def sync_all(force=0, reset_permissions=False): - block_user(True) + _patch_mode(True) for app in frappe.get_installed_apps(): sync_for(app, force, reset_permissions=reset_permissions) - block_user(False) + _patch_mode(False) frappe.clear_cache() diff --git a/frappe/modules/patch_handler.py b/frappe/modules/patch_handler.py index f389312a4f..d5a37f52a5 100644 --- a/frappe/modules/patch_handler.py +++ b/frappe/modules/patch_handler.py @@ -154,7 +154,7 @@ def run_single(patchmodule=None, method=None, methodargs=None, force=False): def execute_patch(patchmodule, method=None, methodargs=None): """execute the patch""" - block_user(True) + _patch_mode(True) if patchmodule.startswith("execute:"): has_patch_file = False @@ -197,7 +197,7 @@ def execute_patch(patchmodule, method=None, methodargs=None): else: frappe.db.commit() end_time = time.time() - block_user(False) + _patch_mode(False) print(f"Success: Done in {round(end_time - start_time, 3)}s") return True @@ -216,18 +216,7 @@ def executed(patchmodule): return frappe.db.get_value("Patch Log", {"patch": patchmodule}) -def block_user(block, msg=None): +def _patch_mode(enable): """stop/start execution till patch is run""" - frappe.local.flags.in_patch = block - frappe.db.begin() - if not msg: - msg = "Patches are being executed in the system. Please try again in a few moments." - frappe.db.set_global("__session_status", block and "stop" or None) - frappe.db.set_global("__session_status_message", block and msg or None) + frappe.local.flags.in_patch = enable frappe.db.commit() - - -def check_session_stopped(): - if frappe.db.get_global("__session_status") == "stop": - frappe.msgprint(frappe.db.get_global("__session_status_message")) - raise frappe.SessionStopped("Session Stopped") diff --git a/frappe/public/js/frappe/form/form.js b/frappe/public/js/frappe/form/form.js index d8e5c3497a..9277d7359c 100644 --- a/frappe/public/js/frappe/form/form.js +++ b/frappe/public/js/frappe/form/form.js @@ -445,6 +445,10 @@ frappe.ui.form.Form = class FrappeForm { .toggleClass("cancelled-form", this.doc.docstatus === 2); this.show_conflict_message(); + + if (frappe.boot.read_only) { + this.disable_form(); + } } } diff --git a/frappe/public/js/frappe/list/list_view.js b/frappe/public/js/frappe/list/list_view.js index c7a2a9b72c..0064f725a6 100644 --- a/frappe/public/js/frappe/list/list_view.js +++ b/frappe/public/js/frappe/list/list_view.js @@ -215,7 +215,7 @@ frappe.views.ListView = class ListView extends frappe.views.BaseList { } set_primary_action() { - if (this.can_create) { + if (this.can_create && !frappe.boot.read_only) { const doctype_name = __(frappe.router.doctype_layout) || __(this.doctype); // Better style would be __("Add {0}", [doctype_name], "Primary action in list view") diff --git a/frappe/public/js/frappe/ui/toolbar/navbar.html b/frappe/public/js/frappe/ui/toolbar/navbar.html index 3276ca302a..ee070d4378 100644 --- a/frappe/public/js/frappe/ui/toolbar/navbar.html +++ b/frappe/public/js/frappe/ui/toolbar/navbar.html @@ -6,6 +6,11 @@