执行语义、约束与安全

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

执行顺序

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

执行语义

Guard(前置条件)

没有专用的 guard 语句。用 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 运行时错误:Http 的最终 status 大于等于 400(4xx·5xx;ignoreStatusCode: true 时不算失败)或超时、响应 body 超过 10MiB、资源操作失败(对象不存在、版本冲突、不支持的运算等)。这类失败会由引擎 abort 并补偿,可用 Try/catch/finally 局部处理。
  • Return 不是错误,而是正常的提前终止。它不是 catch 的对象(没有用户 throw 的概念)。
  • 在 catch 内通过 /error 引用 { message }。其中不包含是在哪个语句失败的信息。

服务端聚合仅限条数

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

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

无等待与延迟

Script 中没有 Delay 语句。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
Regex.pattern 长度128 个字符
修改 ServiceUser 的语句拒绝保存。只有三个读取语句接受该资源
anonymousCallEnabled 为 true 时 where 中的 createdBy: ":self"拒绝保存

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

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

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

值长度上限(运行时)

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

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

时间预算(运行时)

一次执行可用的时间由一个式子决定:min(30 秒 + 各语句所声明时间之和, 180 秒)。

  • 预算是按该 Script 计算出来的。 在基础预算之上,只加上定义所声明的时间。被声明的时间只有 Http 与 EmailSend 的 timeoutMs 这一项。Http 每次重试都会重新使用自己的 timeoutMs,因此按 timeoutMs × (1 + retry) 计数;EmailSend 不重试,因此按一次计数。未写 timeoutMs 时,按默认值(Http 30 秒,EmailSend 10 秒)计数。
  • 没有声明时间的操作从 30 秒基础预算中支出。 资源的读取与写入、Media 文件摄取、迭代在其内部所做的事都属于这一类。因此基础预算不是走形式的数值,而是实际的份额。
  • 累加方式遵循语句的结构。 顺序排列的语句相加,If 取两个分支中较大的一方,Parallel 取各分支中最大的一个。Loop 会给 body 乘上循环次数(maxIterations,未声明时为 10,000),ResourceForEach 会给 onEach 乘上处理项目数(limit,未声明时为 10,000)。
  • 不含外部调用的迭代,其声明时间为 0。 因此 30 秒基础预算就成了实际上限,含有迭代的 Script 实际被卡住的地方也正是这里。
  • 180 秒的上限不会阻止保存,而是截断。 即使计算结果超过上限,该 Script 依然会被保存并执行,到达 180 秒时就在那里中断。

各套餐的数量上限

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

PlanScript 数量
Free10
Basic30
Pro100
Enterprise无限制

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

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

安全模型

secret 请求头

Http.headers 中 secret:true 的项目仅限 CMA(管理员),不会暴露给最终用户(ServiceUser),且仅在发送前才解密。像 LLM API 密钥这样的机密放在这里(即使被打包为 App Bundle,secret 值也会被掩码,不会离开原始 Space)。

Signature 的 secret 在 Space 内不享受这种待遇。 它不会被加密,而是按写入定义的样子保存,因此能够读取该 Script 的角色都能看到这个值。会员(ServiceUser)无法读取 Script 定义(查询与编写仅限 CMA,而 ACMA 中没有 Script API)。对于放置校验用密钥的 Script,把可以读取它的角色范围收窄更安全。

离开 Space 时则不同。该 Script 被打包为 App Bundle 时,Signature 的 secret 会被掩码,不会带到原始 Space 外部。Http.headers 掩码的是带 secret 标记的项目与 Authorization 头,而 Signature 的 secret 因为字段本身就是签名密钥,所以无条件被掩码。嵌套在 If·Loop·Try 内的 Signature 也会一并被掩码。

