执行语义、约束与安全

本文梳理 Script 在运行时如何工作(顺序、事务、错误、锁),保存时受到哪些静态约束,以及安全模型。语法参见 Statement 目录与值表达式,实战组合参见 Cookbook。

执行顺序

  • statements 从上到下顺序执行。到达 Return 时在该处终止。
  • 执行是在处理调用请求的路径上内联进行的。 调用的响应就是执行结果(响应形态参见 Script 概述的请求与响应),一次执行可用的时间在下面的时间预算中讲解。

执行语义

Guard(前置条件)

没有专用的 guard Statement。用 If 和 then:[Return] 表达。条件违反时返回结果,之后的 Statement 不再执行(当然也可以有不带 guard 的 Script)。

{ "type": "If", "condition": { "<": [ "{ /wallet/fields/balance/en-US }", "{ /payload/fields/cost }" ] },
  "then": [ { "type": "Return", "value": { "ok": false, "reason": "insufficient credit" }, "statusCode": 402 } ] }

无事务与 best-effort 补偿

Script 不是事务。 失败时引擎会尝试对目前已完成的操作进行补偿(compensation)并返回错误原因,但存在以下限制(设计上接受的取舍)。

  • 撤销删除会创建新的 sys.id,因此原本指向它的引用会失效。
  • 外部副作用(Http)不可逆(已发出的调用与计费无法撤销)。
  • 补偿完全不执行,因而可能残留未补偿状态。

如果确实需要原子性,请由用户用 Script 亲自补偿,或者把不可逆的操作(外部调用等)放到最后。“能连锁执行却无法回滚、看上去却很安全”的顺序最危险。

乐观锁

update/patch 竞争通过 ResourceUpdate 和 ResourcePatch 的 version 来收窄。给出 version(值表达式,Int)后,只有当它与目标当前的 sys.version 一致时才更新,不一致则以版本冲突错误 abort(可用 Try/catch 局部处理)。省略时不做检查,采用 last-write-wins。通常先用 ResourceRead 或 ResourceFind 读取,再传入其 sys.version(参见 Cookbook 中的乐观锁 CAS)。

基于 origin 的写入

写入始终反映到 origin(draft),delivery(CDA/ACDA)的暴露由 publish 控制(ResourceCreate/ResourceUpdate/ResourcePatch 的 publish,或 ResourcePublish/ResourceUnpublish)。

什么算失败

执行过程中如果有一个 Statement 失败,只要没有处理该失败的 Try,执行就会在该处中断。此时引擎会尝试撤销到那时为止已执行的操作。撤销存在限制,详细内容在无事务与 best-effort 补偿中讲解。

失败的 Statement 如果位于 Try 内,就可以在 catch 中处理该失败。在 catch 内通过 /error 引用失败内容({ message })。其中不包含是在哪个 Statement 失败的信息。

以下情况会被视为失败。

Return 不是失败。它是正常地提前结束执行,因此不会转到 catch。

Http 失败的两种方式

Http 的失败按是否作为 status 响应处理分为两种。这一区别决定了 retry 与 ignoreStatusCode 是否适用。

区分作为 status 响应处理的情况立即失败的情况
适用情况目标 API 响应 400 以上 status 的情况、超时的情况(status 504)、未能连接目标 API 或未能读取响应的情况(status 502)、响应正文超过 10MiB 的情况(status 502)responseType 为 "Json" 但成功响应的正文不是 JSON 的情况、要发送的文件超出下载限额的情况、文件 URL 响应非 2xx 的 status 或无法连接文件 URL 的情况、part 值的格式错误的情况
retry按指定的次数重试不重试
ignoreStatusCode: true不视为失败,而是把 { status, body } 绑定为结果不适用,按失败处理
Try/catch可以在 catch 中处理可以在 catch 中处理

文件发送过程中失败的条件,在将文件作为正文发送中详细讲解。

服务端聚合仅限条数

条数由 ResourceCount 在服务端统计。它不会读取项目,因此不受处理项目数上限的限制。

