File Based Locking at Document Level (#2374)

* [redesign] improved locking in documents and redesigned recent documents

* [minor] patch to update doctype in existing documents
This commit is contained in:
Rushabh Mehta 2016-11-25 16:14:00 +05:30 committed by GitHub
parent 9e70ff8811
commit 4bd2285159
17 changed files with 132 additions and 90 deletions

View file

@ -10,7 +10,12 @@ frappe.listview_settings['Error Log'] = {
order_by: "seen asc, modified desc",
onload: function(listview) {
listview.page.add_menu_item(__("Clear Error Logs"), function() {
frappe.call({method:'frappe.core.doctype.error_log.error_log.clear_error_logs'});
frappe.call({
method:'frappe.core.doctype.error_log.error_log.clear_error_logs',
callback: function() {
listview.refresh();
}
});
});
}
};

View file

@ -712,8 +712,7 @@ class Database:
return frappe.defaults.get_defaults(parent)
def begin(self):
pass
#self.sql("start transaction")
self.sql("start transaction")
def commit(self):
"""Commit current transaction. Calls SQL `COMMIT`."""

View file

@ -62,3 +62,4 @@ class AppNotInstalledError(ValidationError): pass
class IncorrectSitePath(NotFound): pass
class ImplicitCommitError(ValidationError): pass
class RetryBackgroundJobError(Exception): pass
class DocumentLockedError(ValidationError): pass

View file

@ -3,8 +3,9 @@
from __future__ import unicode_literals
import frappe
import time
from frappe import _, msgprint
from frappe.utils import flt, cstr, now, get_datetime_str
from frappe.utils import flt, cstr, now, get_datetime_str, file_lock
from frappe.utils.background_jobs import enqueue
from frappe.model.base_document import BaseDocument, get_controller
from frappe.model.naming import set_new_name
@ -156,23 +157,6 @@ class Document(BaseDocument):
frappe.msgprint(msg)
raise frappe.PermissionError(msg)
def lock(self):
'''Will set docstatus to 3 + the current docstatus and mark it as queued
3 = queued for saving
4 = queued for submission
5 = queued for cancellation
'''
self.db_set('docstatus', 3 + self.docstatus, update_modified = False)
def unlock(self):
'''set the original docstatus at the time it was locked in the controller'''
current_docstatus = self.db_get('docstatus') - 4
if current_docstatus < 0:
current_docstatus = 0
self.db_set('docstatus', current_docstatus, update_modified = False)
def insert(self, ignore_permissions=None):
"""Insert the document in the database (as a new document).
This will check for user permissions and execute `before_insert`,
@ -494,7 +478,9 @@ class Document(BaseDocument):
self._action = "save"
if not self.get('__islocal'):
if self.meta.issingle:
modified = frappe.db.get_value(self.doctype, self.name, "modified")
modified = frappe.db.sql('''select value from tabSingles
where doctype=%s and field='modified' for update''', self.doctype)
modified = modified and modified[0][0]
if cstr(modified) and cstr(modified) != cstr(self._original_modified):
conflict = True
else:
@ -530,13 +516,7 @@ class Document(BaseDocument):
- Submit (1) > Submit (1)
- Submit (1) > Cancel (2)
If docstatus is > 2, it will throw exception as document is deemed queued
"""
if self.docstatus > 2:
frappe.throw(_('This document is currently queued for execution. Please try again'),
title=_('Document Queued'), indicator='red')
if not self.docstatus:
self.docstatus = 0
if docstatus==0:
@ -1014,28 +994,46 @@ class Document(BaseDocument):
def queue_action(self, action, **kwargs):
'''Run an action in background. If the action has an inner function,
like _submit for submit, it will call that instead'''
if action in ('save', 'submit', 'cancel'):
# set docstatus explicitly again due to inconsistent action
self.docstatus = {'save':0, 'submit':1, 'cancel': 2}[action]
else:
raise 'Action must be one of save, submit, cancel'
# call _submit instead of submit, so you can override submit to call
# run_delayed based on some action
# See: Stock Reconciliation
if hasattr(self, '_' + action):
action = '_' + action
if file_lock.lock_exists(self.get_signature()):
frappe.throw(_('This document is currently queued for execution. Please try again'),
title=_('Document Queued'), indicator='red')
self.lock()
frappe.db.commit()
enqueue('frappe.model.document.execute_action', doctype=self.doctype, name=self.name,
action=action, **kwargs)
def lock(self, timeout=None):
'''Creates a lock file for the given document. If timeout is set,
it will retry every 1 second for acquiring the lock again
:param timeout: Timeout in seconds, default 0'''
signature = self.get_signature()
if file_lock.lock_exists(signature):
lock_exists = True
if timeout:
for i in range(timeout):
time.sleep(1)
if not file_lock.lock_exists(signature):
lock_exists = False
break
if lock_exists:
raise frappe.DocumentLockedError
file_lock.create_lock(signature)
def unlock(self):
'''Delete the lock file for this document'''
file_lock.delete_lock(self.get_signature())
def execute_action(doctype, name, action, **kwargs):
'''Execute an action on a document (called by background worker)'''
doc = frappe.get_doc(doctype, name)
doc.unlock()
frappe.db.commit()
try:
getattr(doc, action)(**kwargs)
except Exception:

View file

@ -146,3 +146,4 @@ execute:frappe.db.set_default('language', '')
frappe.patches.v7_1.refactor_integration_broker
frappe.patches.v7_1.set_backup_limit
frappe.patches.v7_1.disabled_print_settings_for_custom_print_format
frappe.patches.v7_2.set_doctype_engine

View file

View file

@ -0,0 +1,6 @@
import frappe
def execute():
for t in frappe.db.sql('show table status'):
if t[0].startswith('tab'):
frappe.db.sql('update tabDocType set engine=%s where name=%s', (t[1], t[0][3:]))

View file

@ -836,7 +836,13 @@ frappe.ui.form.GridRow = Class.extend({
field.$input.on('keydown', function(e) {
var values = me.frm.doc[me.grid.df.fieldname];
var fieldname = $(this).attr('data-fieldname');
var fieldtype = $(this).attr('data-fieldtype');
// TAB
if(in_list(['Text', 'Small Text'], fieldtype)) {
return;
}
if(e.which==TAB) {
// last column
if(me.grid.wrapper.find('input:enabled:last').get(0)===this) {

View file

@ -26,24 +26,12 @@ frappe.get_indicator = function(doc, doctype) {
var is_submittable = frappe.model.is_submittable(doctype),
workflow_fieldname = frappe.workflow.get_state_fieldname(doctype);
if(doc.docstatus==3) {
return [__("Queued for saving"), "orange", "docstatus,=,3"];
}
if(doc.docstatus==4) {
return [__("Queued for submission"), "orange", "docstatus,=,4"];
}
if(doc.docstatus==5) {
return [__("Queued for cancellation"), "orange", "docstatus,=,5"];
}
// workflow
if(workflow_fieldname) {
var value = doc[workflow_fieldname];
if(value) {
var colour = "";
if(locals["Workflow State"][value] && locals["Workflow State"][value].style) {
var colour = {
"Success": "green",

View file

@ -438,9 +438,6 @@ $.extend(frappe.model, {
parent_doc[parentfield] = newlist;
});
}
if(frappe.ui.toolbar.recent)
frappe.ui.toolbar.recent.remove(doctype, docname);
},
get_no_copy_list: function(doctype) {

View file

@ -50,8 +50,13 @@ frappe.route = function() {
frappe.views.pageview.show(route[0]);
}
if(frappe.route_titles[window.location.hash]) {
frappe.utils.set_title(frappe.route_titles[window.location.hash]);
} else {
setTimeout(function() {
frappe.route_titles[frappe.get_route_str()] = frappe._original_title || document.title;
}, 1000);
}
}
@ -145,4 +150,5 @@ $(window).on('hashchange', function() {
cur_dialog.hide();
frappe.route();
});

View file

@ -538,10 +538,7 @@ frappe.ui.Filter = Class.extend({
df.options=[
{value:0, label:__("Draft")},
{value:1, label:__("Submitted")},
{value:2, label:__("Cancelled")},
{value:3, label:__("Queued for saving")},
{value:4, label:__("Queued for submission")},
{value:5, label:__("Queued for cancellation")},
{value:2, label:__("Cancelled")}
]
} else if(df.fieldtype=='Check') {
df.fieldtype='Select';

View file

@ -118,27 +118,45 @@ frappe.search = {
});
},
add_recent: function(txt) {
var doctypes = frappe.utils.unique(keys(locals).concat(keys(frappe.search.recent)));
for(var i in doctypes) {
var doctype = doctypes[i];
if(doctype[0]!==":" && !frappe.model.is_table(doctype)
&& !in_list(frappe.boot.single_types, doctype)
&& !in_list(["DocType", "DocField", "DocPerm", "Page", "Country",
"Currency", "Page Role", "Print Format", "Report"], doctype)) {
values = [];
$.each(frappe.search.recent, function(i, doctype) {
values.push([doctype[1], ['Form', doctype[0], doctype[1]]]);
});
var values = frappe.utils.remove_nulls(frappe.utils.unique(
keys(locals[doctype]).concat(frappe.search.recent[doctype] || [])
));
values = values.reverse();
var ret = frappe.search.find(values, txt, function(match) {
return {
label: __(doctype) + " " + match.bold(),
value: __(doctype) + " " + match,
route: ["Form", doctype, match]
}
}, true);
$.each(frappe.route_history, function(i, route) {
if(route[0]==='Form') {
values.push([route[2], route]);
}
}
else if(in_list(['List', 'Report', 'modules'], route[0])) {
if(route[1]) {
values.push([route[1], route]);
}
}
else if(route[0]) {
values.push([frappe.route_titles[route[0]] || route[0], route]);
}
});
frappe.search.find(values, txt, function(match) {
out = {
route: match[1]
}
if(match[1][0]==='Form') {
out.label = __(match[1][1]) + " " + match[1][2].bold();
out.value = __(match[1][1]) + " " + match[1][2];
} else if(in_list(['List', 'Report', 'modules'], match[1][0])) {
var type = match[1][0];
if(type==='modules') type = 'Module';
out.label = __(match[1][1]).bold() + " " + __(type);
out.value = __(match[1][1]) + " " + __(type);
} else {
out.label = match[0].bold();
out.value = match[0];
}
return out;
}, true);
},
make_page_title_map: function() {
frappe.search.pages = {};
@ -148,21 +166,16 @@ frappe.search = {
});
},
setup_recent: function() {
var recent = JSON.parse(frappe.boot.user.recent || "[]") || [];
frappe.search.recent = {};
for (var i=0, l=recent.length; i < l; i++) {
var d = recent[i];
if (!(d[0] && d[1])) continue;
if (!frappe.search.recent[d[0]]) {
frappe.search.recent[d[0]] = [];
}
frappe.search.recent[d[0]].push(d[1]);
}
frappe.search.recent = JSON.parse(frappe.boot.user.recent || "[]") || [];
},
find: function(list, txt, process, prepend) {
$.each(list, function(i, item) {
_item = __(item).toLowerCase().replace(/-/g, " ");
if($.isArray(item)) {
_item = item[0];
} else {
_item = item;
}
_item = __(_item).toLowerCase().replace(/-/g, " ");
if(txt===_item || _item.indexOf(txt) !== -1) {
var option = process(item);

View file

@ -30,6 +30,8 @@
{%= __("Reload") %}</a></li>
<li><a href="/index" target="_blank">
{%= __("View Website") %}</a></li>
<li><a href="#background_jobs">
{%= __("Background Jobs") %}</a></li>
<li class="divider"></li>
<li><a href="#" onclick="return frappe.app.logout();">
{%= __("Logout") %}</a></li>

View file

@ -334,9 +334,6 @@ _f.Frm.prototype.refresh_header = function(is_a_different_doc) {
frappe.utils.set_title(this.meta.issingle ? this.doctype : this.docname);
}
if(frappe.ui.toolbar.recent)
frappe.ui.toolbar.recent.add(this.doctype, this.docname, 1);
// show / hide buttons
if(this.toolbar) {
if (is_a_different_doc) {

View file

@ -0,0 +1,18 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# MIT License. See license.txt
from __future__ import unicode_literals
import frappe, unittest
class TestDocumentLocks(unittest.TestCase):
def test_locking(self):
todo = frappe.get_doc(dict(doctype='ToDo', description='test')).insert()
todo_1 = frappe.get_doc('ToDo', todo.name)
todo.lock()
self.assertRaises(frappe.DocumentLockedError, todo_1.lock)
todo.unlock()
todo_1.lock()
self.assertRaises(frappe.DocumentLockedError, todo.lock)
todo_1.unlock()

View file

@ -3,8 +3,11 @@
from __future__ import unicode_literals
'''
File based locking utility
'''
import os
import frappe
from time import time
from frappe.utils import get_site_path, touch_file
@ -12,12 +15,17 @@ class LockTimeoutError(Exception):
pass
def create_lock(name):
'''Creates a file in the /locks folder by the given name'''
lock_path = get_lock_path(name)
if not check_lock(lock_path):
return touch_file(lock_path)
else:
return False
def lock_exists(name):
'''Returns True if lock of the given name exists'''
return os.path.exists(get_lock_path(name))
def check_lock(path, timeout=600):
if not os.path.exists(path):
return False