执行语义、约束与安全
本文梳理 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.ttl | 1 到 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),因此它不是在保存时检查,而是在运行中检查。
- 四者都与其他运行时失败相同,可以用
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时,按默认值(Http30 秒,EmailSend10 秒)计数。 - 没有声明时间的操作从 30 秒基础预算中支出。 资源的读取与写入、Media 文件摄取、迭代在其内部所做的事都属于这一类。因此基础预算不是走形式的数值,而是实际的份额。
- 累加方式遵循语句的结构。 顺序排列的语句相加,
If取两个分支中较大的一方,Parallel取各分支中最大的一个。Loop会给 body 乘上循环次数(maxIterations,未声明时为 10,000),ResourceForEach会给onEach乘上处理项目数(limit,未声明时为 10,000)。 - 不含外部调用的迭代,其声明时间为 0。 因此 30 秒基础预算就成了实际上限,含有迭代的 Script 实际被卡住的地方也正是这里。
- 180 秒的上限不会阻止保存,而是截断。 即使计算结果超过上限,该 Script 依然会被保存并执行,到达 180 秒时就在那里中断。
各套餐的数量上限
Script 的每个 Organization 的数量按套餐限制。
| Plan | Script 数量 |
|---|---|
| Free | 10 |
| Basic | 30 |
| Pro | 100 |
| 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 重新检查资源权限。
- 编写时(保存):保存 Script 时,会检查作者是否确实拥有这些 statement 所写入的资源与动作权限。只要缺少任意一个,保存就会被拒绝。也就是说,包含无权限操作的 Script 从一开始就无法保存。凡是选择资源的语句,无论是 leaf 还是拥有块的
ResourceForEach,都要接受这项检查。已经保存过的定义在修改时会再次检查,因此权限被收回之后就无法再保存对该定义的修改。- 会员目录(ServiceUser)不是通过权限映射检查,而是通过设置这条轴。 要在三个读取语句中写
resource: "ServiceUser",作者的 SpaceRolesettings中必须含有SETTING_SERVICE_LOGIN(或SETTING_ALL),参见 SpaceRole 的 settings。因为在系统的其他所有路径上,会员目录也都是由 Space 设置管辖的资源。 - 修改会员的语句在任何角色下都无法保存。 Script 中根本没有创建、修改、删除会员的途径,所以它不是权限不足(
403),而是作为写错的语句被拒绝(400)。也就是说,这不是靠加权限就能补上的空缺。
- 会员目录(ServiceUser)不是通过权限映射检查,而是通过设置这条轴。 要在三个读取语句中写
- 调用时(
/execute):只检查调用方的 Script Execute 权限。没有权限则返回403。通过后,各 statement 的资源权限在运行时不再确认即执行。这与编程中的函数执行权限方式相同。只要有执行函数的权限,其内部各个操作的权限就不会再被询问。匿名调用路径上没有这项检查。 因为没有可检查的调用方,所以开放那条路径等同于把一个 Script 无认证地公开出去。
- 编写时(保存):保存 Script 时,会检查作者是否确实拥有这些 statement 所写入的资源与动作权限。只要缺少任意一个,保存就会被拒绝。也就是说,包含无权限操作的 Script 从一开始就无法保存。凡是选择资源的语句,无论是 leaf 还是拥有块的
- 禁止直接调用(
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 个。 |
WGL400068 | Cache 语句被放在了 Loop 或 ResourceForEach 的块内。 |
WGL400067 | Cache 语句的 key 中写的不是字面量,而是 { /pointer } 引用。 |
WGL400065 | Cache 语句的 ttl 超出了允许范围。 |
WGL400063 | Cache 语句中写入了与 action 不对应的字段(在 Get 中写 ttl,在 Set 中写 defaultValue)。 |
WGL400060 | 写入类语句(ResourceCreate·ResourceUpdate·ResourcePatch·ResourceDelete 以及发布·归档语句)的 resource 中写入了 "ServiceUser"。 |
WGL400061 | 在允许匿名调用的 Script(anonymousCallEnabled)中,读取语句的 where 中写入了 createdBy: ":self"。 |
WGL400023 | 一个定义中包含的 SetVar 语句超过 10 个。 |
WGL400026 | Http 语句的 retry 超过了上限 2。 |
WGL400036 | ResourceForEach 超过了可处理的项目数上限。 |
WGL429005 | 一个定义中包含的 statement 总数超过了套餐上限。 |
WGL429006 | 一个定义中包含的外部调用(Http·EmailSend)数超过了套餐上限。 |
WGL403015 | 作者不具备定义中的语句所处理的资源与动作的权限。即使拥有权限,只要那条允许规则上附带了 contentType·createdBy·tag 过滤条件,保存也会被拒绝。 必须是不带条件的允许规则。唯一的例外是 Content Create,此时限定了 contentType 范围的允许规则也被认可,并与语句中所写的 contentType 进行比对(Media Create 没有这个例外)。读取语句的 resource 中写入了 "ServiceUser",而作者没有 SETTING_SERVICE_LOGIN,这种情况也属于该错误码。 |
相关文档
- 值表达式:值与条件规则。
- Statement 目录:各语句的字段与结果。
- Cookbook:完整示例集合。
- Script 资源与端点:
Script资源结构及/execute等 HTTP 端点。 - Script 概述:顶层结构与一次执行可用的时间。
