LockService does not prevent concurrent script runs when paired with inputBox [duplicate] - google-apps-script

I have a script in Google Sheets, which runs a function when a user clicks on an image. The function modifies content in cells and in order to avoid simultaneous modifications I need to use lock for this function.
I cannot get, why this doesn't work (I still can invoke same function several times from different clients):
function placeBidMP1() {
var lock = LockService.getScriptLock();
lock.waitLock(10000)
placeBid('MP1', 'I21:J25');
lock.releaseLock();
}
placeBid() function is below:
function placeBid(lotName, range) {
var firstPrompt = ui.prompt(lotName + '-lot', 'Please enter your name:', ui.ButtonSet.OK);
var firstPromptSelection = firstPrompt.getSelectedButton();
var userName = firstPrompt.getResponseText();
if (firstPromptSelection == ui.Button.OK) {
do {
var secondPrompt = ui.prompt('Increase by', 'Amount (greater than 0): ', ui.ButtonSet.OK_CANCEL);
var secondPromptSelection = secondPrompt.getSelectedButton();
var increaseAmount = parseInt(secondPrompt.getResponseText());
} while (!(secondPromptSelection == ui.Button.CANCEL) && !(/^[0-9]+$/.test(increaseAmount)) && !(secondPromptSelection == ui.Button.CLOSE));
if (secondPromptSelection != ui.Button.CANCEL & secondPromptSelection != ui.Button.CLOSE) {
var finalPrompt = ui.alert("Price for lot will be increased by " + increaseAmount + " CZK. Are you sure?", ui.ButtonSet.YES_NO);
if (finalPrompt == ui.Button.YES) {
var cell = SpreadsheetApp.getActiveSheet().getRange(range);
var currentCellValue = Number(cell.getValue());
cell.setValue(currentCellValue + Number(increaseAmount));
bidsHistorySheet.appendRow([userName, lotName, cell.getValue()]);
SpreadsheetApp.flush();
showPriceIsIncreased();
} else {showCancelled();}
} else {showCancelled();}
} else {showCancelled();}
}
I have several placeBidMP() functions for different elements on the Sheet and need to lock only separate function from being invoked multiple times.
I've tried as well next way:
if (lock.waitLock(10000)) {
placeBidMP1(...);
}
else {
showCancelled();
}
and in this case, it shows cancellation pop-up straight away.

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

Related

list activities on google drive

