Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
1.1k views
in Technique[技术] by (71.8m points)

google apps script - 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.

See Question&Answers more detail:os

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

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:

  1. 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.
  2. 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

  1. waitLock() method reference
  2. prompt() method reference
  3. Falsiness concept in JavaScript

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to OStack Knowledge Sharing Community for programmer and developer-Open, Learning and Share
Click Here to Ask a Question

...