I have a Google spreadsheet that is used concurrently by a team of people located in the Philippines, South America and the U.S. The load on the spreadsheet is usually no more than ten people at a time. The spreadsheet has two sheets, one being a “Submissions” page and the other is named “Archive”. There is existing data in the spreadsheet but I don’t believe it’s enough to cause these issues.
The logic is pretty simple. There is a dropdown in Column 14 that has the values “Assignment”, “Processing”, “Closed”, “Duplicate” and “Saved”. If the selection is “Assignment” or “Processing” there are timestamps captured at columns 10 and 11. If the selection is “Closed”, “Duplicate” or “Saved” there is a timestamp captured on row 12, it checks to make sure the timestamp is captured and the row is moved to the “Archived” tab.
I’m having issues with the script not being consistent capturing the timestamps or moving the row if there are more than one person using the dropdown. Sometimes it works perfectly and captures the timestamps as needed and moves the data as it should. It seems that always if there’s more than one person working in the spreadsheet it has issues.
The other issue is that it’s very slow. It could be because of some of my attempts to make it consistent with locks and retries.
The errors always seem to be either a “Exceeded max execution time” or “Exception: Lock timeout: another process was holding the lock for too long.”
I’ve rewritten this and tried different logic and I’m still having issues. My priority is that the script works consistently in capturing timestamps and moving the data, secondly is the speed of the script. My code is below, please overlook any logic that may be incorrect, kind of a newbie with Apps Script.
function onEdit(e) {
if (!e || !e.range) {
Logger.log('Event object is invalid or undefined.');
return;
}
var lock = LockService.getScriptLock();
var maxRetries = 5; // Increased number of retries for high contention
var retryDelay = 10000; // 10 seconds delay between retries
var attempt = 0;
while (attempt < maxRetries) {
try {
// Attempt to acquire the lock.
lock.waitLock(15000); // Wait for up to 15 seconds
var range = e.range;
var sheet = range.getSheet();
var value = range.getValue();
// Only proceed if the edit is in 'Submissions' and in column 14.
if (sheet.getName() !== 'Submissions' || range.getColumn() !== 14) {
return;
}
var timestampColumn = getTimestampColumn(value);
if (timestampColumn === null) {
return;
}
// Check if a timestamp is already present; if so, exit
var existingTimestamp = sheet.getRange(range.getRow(), timestampColumn).getValue();
if (existingTimestamp) {
lock.releaseLock();
return;
}
// Write the timestamp
var timestamp = new Date();
sheet.getRange(range.getRow(), timestampColumn).setValue(timestamp);
// Move row to 'Archived' sheet if needed
if (['Ticket closed', 'Unnecessary', 'Saved'].includes(value)) {
moveRowToCompleted(sheet, range.getRow());
}
break; // Exit loop if successful
} catch (e) {
Logger.log('Error: ' + e.toString());
attempt++;
if (attempt < maxRetries) {
Utilities.sleep(retryDelay); // Wait before retrying
} else {
Logger.log('Exceeded maximum retries. Giving up.');
}
} finally {
lock.releaseLock(); // Ensure the lock is always released
}
}
}
function getTimestampColumn(value) {
switch (value) {
case 'Assignment':
return 10;
case 'Processing':
return 11;
case 'Closed':
case 'Duplicate':
case 'Saved':
return 12;
default:
return null;
}
}
function moveRowToCompleted(sheet, rowIndex) {
var numColumns = sheet.getMaxColumns();
var rowValues = sheet.getRange(rowIndex, 1, 1, numColumns).getValues();
var ss = SpreadsheetApp.getActiveSpreadsheet();
var targetSheet = ss.getSheetByName('Archived');
var targetLastRow = targetSheet.getLastRow();
targetSheet.getRange(targetLastRow + 1, 1, 1, numColumns).setValues(rowValues);
// Delete the row after moving to avoid concurrent modification issues.
sheet.deleteRow(rowIndex);
}
I tried with and without locks and retries. I tried with and without Google’s Api. I am currently trying to clear some data out of the spreadsheet and maybe try to take care of some functionality in the Sheet itself if possible.
Edit: I thought I had installed a trigger? I clicked the plus sign on the “Add Trigger” tab at the bottom right of the page. There was then a prompt with dropdowns for “On Edit”(which function), “Head”(what deployment), “From Spreadsheet”(event Source) and “In Open”(event type). When looking at the “Executions” tab, sometimes the exectutions say “Simple Trigger” others just “Trigger”. I may have done something wrong setting it up or trying to use it?
3
Function name
onEdit is a reserved name for the on-edit simple trigger. If you add an on-edit installable trigger and set onEdit as the trigger’s handler function, the onEdit function might be fired twice by the same edit event.
If you use an on-edit installable trigger, change the name of the onEdit function.
Script performance
In general, calls to Google Apps Script methods are slow, but the SpreadsheetApp.Sheet.deleterow
and any method that changes the spreadsheet structure is very expensive regarding execution time. You might improve the script performance by using the Advanced Sheet Service, more specifically, the Sheets.Spreadsheets.batchUpdate
method.
Tips:
- Instead of using
SpreadsheetApp.Range.getValue
to get the value of the edited cell, usee.value
. Please remember that this property will returnundefined
when the cell value is cleared. - In the same way, instead of using
SpreadsheetApp.Range.getColumn
, usee.range.columnStart
. This property is not documented but has been available for a long time.
Errors
Exceeded maximum execution time
Simple triggers have a 30-second execution time limit. Installable triggers might have 6 or 30 minutes as the execution time limit, based on the type of account used. For details, see https://developers.google.com/apps-script/quotas
Note: At this time, the quotas page mentions 6 minutes for both types of accounts, but in practice, Workspace accounts, most of the time, have a 30-minute time limit.
Exception: Lock timeout: another process was holding the lock for too long.
This error is caused by the Lock Service script and the users’ behavior. The user’s behavior might be caused by the spreadsheet design.
You might have to rethink your spreadsheet design if you can’t change the user behavior and the script control flow.
Reference
- Simple Triggers
- Installable Triggers
- Event Objects
2
As Wicket notes, you should use a simple trigger and delete any installable triggers you may have created, or rename onEdit(e)
to something like installableOnEdit(e)
and create exactly one installable trigger to run it. Simple triggers may only run for 30 seconds, while installable triggers may run up to ten times longer. But the root of the problem is probably not with trigger limited runtime — the problem is with the exceedingly long time you say it takes to get a simple timestamp in place and a row moved.
Chances are that the bad performance is caused by the spreadsheet rather than the script. To improve spreadsheet performance, see my optimization tips.
The code unnecessarily calls many SpreadsheetApp
methods inside the loop, including Range.getSheet()
, Range.getValue()
, Sheet.getName()
, Range.getColumn()
and Range.getRow()
. Replace these calls with references to the event object e
and move as many API calls outside the loop as possible. See these onEdit(e) optimization tips.
Here’s a blueprint for a version that observes some best practices:
'use strict';
/**
* Simple trigger that runs each time the user manually edits the spreadsheet.
*
* @param {Object} e The onEdit() event object.
*/
function onEdit(e) {
if (!e) throw new Error('Event object is invalid or undefined.');
if (e.range.columnStart !== 14) return;
const timestampColumn = getTimestampColumn(e.value);
if (timestampColumn === null) return;
const sheet = e.range.getSheet();
if (sheet.getName() !== 'Submissions') return;
const timestampCell = sheet.getRange(e.range.rowStart, timestampColumn);
if (timestampCell.getValue()) return;
timestampCell.setValue(new Date());
const maxRetries = 5;
let attempt = 0;
while (attempt < maxRetries) {
try {
const lock = LockService.getScriptLock();
lock.waitLock(5 * 1000);
if (['Ticket closed', 'Unnecessary', 'Saved'].includes(e.value)) moveRowToCompleted(sheet, e.range.rowStart);
return;
} catch (error) {
console.log(error);
attempt += 1;
if (attempt === maxRetries) {
console.log('Exceeded maximum retries. Giving up.');
return;
}
}
}
}
Locks are automatically cleared when the script completes so the finally()
section can be omitted here.
Whether locking is required in the first place seems to only depend on whether the moveRowToCompleted
function is atomic. If it uses Sheet.appendRow()
rather than Sheet.setValues()
, and clears the source row rather than deletes it, chances are that it is atomic and you can do without locking. Using Range.clearContent()
instead of Sheet.deleteRow()
would probably improve performance in any case. Try this:
/**
* Copies a row from the current sheet to 'Archived' and
* clears the row in the current sheet.
*
* @param {SpreadsheetApp.Sheet} sheet The sheet where the row is.
* @param {Number} rowIndex The row number of the row.
*/
function moveRowToCompleted(sheet, rowIndex) {
const range = sheet.getRange(rowIndex, 1, 1, sheet.getLastColumn());
const values = range.getValues().flat();
sheet.getParent().getSheetByName('Archived').appendRow(values);
range.clearContent();
}
If that is acceptable, you can remove locking in onEdit(e)
.
5
I renamed the trigger installableOnEdit
and reworked the code and at first it did seem to work beautifully, but just when moving one row of data at a time. I tested using six rows, didn’t quite make it to moving the data, just the selection for the first timestamp (Assignment). The script ran and populated the 4th record of the 6th. No other timestamps populated. I stopped there because of the failure and that will cause issues. This was the code I tested:
function installableonEdit(e) {
if (!e || !e.range) {
Logger.log('Event object is invalid or undefined.');
return;
}
var range = e.range;
var sheet = range.getSheet();
var value = range.getValue();
var startRow = range.getRow();
var endRow = range.getLastRow();
var column = range.getColumn();
// Only proceed if the edit is in 'Submissions' and in column 14.
if (sheet.getName() !== 'Submissions' || column !== 14) {
return;
}
// Loop through all affected rows
for (var row = startRow; row <= endRow; row++) {
var currentValue = sheet.getRange(row, column).getValue();
var timestampColumn = getTimestampColumn(currentValue);
if (timestampColumn === null) {
continue;
}
// Check if a timestamp is already present; if so, skip this row
var existingTimestamp = sheet.getRange(row, timestampColumn).getValue();
if (existingTimestamp) {
continue;
}
// Write the timestamp
var timestamp = new Date();
sheet.getRange(row, timestampColumn).setValue(timestamp);
// Move row to 'Completed' sheet if needed
if (['Ticket reviewed', 'Unnecessary', 'Saved'].includes(currentValue)) {
moveRowToCompleted(sheet, row);
}
}
}
function getTimestampColumn(value) {
switch (value) {
case 'Assigned':
return 50;
case 'Processed':
return 51;
case 'Ticket reviewed':
case 'Unnecessary':
case 'Saved':
return 52;
default:
return null;
}
}
function moveRowToCompleted(sheet, rowIndex) {
var numColumns = sheet.getMaxColumns();
var rowValues = sheet.getRange(rowIndex, 1, 1, numColumns).getValues();
var ss = SpreadsheetApp.getActiveSpreadsheet();
var targetSheet = ss.getSheetByName('Archived');
var targetLastRow = targetSheet.getLastRow();
targetSheet.getRange(targetLastRow + 1, 1, 1, numColumns).setValues(rowValues);
// Clear the row content instead of deleting the row to avoid concurrent modification issues
sheet.getRange(rowIndex, 1, 1, numColumns).clearContent();
}
These were the big changes:
- Lock removed: No need for locking since we’re using atomic
operations. - Reduced API calls: We cache values like sheet, range,
and row before the loop starts. - Clear instead of delete: Using
clearContent()
ensures that we’re not
changing the row indices, which could cause issues with concurrent
edits.
2