谷歌应用脚​​本超时 ~ 5 分钟?

Posted

技术标签:

【中文标题】谷歌应用脚​​本超时 ~ 5 分钟?【英文标题】:Google app script timeout ~ 5 minutes? 【发布时间】:2011-12-12 20:45:30 【问题描述】:

我的 google 应用程序脚本正在遍历用户的 google 驱动器文件,并将文件复制并有时移动到其他文件夹。脚本总是在 5 分钟后停止,日志中没有错误消息。

我在一次运行中对数十甚至数千个文件进行排序。

是否有任何设置或解决方法?

【问题讨论】:

您可以通过使用 html 服务在工作的子集上启动脚本的单独“迭代”来改变规则。 Bruce McPherson has blogged about it. 如果您是企业客户,现在可以注册Early Access to App Maker,其中包括Flexible Quotas。 相关:***.com/q/63604878 【参考方案1】:

您可以做的一件事(这当然取决于您要完成的工作)是:

    将必要的信息(例如循环计数器)存储在电子表格或其他永久存储(例如 ScriptProperties)中。 让您的脚本每五分钟左右终止一次。 设置时间驱动的触发器以每五分钟运行一次脚本(或使用 Script service 以编程方式创建触发器)。 每次运行时,从您使用过的永久存储中读取保存的数据,然后从上次停止的地方继续运行脚本。

这不是一个万能的解决方案,如果您发布代码,人们将能够更好地为您提供帮助。

这是我每天使用的脚本的简化代码摘录:

function runMe() 
  var startTime= (new Date()).getTime();
  
  //do some work here
  
  var scriptProperties = PropertiesService.getScriptProperties();
  var startRow= scriptProperties.getProperty('start_row');
  for(var ii = startRow; ii <= size; ii++) 
    var currTime = (new Date()).getTime();
    if(currTime - startTime >= MAX_RUNNING_TIME) 
      scriptProperties.setProperty("start_row", ii);
      ScriptApp.newTrigger("runMe")
               .timeBased()
               .at(new Date(currTime+REASONABLE_TIME_TO_WAIT))
               .create();
      break;
     else 
      doSomeWork();
    
  
  
  //do some more work here
  

注意#1:变量REASONABLE_TIME_TO_WAIT 应该足够大以触发新触发器。 (我将它设置为 5 分钟,但我认为它可能会更短)。

注意#2:doSomeWork() 必须是一个执行相对较快的函数(我会说不到 1 分钟)。

注意#3:Google 已弃用 Script Properties,并引入了 Properties Service 取而代之。功能做了相应的修改。

注意#4:第二次调用该函数时,它将for循环的第i个值作为字符串。所以你必须把它转换成整数

【讨论】:

触发器的触发频率是否有限制?我认为每 24 小时或某事可能会有一个触发限制……谢谢! 我认为这不适用于附加组件。附加定时触发器仅允许每小时执行一次。您是否知道任何其他解决方案可以保持任务运行并处理来自 Excel 工作表的大量数据。 Google 已弃用此方法。有替代方案吗? developers.google.com/apps-script/reference/properties/… @iamtoc 当脚本属性被禁用时,您仍然可以使用 PropertiesService。这是一个非常小的编辑 REASONABLE_TIME_TO_WAIT有什么用,不能只做.at(new Date(currTime))吗?【参考方案2】:

另外,尽量减少对谷歌服务的调用量。例如,如果您想更改电子表格中的一系列单元格,请不要读取每个单元格,对其进行变异并将其存储回来。 而是将整个范围(使用Range.getValues())读入内存,对其进行变异并一次存储所有内容(使用Range.setValues())。

这应该会为您节省大量的执行时间。

【讨论】:

【参考方案3】:

我使用 ScriptDB 来保存我的位置,同时在循环中处理大量信息。该脚本可以/确实超过 5 分钟的限制。通过在每次运行期间更新 ScriptDb,脚本可以从数据库中读取状态并从中断处继续,直到所有处理完成。试试这个策略,我想你会对结果感到满意。

【讨论】:

在遍历电子表格上的 750 个电子邮件地址的脚本中遇到了类似的问题。您如何存储脚本停止的位置并继续执行? 您能否提供更多详细信息...如果可能,请提供示例代码...或链接到更多详细信息。 ScriptDb 已弃用。【参考方案4】:

想出一种方法来拆分您的工作,使其花费不到 6 分钟,因为这是任何脚本的限制。在第一遍中,您可以在电子表格中迭代并存储文件和文件夹列表,并为第 2 部分添加时间驱动触发器。