sum 与 group-by 没有专用的服务端操作。这类聚合需要通过 ResourceForEach 遍历,并用 SetVar 与 JsonLogic 亲自计算,因此受限于处理项目数上限(不适合数百万条的聚合)。只需要条数时,不要遍历,请使用 ResourceCount。

无等待与延迟

Script 中没有 Delay Statement。Script 只执行一次就结束,不会在内部等待或轮询直到外部 job 完成。

静态约束(保存时校验)

以下会在保存(创建/修改)Script 的时刻检查。违反时保存会被拒绝(不是运行时,而是在编写时失败)。哪种违反会以哪个错误码被拒绝,在错误中讲解。

约束值
每个定义最多外部调用(Http、EmailSend)按套餐(参见定价方案)
ResourceForEach 处理项目总数最多(未声明 limit 时遍历到该值为止,仍有剩余匹配时触及则失败)10,000
每个定义最多 SetVar(含嵌套)10
每个定义最多 Cache(含嵌套,与操作种类无关合并计数)5。超过则拒绝保存
Loop、ResourceForEach 块内的 Cache拒绝保存
Cache.key仅字面量,最长 128 个字符。写成值表达式则拒绝保存
Cache.ttl1 到 30 秒之间,省略时为 5 秒。超出该范围则拒绝保存
每个定义全部 Statement 最多(含嵌套)按套餐(参见定价方案)
Http.retry 上限2
一个 Http 的请求正文字段(body、urlBody、multipartBody)三者只能写一个。写两个以上则拒绝保存
Http.multipartBody 的 part 数1 个以上。为空则拒绝保存
multipartBody part 的 value、url恰好一个。两者都写或两者都不写则拒绝保存
multipartBody part 的 fileName仅限写了 url 的 part。没有 url 却写了则拒绝保存
Regex.pattern 长度128 个字符
每个定义最多 secrets20
secrets 的 key1~64 个字符,仅限英文字母、数字、_、-。有两个相同的 key 则拒绝保存
secrets 的 value1~4,096 个字符。超出该范围则拒绝保存
{ /secrets… } 引用恰好为 /secrets/<已声明的 key>。否则拒绝保存
创建或删除 ServiceUser 的 Statement拒绝保存。只有三个读取 Statement 与 ResourceUpdate、ResourcePatch 接受该资源
anonymousCallEnabled 为 true 时 where 中的 createdBy: ":self"拒绝保存
Media 摄取指令的 transformations(文件转换)仅限数组。元素的 type 为 PdfWatermark,color 为 #RRGGBB。text 所写的整个字符串(包括 { /pointer } 的路径)必须是由可用字符组成的一行。违反则拒绝保存
PdfWatermark 的字面量 text 长度1~64 个字符。超出该范围则拒绝保存。含有 { /pointer } 的 text 在保存时不检查,而是在执行时按解析后的值检查

上表中的固定上限由平台设定,与套餐无关,都是同一个值。而每个定义的全部 Statement 数与外部调用数则是按套餐的上限。这两者不是有效性错误,而是套餐上限,因此超出时保存、修改会以超出套餐上限被拒绝(同一定义在更高套餐上可被允许),升级即可解除。各套餐的具体数值见定价方案。

Media 文件摄取与 Http、EmailSend 这样的外部调用不同,不计入每个定义的外部调用上限。

ResourceForEach 是拥有子项的复合 Statement,因此它自身不计入外部调用数。 被计入的是 onEach 内的外部调用 Statement(Http、EmailSend)(静态上按 1 计数,但会随遍历对每一项实际执行)。onEach 中可以放入外部调用或 Media 文件摄取,Loop 的 body 也是如此。迭代实际循环多少次不会进入这个计数,而是在下面的时间预算中以乘法计入。

值长度上限(运行时)

签名与文本处理 Statement,以及 Cache,对所处理的值的大小设有上限。上限针对的是表达式解析后的值的长度,而不是表达式本身的长度({ /rawPayload } 这十六个字符会指向几十 KB),因此它不是在保存时检查,而是在运行中检查。