I would like to be able to upload my file activities with API from google drive activity or other ideas. Each of my files are stored in a tree structure that contains the name of my client. And in the following put it in an excel file in order to be able to sort it correctly for example if a file has been modified more than 2 times in one day, it is validated
Currently, I used the code provided by google but I can't find the directory of these modified files
I thank you in advance, I really block, other ideas or API are welcome.
Sincerly
/**
* Lists 10 activity for a Drive user.
* #see https://developers.google.com/drive/activity/v2/reference/rest/v2/activity/query
*/
function listDriveActivity() {
const request = {
"ancestor_name": "items/root",
"filter": "time >= \"2022-06-01T00:00:00Z\" time < \"2022-06-30T00:00:00Z\" detail.action_detail_case:EDIT",
"consolidation_strategy": { "legacy": {} },
"page_size": 10,
};
try {
// Activity.query method is used Query past activity in Google Drive.
const response = DriveActivity.Activity.query(request);
const activities = response.activities;
if (!activities || activities.length === 0) {
console.log('No activity.');
return;
}
console.log('Recent activity:');
for (const activity of activities) {
// get time information of activity.
const time = getTimeInfo(activity);
// get the action details/information
const action = getActionInfo(activity.primaryActionDetail);
// get the actor's details of activity
const actors = activity.actors.map(getActorInfo);
// get target information of activity.
const targets = activity.targets.map(getTargetInfo);
// print the time,actor,action and targets of drive activity.
console.log('%s: %s, %s, %s', time, actors, action, targets);
}
} catch (err) {
// TODO (developer) - Handle error from drive activity API
console.log('Failed with an error %s', err.message);
}
}
/**
* #param {object} object
* #return {string} Returns the name of a set property in an object, or else "unknown".
*/
function getOneOf(object) {
for (const key in object) {
return key;
}
return 'unknown';
}
/**
* #param {object} activity Activity object.
* #return {string} Returns a time associated with an activity.
*/
function getTimeInfo(activity) {
if ('timestamp' in activity) {
return activity.timestamp;
}
if ('timeRange' in activity) {
return activity.timeRange.endTime;
}
return 'unknown';
}
/**
* #param {object} actionDetail The primary action details of the activity.
* #return {string} Returns the type of action.
*/
function getActionInfo(actionDetail) {
return getOneOf(actionDetail);
}
/**
* #param {object} user The User object.
* #return {string} Returns user information, or the type of user if not a known user.
*/
function getUserInfo(user) {
if ('knownUser' in user) {
const knownUser = user.knownUser;
const isMe = knownUser.isCurrentUser || false;
return isMe ? 'people/me' : knownUser.personName;
}
return getOneOf(user);
}
/**
* #param {object} actor The Actor object.
* #return {string} Returns actor information, or the type of actor if not a user.
*/
function getActorInfo(actor) {
if ('user' in actor) {
return getUserInfo(actor.user);
}
return getOneOf(actor);
}
/**
* #param {object} target The Target object.
* #return {string} Returns the type of a target and an associated title.
*/
function getTargetInfo(target) {
if ('driveItem' in target) {
const title = target.driveItem.title || 'unknown';
return 'driveItem:"' + title + '"';
}
if ('drive' in target) {
const title = target.drive.title || 'unknown';
return 'drive:"' + title + '"';
}
if ('fileComment' in target) {
const parent = target.fileComment.parent || {};
const title = parent.title || 'unknown';
return 'fileComment:"' + title + '"';
}
return getOneOf(target) + ':unknown';
}
Final goal: to get the amount of days I work for all my clients.
That a idea
After some search and code (some help with GTP so sorry for begin code)
function listDriveActivity(sheet,timezone) {
let pageToken = null;
do {
try {
// Activity.query method is used Query past activity in Google Drive.
const response = DriveActivity.Activity.query({ "ancestor_name": "items/13oHhdSDQqnM4ppO48FPJq7HAmVjR27H5", //"items/root",
"filter": "time >= \"2022-01-01T00:00:00Z\" time < \"2022-01-31T00:00:00Z\" detail.action_detail_case:EDIT",
"consolidation_strategy": { "legacy": {} },"pageSize": 10,pageToken: pageToken});
//Logger.log(response);
const activities = response.activities;
if (!activities || activities.length === 0) {
console.log('No activity.');
return;
}
//console.log('Recent activity:');
for (const activity of activities) {
// get time information of activity.
const time = getTimeInfo(activity);
// get the action details/information
const action = getActionInfo(activity.primaryActionDetail);
// get the actor's details of activity
//const actors = activity.actors.map(getActorInfo);
// get target information of activity.
const targets = activity.targets.map(getTargetInfo);
// print the time,actor,action and targets of drive activity.
// const folderName = activity.targets.map(getFileArborescenceByName);
const folderName = activity.targets.map(getFileArborescenceByID);
var NomDossier = JSON.stringify(folderName).replace(/\[\"|\"\]/g,'');
const ClientName = getClient(NomDossier);
//console.log('%s: %s, %s, %s, %s', time, action, targets, folderName, ClientName);
const timeAsDate = new Date(time);
const lastModified = Utilities.formatDate(timeAsDate, "UTC", "dd-MM-yyyy HH:mm");
var Nomfichier = JSON.stringify(targets).replace(/\[\"|\"\]/g,'');
sheet.appendRow([lastModified, action, Nomfichier, NomDossier, ClientName]);
}
pageToken = response.nextPageToken;
}
catch (err) {
// TODO (developer) - Handle error from drive activity API
console.log('Failed with an error %s', err.message);
}
} while (pageToken);
return sheet;
}
function getFileArborescenceByID(activity) {
if ('driveItem' in activity) {
try {
const fileName = activity.driveItem.name;
var modif = getLastPart (fileName);
var file = DriveApp.getFileById(modif);
var folders = [];
var parent = file.getParents();
while (parent.hasNext()) {
parent = parent.next();
folders.push(parent.getName());
parent = parent.getParents();
}
if (folders.length) {
// Display the full folder path
var folderName = (folders.reverse().join("/"));
}
//var ClientName = getClient(folderName);
}
catch (err) {
// TODO (developer) - Handle error from drive activity API
console.log('Failed with an error %s', err.message);
}
}
//return 'folderName:"' + folderName + '", Client :"' + ClientName + '"';
return folderName;

Exception: Failed to retrieve form data. Please wait and try again

I have been getting this error a lot lately due to which my customer thinks this is a bug from my end but it issue from Google's end.
const respondentEmail = e.response.getRespondentEmail();, is where it is giving me an error, can someone help me solve this issue as I'm not able to find any on internet
async function onFormSubmit(e) {
Logger.log("Inside On Form Submit");
const form = FormApp.getActiveForm();
let latestFormResponse;
const formResponses = form.getResponses();
const respondentEmail = e.response.getRespondentEmail();
if (respondentEmail === "") return false
///.. other code
}
can anyone please help me out? I'm loosing customers due to this bug :(
Assuming that the issue is intermittent, you could retry the problematic call with exponential backoff. Replace this line:
const respondentEmail = e.response.getRespondentEmail();
...with:
let respondentEmail;
try {
respondentEmail = exponentialBackoff_(() => e.response.getRespondentEmail());
} catch (error) {
console.log(error.message);
respondentEmail = '';
}
Here's a simple exponentialBackoff_() implementation:
/**
* Calls a closure, retries on failure, and returns the value it gives.
*
* Usage:
* exponentialBackoff_(myFunction);
* // ...or:
* exponentialBackoff_(() => myFunction(param1, param2));
* // ...or:
* const result = exponentialBackoff_(() => myFunction(param1, param2));
* // ...or:
* const respondentEmail = exponentialBackoff_(() => e.response.getRespondentEmail());
*
* #see https://en.wikipedia.org/wiki/Exponential_backoff
* #param {Function} action The closure to call.
* #param {Number} maxNumTries Optional. The number of times to retry. Defaults to 5.
* #return {Object} The closure return value.
*/
function exponentialBackoff_(action, maxNumTries = 5) {
// version 1.0, written by --Hyde, 29 December 2022
// - see https://stackoverflow.com/a/74952372/13045193
for (let tryNumber = 1; tryNumber <= maxNumTries; tryNumber++) {
try {
return action();
} catch (error) {
if (tryNumber >= maxNumTries) {
throw error;
}
Utilities.sleep(2 ** (tryNumber - 1) * 1000);
}
}
}
In my opinion, this issue occurs from google and we don’t have much control over it, try delaying the call using Sleep method.

2nd Function not executing - suitescript

I have the below code. I want the function called execute2 to run after the function execute. When I put the script task inside the first function and don't include execute2 then it all runs fine and the second separate script runs. But when I run it in the below format it does not. What is preventing execute 2 from actually running here?
Thank you
/**
* #NApiVersion 2.x
* #NScriptType ScheduledScript
* #NModuleScope SameAccount
*/
define(['N/task'],
/**
* #param {record} record
* #param {search} search
*/
function(task) {
var FILE_ID = 433961;
var SEARCH_ID = 1610;
function execute(scriptContext) {
var searchTask1 = task.create({
taskType: task.TaskType.SEARCH
});
searchTask1.savedSearchId = SEARCH_ID;
searchTask1.fileId = FILE_ID;
var searchTaskId1 = searchTask1.submit();
//
//Next Task
//
FILE_ID = 433963;
SEARCH_ID = 1606;
var searchTask2 = task.create({
taskType: task.TaskType.SEARCH
});
searchTask2.savedSearchId = SEARCH_ID;
searchTask2.fileId = FILE_ID;
var searchTaskId2 = searchTask2.submit();
//
//Next Task
//
FILE_ID = 434489;
SEARCH_ID = 1637;
var searchTask3 = task.create({
taskType: task.TaskType.SEARCH
});
searchTask3.savedSearchId = SEARCH_ID;
searchTask3.fileId = FILE_ID;
var searchTaskId3 = searchTask3.submit();
//
// Next Task
//
FILE_ID = 434490;
SEARCH_ID = 1606;
var searchTask4 = task.create({
taskType: task.TaskType.SEARCH
});
searchTask4.savedSearchId = SEARCH_ID;
searchTask4.fileId = FILE_ID;
var searchTaskId4 = searchTask4.submit();
}
function execute2(scriptContext){
var scriptTask = task.create({
taskType: task.TaskType.SCHEDULED_SCRIPT,
scriptId: "customscript_email_sender",
deploymentId: "customdeploy_email_sender_d"
});
var scriptTaskId = scriptTask.submit();
}
return {
execute: execute,
execute2: execute2,
};
});
Building on the comment from W.S., all you need to do is to define two functions that encapsulate your business logic and then call those functions in order within your scheduled script's entry point.
Below is a simple example, showing the scheduled script executing doFirstTask() followed by doSecondTask(). You can redefine those however you like to meet your own needs.
/**
* #NApiVersion 2.x
* #NScriptType ScheduledScript
*/
define([], function() {
return {
execute: function (context)
{
doFirstTask(context);
doSecondTask(context);
}
}
});
/**
* This function contains all of the logic you need to execute
* for the first of your two tasks.
*
* #param context Passed in from the scheduled script's entry point.
*/
function doFirstTask(context) {
log.debug({
title: context.type + ': First Task',
details: 'The first of many great feats.'
});
}
/**
* This function contains all of the logic you need to execute
* for the second of your two tasks.
*
* #param context Passed in from the scheduled script's entry point.
*/
function doSecondTask(context) {
log.debug({
title: context.type + ': Second Task',
details: 'Second, even more impressive than the first.'
});
}
Here's a screenshot showing what the output looks like.

Google Calendar API App Script - "Error: Not Found: Skipping"

I'm using this App Script Populate a team vacation calendar
I'm receiving the following error for 1 person in a Google Group of 50 users.
'Error retriving events for email#email.com, vacation:
GoogleJsonResponseException: API call to calendar.events.list failed
with error: Not Found; skipping'
When setting a breakpoint, it fails on this import event.
function importEvent(username, event) {
event.summary = '[' + username + '] ' + event.summary;
event.organizer = {
id: TEAM_CALENDAR_ID,
};
event.attendees = [];
console.log('Importing: %s', event.summary);
try {
Calendar.Events.import(event, TEAM_CALENDAR_ID);
} catch (e) {
console.error('Error attempting to import event: %s. Skipping.',
e.toString());
}
}
do {
params.pageToken = pageToken;
let response;
try {
response = Calendar.Events.list(user.getEmail(), params);
} catch (e) {
console.error('Error retriving events for %s, %s: %s; skipping',
user, keyword, e.toString());
continue;
}
events = events.concat(response.items.filter(function(item) {
return shoudImportEvent(user, keyword, item);
}));
pageToken = response.nextPageToken;
} while (pageToken);
return events;
}
This is the stack:
"GoogleJsonResponseException: API call to calendar.events.list failed
with error: Not Found at findEvents (Code:111:34) at Code:48:20 at
Array.forEach () at Code:47:14 a…"
This is the full code
// Set the ID of the team calendar to add events to. You can find the calendar's
// ID on the settings page.
let TEAM_CALENDAR_ID = 'CALENDAR ID';
// Set the email address of the Google Group that contains everyone in the team.
// Ensure the group has less than 500 members to avoid timeouts.
let GROUP_EMAIL = 'GROUP EMAIL';
let KEYWORDS = ['vacation', 'ooo', 'out of office', 'offline'];
let MONTHS_IN_ADVANCE = 3;
/**
* Sets up the script to run automatically every hour.
*/
function setup() {
let triggers = ScriptApp.getProjectTriggers();
if (triggers.length > 0) {
throw new Error('Triggers are already setup.');
}
ScriptApp.newTrigger('sync').timeBased().everyHours(1).create();
// Runs the first sync immediately.
sync();
}
/**
* Looks through the group members' public calendars and adds any
* 'vacation' or 'out of office' events to the team calendar.
*/
function sync() {
// Defines the calendar event date range to search.
let today = new Date();
let maxDate = new Date();
maxDate.setMonth(maxDate.getMonth() + MONTHS_IN_ADVANCE);
// Determines the time the the script was last run.
let lastRun = PropertiesService.getScriptProperties().getProperty('lastRun');
lastRun = lastRun ? new Date(lastRun) : null;
// Gets the list of users in the Google Group.
let users = GroupsApp.getGroupByEmail(GROUP_EMAIL).getUsers();
// For each user, finds events having one or more of the keywords in the event
// summary in the specified date range. Imports each of those to the team
// calendar.
let count = 0;
users.forEach(function(user) {
let username = user.getEmail().split('#')[0];
KEYWORDS.forEach(function(keyword) {
let events = findEvents(user, keyword, today, maxDate, lastRun);
events.forEach(function(event) {
importEvent(username, event);
count++;
}); // End foreach event.
}); // End foreach keyword.
}); // End foreach user.
PropertiesService.getScriptProperties().setProperty('lastRun', today);
console.log('Imported ' + count + ' events');
}
/**
* Imports the given event from the user's calendar into the shared team
* calendar.
* #param {string} username The team member that is attending the event.
* #param {Calendar.Event} event The event to import.
*/
function importEvent(username, event) {
event.summary = '[' + username + '] ' + event.summary;
event.organizer = {
id: TEAM_CALENDAR_ID,
};
event.attendees = [];
console.log('Importing: %s', event.summary);
try {
Calendar.Events.import(event, TEAM_CALENDAR_ID);
} catch (e) {
console.error('Error attempting to import event: %s. Skipping.',
e.toString());
}
}
/**
* In a given user's calendar, looks for occurrences of the given keyword
* in events within the specified date range and returns any such events
* found.
* #param {Session.User} user The user to retrieve events for.
* #param {string} keyword The keyword to look for.
* #param {Date} start The starting date of the range to examine.
* #param {Date} end The ending date of the range to examine.
* #param {Date} optSince A date indicating the last time this script was run.
* #return {Calendar.Event[]} An array of calendar events.
*/
function findEvents(user, keyword, start, end, optSince) {
let params = {
q: keyword,
timeMin: formatDateAsRFC3339(start),
timeMax: formatDateAsRFC3339(end),
showDeleted: true,
};
if (optSince) {
// This prevents the script from examining events that have not been
// modified since the specified date (that is, the last time the
// script was run).
params.updatedMin = formatDateAsRFC3339(optSince);
}
let pageToken = null;
let events = [];
do {
params.pageToken = pageToken;
let response;
try {
response = Calendar.Events.list(user.getEmail(), params);
} catch (e) {
console.error('Error retriving events for %s, %s: %s; skipping',
user, keyword, e.toString());
continue;
}
events = events.concat(response.items.filter(function(item) {
return shoudImportEvent(user, keyword, item);
}));
pageToken = response.nextPageToken;
} while (pageToken);
return events;
}
/**
* Determines if the given event should be imported into the shared team
* calendar.
* #param {Session.User} user The user that is attending the event.
* #param {string} keyword The keyword being searched for.
* #param {Calendar.Event} event The event being considered.
* #return {boolean} True if the event should be imported.
*/
function shoudImportEvent(user, keyword, event) {
// Filters out events where the keyword did not appear in the summary
// (that is, the keyword appeared in a different field, and are thus
// is not likely to be relevant).
if (event.summary.toLowerCase().indexOf(keyword.toLowerCase) < 0) {
return false;
}
if (!event.organizer || event.organizer.email == user.getEmail()) {
// If the user is the creator of the event, always imports it.
return true;
}
// Only imports events the user has accepted.
if (!event.attendees) return false;
let matching = event.attendees.filter(function(attendee) {
return attendee.self;
});
return matching.length > 0 && matching[0].responseStatus == 'accepted';
}
/**
* Returns an RFC3339 formated date String corresponding to the given
* Date object.
* #param {Date} date a Date.
* #return {string} a formatted date string.
*/
function formatDateAsRFC3339(date) {
return Utilities.formatDate(date, 'UTC', 'yyyy-MM-dd\'T\'HH:mm:ssZ');
}

LockService lock does not persist after a prompt is shown

I have a script in Google Sheets, which runs a function when a user clicks on an image. The function modifies content in cells and in order to avoid simultaneous modifications I need to use lock for this function.
I cannot get, why this doesn't work (I still can invoke same function several times from different clients):
function placeBidMP1() {
var lock = LockService.getScriptLock();
lock.waitLock(10000)
placeBid('MP1', 'I21:J25');
lock.releaseLock();
}
placeBid() function is below:
function placeBid(lotName, range) {
var firstPrompt = ui.prompt(lotName + '-lot', 'Please enter your name:', ui.ButtonSet.OK);
var firstPromptSelection = firstPrompt.getSelectedButton();
var userName = firstPrompt.getResponseText();
if (firstPromptSelection == ui.Button.OK) {
do {
var secondPrompt = ui.prompt('Increase by', 'Amount (greater than 0): ', ui.ButtonSet.OK_CANCEL);
var secondPromptSelection = secondPrompt.getSelectedButton();
var increaseAmount = parseInt(secondPrompt.getResponseText());
} while (!(secondPromptSelection == ui.Button.CANCEL) && !(/^[0-9]+$/.test(increaseAmount)) && !(secondPromptSelection == ui.Button.CLOSE));
if (secondPromptSelection != ui.Button.CANCEL & secondPromptSelection != ui.Button.CLOSE) {
var finalPrompt = ui.alert("Price for lot will be increased by " + increaseAmount + " CZK. Are you sure?", ui.ButtonSet.YES_NO);
if (finalPrompt == ui.Button.YES) {
var cell = SpreadsheetApp.getActiveSheet().getRange(range);
var currentCellValue = Number(cell.getValue());
cell.setValue(currentCellValue + Number(increaseAmount));
bidsHistorySheet.appendRow([userName, lotName, cell.getValue()]);
SpreadsheetApp.flush();
showPriceIsIncreased();
} else {showCancelled();}
} else {showCancelled();}
} else {showCancelled();}
}
I have several placeBidMP() functions for different elements on the Sheet and need to lock only separate function from being invoked multiple times.
I've tried as well next way:
if (lock.waitLock(10000)) {
placeBidMP1(...);
}
else {
showCancelled();
}
and in this case, it shows cancellation pop-up straight away.
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