在第 2 部分中,在处理列表时删除列表中的每个条目。当列表中没有项目时,删除触发器。

这就是我处理大约 1500 行的工作表的方式,这些工作表分布到大约十几个不同的电子表格中。由于对电子表格的调用次数过多,它会超时,但会在触发器再次运行时继续。

【讨论】:

准确的说最大执行时间是6分钟:“当前最大脚本执行时间限制(6分钟)”这里注明developers.google.com/apps-script/scriptdb 谢谢,我已经解决了。此外,我为我的脚本使用了 10 分钟触发器,以确保执行之间没有重叠。我不确定 Google 是如何决定启动时间驱动的触发器的,所以一点缓冲也无妨。 所以您可以将所有数据存储到 ScriptDb 并只做一小部分(因为有 6 分钟的限制),然后在下一次运行中继续(这将由计时器触发)。听起来不错的解决方案。 另外,您现在可以随时创建触发器,因此我的脚本会在每次启动时的 7 分钟后创建一个触发器(如果它知道必须继续执行)。跨度> 【参考方案5】:

Anton Soradoi's answer 看起来不错,但考虑使用Cache Service 而不是将数据存储到临时工作表中。

 function getRssFeed() 
   var cache = CacheService.getPublicCache();
   var cached = cache.get("rss-feed-contents");
   if (cached != null) 
     return cached;
   
   var result = UrlFetchApp.fetch("http://example.com/my-slow-rss-feed.xml"); // takes 20 seconds
   var contents = result.getContentText();
   cache.put("rss-feed-contents", contents, 1500); // cache for 25 minutes
   return contents;
 

另请注意,截至 2014 年 4 月,limitation of script runtime为 6 分钟


G Suite 商务/企业/教育和抢先体验用户:

截至 2018 年 8 月,这些用户的最大脚本运行时间现在设置为 30 分钟。

【讨论】:

在我看来,这似乎是解决问题的最简单方法,因为您不需要设置也不关心任何其他资源(电子表格、数据库等)和所有脚本逻辑保留在脚本本身中。谢谢! 你能举一个泛化函数的例子吗?【参考方案6】:

配额

单个脚本的最长执行时间为 6 分钟/执行 - https://developers.google.com/apps-script/guides/services/quotas

但您需要熟悉其他限制。例如,您只允许每天 1 小时的总触发器运行时间,因此您不能将一个长函数分解为 12 个不同的 5 分钟块。

优化

也就是说,您真的需要花 6 分钟来执行的原因很少。 javascript 在几秒钟内对数千行数据进行排序应该没有问题。对 Google Apps 本身的服务调用可能会影响您的性能。

您可以编写脚本以最大限度地利用内置缓存,方法是最大限度地减少读写次数。交替读取和写入命令很慢。要加快脚本速度,只需使用一条命令将所有数据读入数组,对数组中的数据执行任何操作,然后使用一条命令将数据写出。 - https://developers.google.com/apps-script/best_practices

批处理

您能做的最好的事情就是减少服务调用的数量。谷歌通过允许大部分 API 调用的批处理版本来实现这一点。

作为一个简单的例子,代替这个

for (var i = 1; i <= 100; i++) 
  SpreadsheetApp.getActiveSheet().deleteRow(i);

这样做

SpreadsheetApp.getActiveSheet().deleteRows(i, 100);

在第一个循环中,您不仅需要对工作表上的 deleteRow 调用 100 次,而且还需要获取活动工作表 100 次。第二个变体的性能应该比第一个好几个数量级。

交织读取和写入

此外,您还应该非常小心,不要在阅读和写作之间频繁地来回走动。您不仅会失去批处理操作的潜在收益,而且 Google 将无法使用其内置缓存。

每次读取时,我们必须先清空(提交)写入缓存以确保您正在读取最新数据(您可以通过调用SpreadsheetApp.flush() 强制写入缓存)。同样,每次您进行写入时,我们都必须丢弃读取缓存,因为它不再有效。因此,如果您可以避免交叉读取和写入,您将充分利用缓存。 - http://googleappsscript.blogspot.com/2010/06/optimizing-spreadsheet-operations.html

例如,代替这个

sheet.getRange("A1").setValue(1);
sheet.getRange("B1").setValue(2);
sheet.getRange("C1").setValue(3);
sheet.getRange("D1").setValue(4);

这样做

sheet.getRange("A1:D1").setValues([[1,2,3,4]]);

链接函数调用