对象上限超出时
Signature 的 value65,536 个字符该 Statement 失败(status 422)
Hash 的 value128 个字符该 Statement 失败(status 422)
Regex 的 value10,240 个字符(10KiB)该 Statement 失败(status 400)
Cache 的 value10,240 字节(10KiB)该 Statement 失败(status 422)
  • 四者都与其他运行时失败相同,可以用 Try/catch 局部处理。
  • Signature 的上限是按实际服务商发送的 body 大小来定的(支付事件为数 KB,订单 Webhook 可达数十 KB)。Hash 是放几个拼接字段的位置,因此窄得多。
  • Regex.pattern 的 128 个字符是上面静态约束中的保存时检查。这个长度并不是阻止失控匹配的装置((a+)+$ 只用六个字符就很危险)。阻止失控匹配的是只允许把 pattern 写成字面量的规则与下面的时间预算,而长度所承诺的只是一个人可以阅读和审查的规模。

时间预算(运行时)

一次执行允许的时间最长为 180 秒。实际允许的时间由 Script 定义(发送文件时还包括套餐)决定,等于基础 30 秒加上外部调用可使用的时间。

这个预算适用于整个执行。此外,每一次 Http 请求还必须在该请求的 timeoutMs 内完成。

基础 30 秒

读取和写入资源、获取并处理 Media 文件等不属于外部调用的操作,全部在这 30 秒内处理。不含外部调用的 Script 允许 30 秒。

Statement 各自增加的时间

顺序排列的 Statement 的时间全部相加。

Statement增加的时间
HttptimeoutMs(未指定时为 30 秒)。发送文件时按每个文件加上下载时间(见下表),指定了 retry 时再对整体乘以尝试次数(1 + retry)
EmailSendtimeoutMs(未指定时为 10 秒)。不重试,因此只加一次
Ifthen 与 else 中较长一方的时间
Parallelbranches 中最长一个的时间
Trybody、catch、finally 的时间之和
Loopbody 的时间乘以 maxIterations(未指定时为 10,000)的值
ResourceForEachonEach 的时间乘以 limit(未指定时为 10,000)的值
其他 Statement(不包含其他 Statement 的)不增加。在基础 30 秒内处理

迭代内没有外部调用时,即使乘以迭代次数,增加的时间也是 0。因此这类迭代必须在基础 30 秒内完成。

每个文件的下载时间

Plan下载时间
Free约 5.2 秒
Basic约 10.5 秒
Pro约 10.5 秒
Enterprise约 107.4 秒

这个值是以每秒 10,000,000 字节的速度接收相当于套餐下载限额的数据所需的时间。

计算示例

在 Pro 套餐下,调用一个发送两张照片的 Http(timeoutMs 30 秒,retry 1)的 Script,按如下方式计算。

  • Http:(30 秒 + 10.5 秒 × 2) × 2 = 约 102 秒
  • 整个执行:30 秒 + 102 秒 = 约 132 秒

132 秒短于 180 秒,因此该 Script 允许 132 秒。

超过 180 秒的情况

即使计算出的值超过 180 秒,Script 依然会被保存并执行。不过执行到达 180 秒时,会在那一刻中断。

各套餐的数量上限

Script 的每个 Organization 的数量按套餐限制。

PlanScript 数量
Free10
Basic30
Pro100
Enterprise无限制

除此之外,一个 Script 定义所能容纳的 Statement 数与外部调用(Http、EmailSend)数也按套餐限制。保存、修改定义时若超出该套餐的上限则被拒绝,具体数值请参见定价方案。

达到上限后,新建 Script 会被拒绝。

Http 文件下载大小

Http 从 URL 获取要作为请求正文发送的文件时(urlBody、multipartBody 的 url part),每个文件 URL 可获取的大小按套餐限制。遵循拥有该 Script 的 Space 所属 Organization 的套餐。

Plan每个文件 URL 的最大大小
Free50MiB
Basic100MiB
Pro100MiB
Enterprise1GiB
  • 限额按每个 URL 分别适用。有多个 url part 时,每个文件都必须在该限额以内。
  • 尝试发送超出限额的文件时,该 Http 会失败(参见 Http 失败的两种方式)。
  • 这个限额也是时间预算中所加下载时间的基准。