执行身份与授权

  • 执行身份:执行期间所有资源操作都以调用 /execute 的用户身份执行。被创建或修改的资源的 createdBy/updatedBy 即为调用方,createdBy: ":self" 作用域也以调用方为基准解析。匿名调用是例外。 通过 /execute/anonymous 进入的执行没有调用方,因此两者都以作者为基准解析(匿名调用)。
  • 授权边界有两个,而且运行时不会对每个 statement 重新检查资源权限。
    1. 编写时(保存):保存 Script 时,会检查作者是否确实拥有这些 statement 所写入的资源与动作权限。只要缺少任意一个,保存就会被拒绝。也就是说,包含无权限操作的 Script 从一开始就无法保存。凡是选择资源的语句,无论是 leaf 还是拥有块的 ResourceForEach,都要接受这项检查。已经保存过的定义在修改时会再次检查,因此权限被收回之后就无法再保存对该定义的修改。
      • 会员目录(ServiceUser)不是通过权限映射检查,而是通过设置这条轴。 要在三个读取语句中写 resource: "ServiceUser",作者的 SpaceRole settings 中必须含有 SETTING_SERVICE_LOGIN(或 SETTING_ALL),参见 SpaceRole 的 settings。因为在系统的其他所有路径上,会员目录也都是由 Space 设置管辖的资源。
      • 修改会员的语句在任何角色下都无法保存。 Script 中根本没有创建、修改、删除会员的途径,所以它不是权限不足(403),而是作为写错的语句被拒绝(400)。也就是说,这不是靠加权限就能补上的空缺。
    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",并且把校验所收请求的语句(如 Signature)放在了最前面。
  • 外部调用(Http·EmailSend)数与全部 statement 数在套餐上限以内,SetVar 不超过 10 个,Cache 不超过 5 个。
  • 若使用了 Cache,key 写成了字面量,并且没有放在 Loop 或 ResourceForEach 内。
  • 用 ResourceForEach 遍历大集合时,已声明 limit 或确认过是可跑完的规模。
  • 若含有迭代(Loop·ResourceForEach),已确认该迭代会以乘法计入时间预算(没有外部调用时,30 秒基础预算就是上限)。
  • secret 值仅通过 Http.headers 的 secret:true 放入(Signature.secret 不是加密存储,因此已确认哪些角色可以读取该 Script)。
  • 用于签名校验的消息取自 { /rawPayload },而不是 /payload。
  • 若有读取会员(ServiceUser)的语句,作者拥有 SETTING_SERVICE_LOGIN,并且没有放入修改该资源的语句。
  • 尽量把不可逆的操作(外部调用)放到后面。
  • 如果担心 update/patch 竞争,就使用 ResourceUpdate 或 ResourcePatch 的 version。
  • 若要返回结果,已明确指定 Return.value。

错误

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

错误码条件
WGL400066一个定义中包含的 Cache 语句超过 5 个。
WGL400068Cache 语句被放在了 Loop 或 ResourceForEach 的块内。
WGL400067Cache 语句的 key 中写的不是字面量,而是 { /pointer } 引用。
WGL400065Cache 语句的 ttl 超出了允许范围。
WGL400063Cache 语句中写入了与 action 不对应的字段(在 Get 中写 ttl,在 Set 中写 defaultValue)。
WGL400060写入类语句(ResourceCreate·ResourceUpdate·ResourcePatch·ResourceDelete 以及发布·归档语句)的 resource 中写入了 "ServiceUser"。
WGL400061在允许匿名调用的 Script(anonymousCallEnabled)中,读取语句的 where 中写入了 createdBy: ":self"。
WGL400023一个定义中包含的 SetVar 语句超过 10 个。
WGL400026Http 语句的 retry 超过了上限 2。
WGL400036ResourceForEach 超过了可处理的项目数上限。
WGL429005一个定义中包含的 statement 总数超过了套餐上限。
WGL429006一个定义中包含的外部调用(Http·EmailSend)数超过了套餐上限。
WGL403015作者不具备定义中的语句所处理的资源与动作的权限。即使拥有权限,只要那条允许规则上附带了 contentType·createdBy·tag 过滤条件,保存也会被拒绝。 必须是不带条件的允许规则。唯一的例外是 Content Create,此时限定了 contentType 范围的允许规则也被认可,并与语句中所写的 contentType 进行比对(Media Create 没有这个例外)。读取语句的 resource 中写入了 "ServiceUser",而作者没有 SETTING_SERVICE_LOGIN,这种情况也属于该错误码。