作为最后的手段,如果您的函数确实无法在 6 分钟内完成,您可以将调用链接在一起或拆分您的函数以处理较小的数据段。

您可以将数据存储在 Cache Service(临时)或 Properties Service(永久)存储桶中,以便在执行过程中检索(因为 Google Apps 脚本具有无状态执行)。

如果您想开始另一个活动,您可以使用Trigger Builder Class 创建自己的触发器,或在紧迫的时间表上设置重复触发器。

【讨论】:

感谢 KyleMit,这是一个非常全面的答案! “也就是说,你真的需要花费 6 分钟来执行的原因很少。” 尝试编写一个脚本来处理例如Gmail、云端硬盘等中的内容... @Mehrdad,这些似乎有几个原因 :) 但是,是的,95% 以上的脚本不应该遇到这个障碍【参考方案7】:

如果您是企业客户,现在可以注册Early Access to App Maker,其中包括Flexible Quotas。

在灵活配额制度下,此类硬配额限制被取消。脚本在达到配额限制时不会停止。相反,它们会延迟到配额可用,此时脚本执行会恢复。一旦开始使用配额,它们就会以固定速率重新填充。为了合理使用,脚本延迟很少见。

【讨论】:

【参考方案8】:

如果您使用的是 G Suite 商务版或企业版。 您可以register early access for App Maker 启用应用程序制造商后,您的脚本运行运行时将增加运行时间从 6 分钟到 30 分钟 :)

更多关于应用程序制造商Click here的详细信息

【讨论】:

是的,我们可以使用抢先体验计划将运行时间从 6 分钟增加到 30 分钟,但这些应用程序无法公开部署。 应用制作工具产品将于 2021 年 1 月 19 日关闭support.google.com/a/answer/9682494?p=am_announcement 除了 App Maker 正在关闭之外,无需注册提前访问某些内容即可获得 30 分钟的限制。【参考方案9】:

如果您将 G Suite 用作 Business、Enterprise 或 EDU 客户,则运行脚本的执行时间设置为:

30 分钟/执行

见:https://developers.google.com/apps-script/guides/services/quotas

【讨论】:

链接中的限制是 6 分钟/执行,我错过了什么? @jason 直到大约一年前,商业、企业和 EDU 客户每次执行 30 分钟都是如此。此后,Google 已将其回滚到 6 分钟。 我上周使用循环和睡眠功能对其进行了测试,结果超过了 6 分钟。我现在真的很困惑。它做了 5 次循环 5 分钟的睡眠。 脚本运行时间 6 分钟/执行 6 分钟/执行【参考方案10】:

我们的想法是优雅地退出脚本,保存您的进度,创建一个触发器以从您离开的地方重新开始,根据需要重复多次,然后在完成后清理触发器和任何临时文件。

a detailed article 就这个主题。

【讨论】:

【参考方案11】:

这是一个非常基于Dmitry Kostyuk's absolutely excellent article 的方法。

不同之处在于它不会尝试计时执行并优雅地退出。相反,它故意每分钟生成一个新线程,并让它们运行直到它们被 Google 超时。这绕过了最大执行时间限制,并通过在多个线程中并行运行处理来加快处理速度。 (即使您没有达到执行时间限制,这也会加快速度。)

它在脚本属性中跟踪任务状态,加上一个信号量,以确保任何时候都没有两个线程正在编辑任务状态。 (它使用了几个属性,因为每个属性限制为 9k。)

我试图模仿 Google Apps 脚本 iterator.next() API,但不能使用 iterator.hasNext(),因为这不是线程安全的(请参阅 TOCTOU)。它在底部使用了几个外观类。

如果有任何建议,我将不胜感激。这对我来说效果很好,通过生成三个并行线程来运行文档目录将处理时间减半。您可以在配额内生成 20 个,但这对于我的用例来说已经足够了。

该类被设计为插入式,无需修改即可用于任何目的。用户唯一必须做的就是在处理文件时,删除之前超时尝试的任何输出。如果处理任务在完成之前被 Google 超时,迭代器将多次返回给定的fileId

要使日志静音,这一切都通过底部的log() 函数进行。

这是你使用它的方式:

const main = () => 
  const srcFolder = DriveApp.getFoldersByName('source folder',).next()
  const processingMessage = processDocuments(srcFolder, 'spawnConverter')
  log('main() finished with message', processingMessage)


const spawnConverter = e => 
  const processingMessage = processDocuments()
  log('spawnConverter() finished with message', processingMessage)