安全模型

secrets

外部 API 密钥、支付服务商(PG)的密钥(Secret Key)、Webhook 签名密钥、预先计算好的 Basic … 认证值这类机密值,不直接把值写进 Statement,而是在定义的 secrets 中声明,并在 Statement 中以 { /secrets/<key> } 读取。公开的测试密钥也放在同一位置。这样换成真实密钥时,Statement 保持不变,只需修改 secrets 中的值即可。

"secrets": [
  { "key": "LLM_API_KEY",            "value": "sk-...",    "description": "用于调用 LLM 的 API 密钥" },
  { "key": "WEBHOOK_SIGNING_SECRET", "value": "whsec_...", "description": "" }
],
"statements": [
  { "type": "Http", "method": "POST", "url": "https://api.openai.com/v1/chat/completions",
    "headers": [ { "key": "Authorization", "value": "Bearer { /secrets/LLM_API_KEY }" } ],
    "body": { /* ... */ }, "name": "llm" }
]
字段必填规则
key必填1~64 个字符,只能使用英文字母、数字、_、-。在一个 Script 内必须唯一。
value必填1~4,096 个字符的字符串。它是静态值,不会作为模板解析,因此即使在其中放入 { /… },也会按字面原样使用。
description必填用途说明,0~128 个字符。也可以是空字符串 ""。
  • 保存:value 由 WEEGLOO 加密后安全保存。Statement 通过 { /secrets/<key> } 读取原始值。
  • 查询:在返回 Script 的 API 响应(查询、创建、修改)中,value 以原始值返回。能够读取该 Script 的角色都能看到这个值,因此请把读取权限收窄。会员(ServiceUser)无法读取 Script 定义(查询与编写仅限 CMA,而 ACMA 中没有 Script API)。
  • 修改:整体修改(PUT)会替换整个定义。如果不带 secrets 发送,已声明的机密值会被删除;若 Statement 仍在以 { /secrets/… } 引用该值,则保存会被拒绝。因此每次修改时都要一并发送 secrets。把查询响应中的值原样发回即可。
  • 执行中的值:读取到的值用来做什么,由 Statement 决定。用 Return 返回读取到的值,它就会留在执行响应与 ScriptLog 中;放进 SetVar、Cache、Content,它就会以原始值留在那里。机密值只发送给需要该值的外部 API。
  • App Bundle:Script 被打包为 App Bundle 时,secrets 的 value 会被遮盖为 ******,key 与 description 则原样带上。安装该 App Bundle 而生成的 Script 中,机密值处于 ****** 状态,因此安装方需要填入真实值,读取该值的 Statement 才能正常工作。写在 secrets 以外位置(Http 的请求头、Signature 的 secret 等)的值不会被遮盖,而是原样带上。

一个 Script 最多可以有 20 个 secrets。指针规则在值表达式的 /secrets中讲解。

