I still can invoke the same function several times from different clients
The documentation is clear on that part: prompt()
method won't persist LockService
locks as it suspends script execution awaiting user interaction:
The script resumes after the user dismisses the dialog, but Jdbc connections and LockService locks don't persist across the suspension
and in this case, it shows cancellation pop-up straight away
Nothing strange here as well - if
statement evaluates what's inside the condition and coerces the result to Boolean
. Take a look at the waitLock()
method signature - it returns void
, which is a falsy value. You essentially created this: if(false)
and this is why showCancelled()
fires straight away.
Workaround
You could work around that limitation by emulating what Lock
class does. Be aware that this approach is not meant to replace the service, and there are limitations too, specifically:
PropertiesService
has quota on reads / writes. A generous one, but you might want to set toSleep
interval to higher values to avoid burning through your quota at the expense of precision.
- Do not replace the
Lock
class with this custom implementation - V8 does not put your code in a special context, so the services are directly exposed and can be overridden.
function PropertyLock() {
const toSleep = 10;
let timeoutIn = 0, gotLock = false;
const store = PropertiesService.getScriptProperties();
/**
* @returns {boolean}
*/
this.hasLock = function () {
return gotLock;
};
/**
* @param {number} timeoutInMillis
* @returns {boolean}
*/
this.tryLock = function (timeoutInMillis) {
//emulates "no effect if the lock has already been acquired"
if (this.gotLock) {
return true;
}
timeoutIn === 0 && (timeoutIn = timeoutInMillis);
const stored = store.getProperty("locked");
const isLocked = stored ? JSON.parse(stored) : false;
const canWait = timeoutIn > 0;
if (isLocked && canWait) {
Utilities.sleep(toSleep);
timeoutIn -= toSleep;
return timeoutIn > 0 ?
this.tryLock(timeoutInMillis) :
false;
}
if (!canWait) {
return false;
}
store.setProperty("locked", true);
gotLock = true;
return true;
};
/**
* @returns {void}
*/
this.releaseLock = function () {
store.setProperty("locked", false);
gotLock = false;
};
/**
* @param {number} timeoutInMillis
* @returns {boolean}
*
* @throws {Error}
*/
this.waitLock = function (timeoutInMillis) {
const hasLock = this.tryLock(timeoutInMillis);
if (!hasLock) {
throw new Error("Could not obtain lock");
}
return hasLock;
};
}
Version 2
What follows below is closer to the original and solves one important issue with using PropertiesService
as a workaround: if there is an unhandled exception during the execution of the function that acquires the lock, the version above will get the lock stuck indefinitely (can be solved by removing the corresponding script property).
The version below (or as a gist) uses a self-removing time-based trigger set to fire after the current maximum execution time of a script is exceeded (30 minutes) and can be configured to a lower value should one wish to clean up earlier:
var PropertyLock = (() => {
let locked = false;
let timeout = 0;
const store = PropertiesService.getScriptProperties();
const propertyName = "locked";
const triggerName = "PropertyLock.releaseLock";
const toSleep = 10;
const currentGSuiteRuntimeLimit = 30 * 60 * 1e3;
const lock = function () { };
/**
* @returns {boolean}
*/
lock.hasLock = function () {
return locked;
};
/**
* @param {number} timeoutInMillis
* @returns {boolean}
*/
lock.tryLock = function (timeoutInMillis) {
//emulates "no effect if the lock has already been acquired"
if (locked) {
return true;
}
timeout === 0 && (timeout = timeoutInMillis);
const stored = store.getProperty(propertyName);
const isLocked = stored ? JSON.parse(stored) : false;
const canWait = timeout > 0;
if (isLocked && canWait) {
Utilities.sleep(toSleep);
timeout -= toSleep;
return timeout > 0 ?
PropertyLock.tryLock(timeoutInMillis) :
false;
}
if (!canWait) {
return false;
}
try {
store.setProperty(propertyName, true);
ScriptApp.newTrigger(triggerName).timeBased()
.after(currentGSuiteRuntimeLimit).create();
console.log("created trigger");
locked = true;
return locked;
}
catch (error) {
console.error(error);
return false;
}
};
/**
* @returns {void}
*/
lock.releaseLock = function () {
try {
locked = false;
store.setProperty(propertyName, locked);
const trigger = ScriptApp
.getProjectTriggers()
.find(n => n.getHandlerFunction() === triggerName);
console.log({ trigger });
trigger && ScriptApp.deleteTrigger(trigger);
}
catch (error) {
console.error(error);
}
};
/**
* @param {number} timeoutInMillis
* @returns {boolean}
*
* @throws {Error}
*/
lock.waitLock = function (timeoutInMillis) {
const hasLock = PropertyLock.tryLock(timeoutInMillis);
if (!hasLock) {
throw new Error("Could not obtain lock");
}
return hasLock;
};
return lock;
})();
var PropertyLockService = (() => {
const init = function () { };
/**
* @returns {PropertyLock}
*/
init.getScriptLock = function () {
return PropertyLock;
};
return init;
})();
Note that the second version uses static methods and, just as LockService
, should not be instantiated (you could go for a class
and static
methods to enforce this).
References
waitLock()
method reference
prompt()
method reference
- Falsiness concept in JavaScript
与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…