const processDocuments = (folder = null, spawnFunction = null) => 
  // folder and spawnFunction are only passed the first time we trigger this function,
  // threads spawned by triggers pass nothing.
  // 10,000 is the maximum number of milliseconds a file can take to process.
  const pfi = new ParallelFileIterator(10000, MimeType.GOOGLE_DOCS, folder, spawnFunction)
  let fileId = pfi.nextId()
  const doneDocs = []
  while (fileId) 
    const fileRelativePath = pfi.getFileRelativePath(fileId)
    const doc = DocumentApp.openById(fileId)
    const mc = MarkupConverter(doc)

    // This is my time-consuming task:
    const mdContent = mc.asMarkdown(doc)

    pfi.completed(fileId)
    doneDocs.push([...fileRelativePath, doc.getName() + '.md'].join('/'))
    fileId = pfi.nextId()
  
  return ('This thread did:\r' + doneDocs.join('\r'))

代码如下:

const ParallelFileIterator = (function() 
  /**
  * Scans a folder, depth first, and returns a file at a time of the given mimeType.
  * Uses ScriptProperties so that this class can be used to process files by many threads in parallel.
  * It is the responsibility of the caller to tidy up artifacts left behind by processing threads that were timed out before completion.
  * This class will repeatedly dispatch a file until .completed(fileId) is called.
  * It will wait maxDurationOneFileMs before re-dispatching a file.
  * Note that Google Apps kills scripts after 6 mins, or 30 mins if you're using a Workspace account, or 45 seconds for a simple trigger, and permits max 30  
  * scripts in parallel, 20 triggers per script, and 90 mins or 6hrs of total trigger runtime depending if you're using a Workspace account.
  * Ref: https://developers.google.com/apps-script/guides/services/quotas
  maxDurationOneFileMs, mimeType, parentFolder=null, spawnFunction=null
  * @param Number maxDurationOneFileMs A generous estimate of the longest a file can take to process.
  * @param string mimeType The mimeType of the files required.
  * @param Folder parentFolder The top folder containing all the files to process. Only passed in by the first thread. Later spawned threads pass null (the files have already been listed and stored in properties).
  * @param string spawnFunction The name of the function that will spawn new processing threads. Only passed in by the first thread. Later spawned threads pass null (a trigger can't create a trigger).
  */
  class ParallelFileIterator 
    constructor(
      maxDurationOneFileMs,
      mimeType,
      parentFolder = null,
      spawnFunction = null,
    ) 
      log(
        'Enter ParallelFileIterator constructor',
        maxDurationOneFileMs,
        mimeType,
        spawnFunction,
        parentFolder ? parentFolder.getName() : null,
      )

      // singleton
      if (ParallelFileIterator.instance) return ParallelFileIterator.instance

      if (parentFolder) 
        _cleanUp()
        const t0 = Now.asTimestamp()
        _getPropsLock(maxDurationOneFileMs)
        const t1 = Now.asTimestamp()
        const  fileIds, fileRelativePaths  = _catalogFiles(
          parentFolder,
          mimeType,
        )
        const t2 = Now.asTimestamp()
        _setQueues(fileIds, [])
        const t3 = Now.asTimestamp()
        this.fileRelativePaths = fileRelativePaths
        ScriptProps.setAsJson(_propsKeyFileRelativePaths, fileRelativePaths)
        const t4 = Now.asTimestamp()
        _releasePropsLock()
        const t5 = Now.asTimestamp()
        if (spawnFunction) 
          // only triggered on the first thread
          const trigger = Trigger.create(spawnFunction, 1)
          log(
            `Trigger once per minute: UniqueId: $trigger.getUniqueId(), EventType: $trigger.getEventType(), HandlerFunction: $trigger.getHandlerFunction(), TriggerSource: $trigger.getTriggerSource(), TriggerSourceId: $trigger.getTriggerSourceId().`,
          )
        
        log(
          `PFI instantiated for the first time, has found $
            fileIds.length
           documents to process. getPropsLock took $t1 -
            t0ms, _catalogFiles took $t2 - t1ms, setQueues took $t3 -
            t2ms, setAsJson took $t4 - t3ms, releasePropsLock took $t5 -
            t4ms, trigger creation took $Now.asTimestamp() - t5ms.`,
        )
       else 
        const t0 = Now.asTimestamp()
        // wait for first thread to set up Properties
        while (!ScriptProps.getJson(_propsKeyFileRelativePaths)) 
          Utilities.sleep(250)
        
        this.fileRelativePaths = ScriptProps.getJson(_propsKeyFileRelativePaths)
        const t1 = Now.asTimestamp()
        log(
          `PFI instantiated again to run in parallel. getJson(paths) took $t1 -
            t0ms`,
        )
        spawnFunction
      

      _internals.set(this,  maxDurationOneFileMs: maxDurationOneFileMs )
      // to get: _internal(this, 'maxDurationOneFileMs')

      ParallelFileIterator.instance = this
      return ParallelFileIterator.instance
    

    nextId() 
      // returns false if there are no more documents

      const maxDurationOneFileMs = _internals.get(this).maxDurationOneFileMs
      _getPropsLock(maxDurationOneFileMs)
      let  pending, dispatched  = _getQueues()
      log(
        `PFI.nextId: $pending.length files pending, $
          dispatched.length
         dispatched, $Object.keys(this.fileRelativePaths).length -
          pending.length -
          dispatched.length completed.`,
      )
      if (pending.length) 
        // get first pending Id, (ie, deepest first)
        const nextId = pending.shift()
        dispatched.push([nextId, Now.asTimestamp()])
        _setQueues(pending, dispatched)
        _releasePropsLock()
        return nextId
       else if (dispatched.length) 
        log(`PFI.nextId: Get first dispatched Id, (ie, oldest first)`)
        let startTime = dispatched[0][1]
        let timeToTimeout = startTime + maxDurationOneFileMs - Now.asTimestamp()
        while (dispatched.length && timeToTimeout > 0) 
          log(
            `PFI.nextId: None are pending, and the oldest dispatched one hasn't yet timed out, so wait $timeToTimeoutms to see if it will`,
          )
          _releasePropsLock()
          Utilities.sleep(timeToTimeout + 500)
          _getPropsLock(maxDurationOneFileMs)
          ;( pending, dispatched  = _getQueues())
          if (pending && dispatched) 
            if (dispatched.length) 
              startTime = dispatched[0][1]
              timeToTimeout =
                startTime + maxDurationOneFileMs - Now.asTimestamp()
            
          
        
        // We currently still have the PropsLock
        if (dispatched.length) 
          const nextId = dispatched.shift()[0]
          log(
            `PFI.nextId: Document id $nextId has timed out; reset start time, move to back of queue, and re-dispatch`,
          )
          dispatched.push([nextId, Now.asTimestamp()])
          _setQueues(pending, dispatched)
          _releasePropsLock()
          return nextId
        
      
      log(`PFI.nextId: Both queues empty, all done!`)
      ;( pending, dispatched  = _getQueues())
      if (pending.length || dispatched.length) 
        log(
          "ERROR: All documents should be completed, but they're not. Giving up.",
          pending,
          dispatched,
        )
      
      _cleanUp()
      return false
    

    completed(fileId) 
      _getPropsLock(_internals.get(this).maxDurationOneFileMs)
      const  pending, dispatched  = _getQueues()
      const newDispatched = dispatched.filter(el => el[0] !== fileId)
      if (dispatched.length !== newDispatched.length + 1) 
        log(
          'ERROR: A document was completed, but not found in the dispatched list.',
          fileId,
          pending,
          dispatched,
        )
      
      if (pending.length || newDispatched.length) 
        _setQueues(pending, newDispatched)
        _releasePropsLock()
       else 
        log(`PFI.completed: Both queues empty, all done!`)
        _cleanUp()
      
    

    getFileRelativePath(fileId) 
      return this.fileRelativePaths[fileId]
    
  

  // ============= PRIVATE MEMBERS ============= //

  const _propsKeyLock = 'PropertiesLock'
  const _propsKeyDispatched = 'Dispatched'
  const _propsKeyPending = 'Pending'
  const _propsKeyFileRelativePaths = 'FileRelativePaths'

  // Not really necessary for a singleton, but in case code is changed later
  var _internals = new WeakMap()

  const _cleanUp = (exceptProp = null) => 
    log('Enter _cleanUp', exceptProp)
    Trigger.deleteAll()
    if (exceptProp) 
      ScriptProps.deleteAllExcept(exceptProp)
     else 
      ScriptProps.deleteAll()
    
  

  const _catalogFiles = (folder, mimeType, relativePath = []) => 
    // returns IDs of all matching files in folder, depth first
    log(
      'Enter _catalogFiles',
      folder.getName(),
      mimeType,
      relativePath.join('/'),
    )
    let fileIds = []
    let fileRelativePaths = 
    const folders = folder.getFolders()
    let subFolder
    while (folders.hasNext()) 
      subFolder = folders.next()
      const results = _catalogFiles(subFolder, mimeType, [
        ...relativePath,
        subFolder.getName(),
      ])
      fileIds = fileIds.concat(results.fileIds)
      fileRelativePaths =  ...fileRelativePaths, ...results.fileRelativePaths 
    
    const files = folder.getFilesByType(mimeType)
    while (files.hasNext()) 
      const fileId = files.next().getId()
      fileIds.push(fileId)
      fileRelativePaths[fileId] = relativePath
    
    return  fileIds: fileIds, fileRelativePaths: fileRelativePaths 
  

  const _getQueues = () => 
    const pending = ScriptProps.getJson(_propsKeyPending)
    const dispatched = ScriptProps.getJson(_propsKeyDispatched)
    log('Exit _getQueues', pending, dispatched)
    // Note: Empty lists in Javascript are truthy, but if Properties have been deleted by another thread they'll be null here, which are falsey
    return  pending: pending || [], dispatched: dispatched || [] 
  
  const _setQueues = (pending, dispatched) => 
    log('Enter _setQueues', pending, dispatched)
    ScriptProps.setAsJson(_propsKeyPending, pending)
    ScriptProps.setAsJson(_propsKeyDispatched, dispatched)
  

  const _getPropsLock = maxDurationOneFileMs => 
    // will block until lock available or lock times out (because a script may be killed while holding a lock)
    const t0 = Now.asTimestamp()
    while (
      ScriptProps.getNum(_propsKeyLock) + maxDurationOneFileMs >
      Now.asTimestamp()
    ) 
      Utilities.sleep(2000)
    
    ScriptProps.set(_propsKeyLock, Now.asTimestamp())
    log(`Exit _getPropsLock: took $Now.asTimestamp() - t0ms`)
  
  const _releasePropsLock = () => 
    ScriptProps.delete(_propsKeyLock)
    log('Exit _releasePropsLock')
  

  return ParallelFileIterator
)()