执行身份与授权

  • 执行身份:执行期间所有资源操作都以调用 /execute 的用户身份执行。被创建或修改的资源的 createdBy/updatedBy 即为调用方,createdBy: ":self" 作用域也以调用方为基准解析。匿名调用是例外。 通过 /execute/anonymous 进入的执行没有调用方,因此两者都以作者为基准解析(匿名调用)。
  • 授权边界有两个,而且运行时不会对每个 Statement 重新检查资源权限。
    1. 编写时(保存):保存 Script 时,会检查作者是否确实拥有这些 Statement 所使用的资源与动作权限。只要缺少任意一个,保存就会被拒绝。也就是说,包含无权限操作的 Script 从一开始就无法保存。凡是选择资源的 Statement,无论是 leaf 还是拥有块的 ResourceForEach,都要接受这项检查。已经保存过的定义在修改时会再次检查,因此权限被收回之后就无法再保存对该定义的修改。
      • 会员目录(ServiceUser)不是通过权限映射检查,而是通过设置这条轴。 要在三个读取 Statement 以及 ResourceUpdate、ResourcePatch 中写 resource: "ServiceUser",作者的 SpaceRole settings 中必须含有 SETTING_SERVICE_LOGIN(或 SETTING_ALL),参见 SpaceRole 的 settings。因为在系统的其他所有路径上,会员目录也都是由 Space 设置管辖的资源。
      • 创建或删除会员的 Statement 在任何角色下都无法保存。 Script 无法创建或删除会员,所以它不是权限不足(403),而是作为写错的 Statement 被拒绝(400)。也就是说,这不是靠加权限就能补上的空缺。被允许的改动只有 ResourceUpdate、ResourcePatch 的 roleOverride 与 enableLogin(参见处理会员目录)。
    2. 调用时(/execute):只检查调用方的 Script Execute 权限。没有权限则返回 403。通过后,各 Statement 的资源权限在运行时不再确认即执行。这与编程中的函数执行权限方式相同。只要有执行函数的权限,其内部各个操作的权限就不会再被询问。匿名调用路径上没有这项检查。 因为没有可检查的调用方,所以开放那条路径等同于把一个 Script 无认证地公开出去。
  • 禁止直接调用(directCallEnabled):Script 的 directCallEnabled 为 false 时,/execute 直接调用本身会被拒绝。该门禁是在通过 Execute 权限检查之后才生效的,因此即使拥有 Execute 权限也会被拦截。没有 Execute 权限的调用方在到达该门禁之前就会收到 403。这个门禁只存在于该端点上,因此 Webhook 的关联动作(script)与 Scheduler 照常执行。默认值为 true(允许直接调用)。
  • 匿名调用(anonymousCallEnabled):默认值为 false。设为 true 后,仅该 Script 也可以通过无需认证的专用路径(/execute/anonymous)执行,而那时执行身份是作者,而不是调用方。上述两个边界中的调用时检查(Execute 权限)在那条路径上并不存在,因此实质上的认证由 Script 自己完成(校验所收到请求的签名)。开启的条件与保存规则参见匿名调用。
  • 所有权作用域:where 过滤器中的 createdBy: ":self" 表示“仅限当前调用方创建的对象”(例如只查询我自己的钱包)。在允许匿名调用的 Script 中不能使用这个过滤器。因为没有调用方时它会解析为作者,所以作为所有权作用域的原本含义就不成立了。
  • 委托的权限(编写注意):把上述两个边界合起来看,Script 的执行等同于受委托作者的权限来执行。调用方只需拥有 Execute 一项即可,Script 内的 Statement 会在作者保存时被授权的范围内照常执行。因此,调用方自己无法完成的资源操作也可能通过 Script 发生。授予作者的权限即是该 Script 的影响范围,所以要谨慎设定 Script 中包含的动作。

摘要检查清单

保存前请确认以下几点。

  • 若开启了匿名调用(anonymousCallEnabled),where 中没有 createdBy: ":self",并且把校验所收请求的 Statement(如 Signature)放在了最前面。
  • 外部调用(Http、EmailSend)数与全部 Statement 数在套餐上限以内,SetVar 不超过 10 个,Cache 不超过 5 个。
  • 若使用了 Cache,key 写成了字面量,并且没有放在 Loop 或 ResourceForEach 内。
  • 用 ResourceForEach 遍历大集合时,已声明 limit 或确认过是可跑完的规模。
  • 若含有迭代(Loop、ResourceForEach),已确认该迭代会以乘法计入时间预算(没有外部调用时,30 秒基础预算就是上限)。
  • 外部 API 密钥或签名密钥这类机密值已在 secrets 中声明,并以 { /secrets/<key> } 读取。没有把值直接写进 Statement 的机密值。
  • 若以整体修改(PUT)保存,已一并发送 secrets。
  • 用于签名校验的消息取自 { /rawPayload },而不是 /payload。
  • 若有处理会员(ServiceUser)的 Statement,作者拥有 SETTING_SERVICE_LOGIN,并且没有放入创建或删除该资源的 Statement。
  • 尽量把不可逆的操作(外部调用)放到后面。
  • 若用 Http 发送文件,请求正文字段(body、urlBody、multipartBody)只写了一个,并已确认每个文件都在各套餐的下载限额以内。
  • 如果担心 update/patch 竞争,就使用 ResourceUpdate 或 ResourcePatch 的 version。
  • 若要返回结果,已明确指定 Return.value。

