执行语义、约束与安全

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

执行顺序

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

执行语义

Guard(前置条件)

没有专用的 guard 语句。用 Ifthen:[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 竞争通过 ResourceUpdateResourcePatchversion 来收窄。给出 version(值表达式,Int)后,只有当它与目标当前的 sys.version 一致时才更新,不一致则以版本冲突错误 abort(可用 Try/catch 局部处理)。省略时不做检查,采用 last-write-wins。通常先用 ResourceReadResourceFind 读取,再传入其 sys.version(参见 Cookbook 中的乐观锁 CAS)。

基于 origin 的写入

写入始终反映到 origin(draft),delivery(CDA/ACDA)的暴露由 publish 控制(ResourceCreate/ResourceUpdate/ResourcePatchpublish,或 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 的语句拒绝保存。只有三个读取语句接受该资源
anonymousCallEnabledtruewhere 中的 createdBy: ":self"拒绝保存

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

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

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

值长度上限(运行时)

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

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

时间预算(运行时)

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

  • 预算是按该 Script 计算出来的。 在基础预算之上,只加上定义所声明的时间。被声明的时间只有 HttpEmailSendtimeoutMs 这一项。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.headerssecret:true 的项目仅限 CMA(管理员),不会暴露给最终用户(ServiceUser),且仅在发送前才解密。像 LLM API 密钥这样的机密放在这里(即使被打包为 App Bundle,secret 值也会被掩码,不会离开原始 Space)。

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

离开 Space 时则不同。该 Script 被打包为 App Bundle 时,Signaturesecret被掩码,不会带到原始 Space 外部Http.headers 掩码的是带 secret 标记的项目与 Authorization 头,而 Signaturesecret 因为字段本身就是签名密钥,所以无条件被掩码。嵌套在 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 无认证地公开出去。
  • 禁止直接调用(directCallEnabledScriptdirectCallEnabledfalse 时,/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 个。
  • 若使用了 Cachekey 写成了字面量,并且没有放在 LoopResourceForEach 内。
  • ResourceForEach 遍历大集合时,已声明 limit 或确认过是可跑完的规模。
  • 若含有迭代(Loop·ResourceForEach),已确认该迭代会以乘法计入时间预算(没有外部调用时,30 秒基础预算就是上限)。
  • secret 值仅通过 Http.headerssecret:true 放入(Signature.secret 不是加密存储,因此已确认哪些角色可以读取该 Script)。
  • 用于签名校验的消息取自 { /rawPayload },而不是 /payload
  • 若有读取会员(ServiceUser)的语句,作者拥有 SETTING_SERVICE_LOGIN,并且没有放入修改该资源的语句。
  • 尽量把不可逆的操作(外部调用)放到后面。
  • 如果担心 update/patch 竞争,就使用 ResourceUpdateResourcePatchversion
  • 若要返回结果,已明确指定 Return.value

错误

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

错误码条件
WGL400066一个定义中包含的 Cache 语句超过 5 个。
WGL400068Cache 语句被放在了 LoopResourceForEach 的块内。
WGL400067Cache 语句的 key 中写的不是字面量,而是 { /pointer } 引用。
WGL400065Cache 语句的 ttl 超出了允许范围。
WGL400063Cache 语句中写入了与 action 不对应的字段(在 Get 中写 ttl,在 Set 中写 defaultValue)。
WGL400060写入类语句(ResourceCreate·ResourceUpdate·ResourcePatch·ResourceDelete 以及发布·归档语句)的 resource 中写入了 "ServiceUser"
WGL400061在允许匿名调用的 ScriptanonymousCallEnabled)中,读取语句的 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,这种情况也属于该错误码。