const log = (...args) => 
  // easier to turn off, json harder to read but easier to hack with
  console.log(args.map(arg => JSON.stringify(arg)).join(';'))


class Trigger 
  // Script triggering facade

  static create(functionName, everyMinutes) 
    return ScriptApp.newTrigger(functionName)
      .timeBased()
      .everyMinutes(everyMinutes)
      .create()
  
  static delete(e) 
    if (typeof e !== 'object') return log(`$e is not an event object`)
    if (!e.triggerUid)
      return log(`$JSON.stringify(e) doesn't have a triggerUid`)
    ScriptApp.getProjectTriggers().forEach(trigger => 
      if (trigger.getUniqueId() === e.triggerUid) 
        log('deleting trigger', e.triggerUid)
        return ScriptApp.delete(trigger)
      
    )
  
  static deleteAll() 
    // Deletes all triggers in the current project.
    var triggers = ScriptApp.getProjectTriggers()
    for (var i = 0; i < triggers.length; i++) 
      ScriptApp.deleteTrigger(triggers[i])
    
  


class ScriptProps 
  // properties facade
  static set(key, value) 
    if (value === null || value === undefined) 
      ScriptProps.delete(key)
     else 
      PropertiesService.getScriptProperties().setProperty(key, value)
    
  
  static getStr(key) 
    return PropertiesService.getScriptProperties().getProperty(key)
  
  static getNum(key) 
    // missing key returns Number(null), ie, 0
    return Number(ScriptProps.getStr(key))
  
  static setAsJson(key, value) 
    return ScriptProps.set(key, JSON.stringify(value))
  
  static getJson(key) 
    return JSON.parse(ScriptProps.getStr(key))
  
  static delete(key) 
    PropertiesService.getScriptProperties().deleteProperty(key)
  
  static deleteAll() 
    PropertiesService.getScriptProperties().deleteAllProperties()
  
  static deleteAllExcept(key) 
    PropertiesService.getScriptProperties()
      .getKeys()
      .forEach(curKey => 
        if (curKey !== key) ScriptProps.delete(key)
      )
  

【讨论】:

以上是关于谷歌应用脚​​本超时 ~ 5 分钟?的主要内容,如果未能解决你的问题,请参考以下文章

丢失谷歌应用脚​​本项目

谷歌应用脚​​本没有循环

谷歌应用脚​​本:您无权调用提示

谷歌应用脚​​本:是不是可以在发送共享通知的同时限制文件的共享设置?

如何使用谷歌应用脚​​本发送电子邮件草稿

谷歌应用脚​​本抛出错误