错误

以下是定义的形态违反静态约束、保存因此被拒绝时出现的错误码。违反值表达式规则的错误码在值表达式的错误中,调用、删除时出现的错误码在端点的错误中。所有资源共通的错误码,请参见通用错误。

错误码条件
WGL400066一个定义中包含的 Cache Statement 超过 5 个。
WGL400068Cache Statement 被放在了 Loop 或 ResourceForEach 的块内。
WGL400067Cache Statement 的 key 中写的不是字面量,而是 { /pointer } 引用。
WGL400065Cache Statement 的 ttl 超出了允许范围。
WGL400063Cache Statement 中写入了与 action 不对应的字段(在 Get 中写 ttl,在 Set 中写 defaultValue)。
WGL400060ResourceCreate、ResourceDelete 以及发布、归档 Statement 的 resource 中写入了 "ServiceUser"。会员目录只接受三个查询 Statement 与 ResourceUpdate、ResourcePatch。
WGL400077ResourceCount 的 resource 中写入了无法统计的资源。Media 与会员目录(ServiceUser)无法统计。
WGL400037EmailSend 中同时写了 to 与 toServiceUser,或者两者都留空了。只能写其中恰好一个。
WGL400054EmailSend 的收件人合计(to 1 人 + cc + bcc)超过 50 人。
WGL400039EmailSend 的 timeoutMs 超过上限。
WGL400101EmailSend 中同时写了 body 与 templateBody,或者两者都留空了。只能写其中恰好一个。
WGL400102EmailSend 的 templateBody.source 不是指向本 Script 的 ResourceRead、ResourceFind 所读取的 Content 的单个指针。指针前后有其他字符时也会被拒绝。
WGL400061在允许匿名调用的 Script(anonymousCallEnabled)中,读取 Statement 的 where 中写入了 createdBy: ":self"。
WGL400023一个定义中包含的 SetVar Statement 超过 10 个。
WGL400026Http Statement 的 retry 超过了上限 2。
WGL400087Http 中写入了两个以上的请求正文字段(body、urlBody、multipartBody)。
WGL400088Http 的 multipartBody 中一个 part 也没有。
WGL400089multipartBody 的 part 中 value 与 url 两者都写了,或两者都没写。
WGL400090multipartBody 的 part 中没有 url 却写了 fileName。
WGL400036ResourceForEach 超过了可处理的项目数上限。
WGL400094secrets 中有两个以上使用相同 key 的项目。
WGL400095一个定义中包含的 secrets 超过 20 个。
WGL400086secrets 的 value 超出了 1~4,096 个字符的范围。错误中不会带上 value。
WGL400086不含 { /pointer } 的 PdfWatermark 的 text 超出了 1~64 个字符的范围。
WGL400096Media 摄取指令的 transformations 不是数组,或元素的 type 不是 PdfWatermark,或 PdfWatermark 中没有 text 或其值为对象、数组,或 color 不是 #RRGGBB 格式,或按原样所写的 text 中含有不可用的字符或换行符。
WGL429005一个定义中包含的 Statement 总数超过了套餐上限。
WGL429006一个定义中包含的外部调用(Http、EmailSend)数超过了套餐上限。
WGL403015作者不具备定义中的 Statement 所处理的资源与动作的权限。即使拥有权限,只要那条允许规则上附带了 contentType、createdBy、tag 过滤条件,保存也会被拒绝。 必须是不带条件的允许规则。唯一的例外是 Content Create,此时限定了 contentType 范围的允许规则也被认可,并与 Statement 中所写的 contentType 进行比对(Media Create 没有这个例外)。读取 Statement 的 resource 中写入了 "ServiceUser",而作者没有 SETTING_SERVICE_LOGIN,这种情况也属于该错误码。