mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
test(ql3): qualify failed upgrades on edge devices
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
- 2026-08-14 人工恢复更新(ADR-0397,已接受):上述唯一保留的 `executing + Job/receipt 均缺失` 不确定窗口现在具有显式 Cluster 产品处置路径。既有 Approval management mTLS/OIDC endpoint 新增 `approval.recover.inspect|resolve`,只接受五分钟内 `multi_factor|hardware` User、独立 `approval.recover` 权限、二次认证、exact execution version/digest 和外部 evidence SHA-256。只允许 Secret binding/transition action;`confirm_failed` 写 failed,`abandon_unknown` 写 blocked,永远禁止人工 succeeded、Job 重建或 execution 重置。PostgreSQL `pg-0065`/capability v64 新增不可变 resolution ledger 与单个 SECURITY DEFINER resolver,在同一事务内锁 Policy/execution fence、写 allowed audit、推进终态并写 receipt;Approval manager 只有 dispatch/execution/resolution SELECT 与函数 EXECUTE,没有 execution UPDATE。通用 execution repository 与 Worker Credential 调用链保持不变。真实 PostgreSQL 18.4 已从空库完成 65 migration,证明原子提交、exact replay 不重复审计和 direct UPDATE `42501`;实现不新增 package、依赖、Pod、Pool、daemon、timer、watcher 或 Edge/Standalone 负担。18-package clean build/test 退出 0,backend 1,194 pass/2 skip/0 fail,package/dependency/edge/deployment 审计零 finding;新 migration 与 repository 内聚到 `approved-action` 领域,migration ledger 直属源码保持审定上限 65。PostgreSQL 18.4 arm64 physical HA 125 项 gate、timeline `1→2` 通过,报告 SHA-256 为 `6d4921cba74475d15722a13c6a8034793c0ee25681bc7dcaf91024927c5752fe`,临时 Docker 资源已清理。
|
||||
- 2026-08-14 升级失败收口(ADR-0398,进行中):已存在旧 active 的 `upgrade|reinstall|rollback` 不再先切 active pointer 再物化 Package 资源。共享 activation prerequisite sequence 固定为 Secret binding/transition receipt 就绪后,从 staged install 与 immutable lock 构建候选 generation,按既有字节上限完成 Manifest、Task、Workflow、Prompt、Tool 语义物化,并以 generation digest 预发布 immutable revision;只有成功后才进入 active pointer CAS。确定性候选错误把本次 install 写为 `failed(activation_fact_conflict)`,`activeLockDigest` 保持 `previousActiveLockDigest` 且 publisher 不被调用;瞬时文件/OCI/数据库故障保持 staged 重试。generation 1 没有旧版本可保留,Secret-aware 首次安装继续使用 ADR-0395 的 post-activation B1 binding ceremony,不被候选门错误拒绝。激活后的 publication recovery 只复用 revision 做 generation-fenced reconciliation。实现复用既有 18 个 package、双方言 repository、Local 单 SQLite authority 与 Cluster caller-driven recovery Job/单 Pool,不新增 migration、表、依赖、daemon、timer、watcher、listener、连接池或常驻 cache。定向 Runtime Core 21/21、Runtime Core 全量 548/548、Local Application 47 pass/4 条件 skip、Cluster Admin 347 pass/3 条件 skip;18-package clean build/test 退出 0,backend 1196 项为 1194 pass/2 条件 skip/0 fail,package boundary、cluster dependency、cluster deployment 与 edge import 审计均通过。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`。真实 Kubernetes 失败升级未移动 active pointer/head 的现场门与固定物理低配设备证据仍待闭合。
|
||||
- 2026-08-14 失败升级现场门更新:既有 PostgreSQL/OCI/Kubernetes recovery E2E 已升级为 report v2。门先激活 signed OCI generation 1,再排入包含合法 Task 与循环 Workflow 的 generation 2;第一次 recovery 必须因 transition receipt 缺失而失败并保持 staged,提交 content-free receipt 后,第二次 recovery 必须确定性写入 `failed(activation_fact_conflict)`,且 generation 2 materialized revision 数量为 0。门在升级前后逐字比较 active ConfigMap UID、`resourceVersion` 与完整 `active.json`,从而排除“先切 pointer、再补偿”的假安全;OCI v1 六路径各取一次、v2 六路径各取两次,全部要求 HTTPS、exact Basic authentication 与零 redirect。runtime rollout 只绑定最终成功 recovery Job,ConfigMap-only RBAC 与 runtime 数据库隔离保持不变。证据链现强制使用 canonical absolute path 原子写入 `0600` no-replace 私有报告,绑定 40-hex source revision 与 admin/control OCI revision label;持久报告只保存 active JSON digest,不保存原始 pointer、credential、DSN、kubeconfig、证书或 Secret material。独立离线审计以 `O_NOFOLLOW` 和 inode/mode/size 复验文件,并 exact-shape 校验 provenance、ordering、数据库/OCI/RBAC/runtime 事实、11 个 gate 和 limitation;CI 审计成功后上传固定 14 天的 evidence artifact。该链路只增加验收代码,不增加产品 package、依赖或低配/集群运行时常驻开销。本轮 producer/离线审计契约 14/14、18-package clean build/test 退出 0、backend 1201 pass/2 skip/0 fail,package/dependency/deployment/edge 审计均通过;PostgreSQL 18.4 arm64 physical HA 125 gate、timeline `1→2` 通过,报告 SHA-256 `8560469694c67776e5e4c70977f8bde8d4f5635f8e7d1c293ef449dc6da59f72`。本机 admin/control 镜像构建成功,但固定 Kind 1.32.8 节点镜像不在缓存且受限网络无法取得,门在创建节点前中止并确认无遗留集群/容器;因此远端 Kubernetes 成功记录与固定物理低配设备证据仍阻断 ADR Accepted。
|
||||
- 2026-08-14 固定低配失败升级 workload 更新:新增 `plugin_package_failed_upgrade_edge_candidate`,在 fresh production migration SQLite 上用正式 install/materialized repositories、正式 recovery coordinator 和正式资源物化 prerequisite,先激活 generation 1,再恢复含循环 Workflow 的 generation 2。exact gate 要求候选从 staged 进入 `failed(activation_fact_conflict)`、旧 active/previous digest 均保留、stage/publisher/inspect 零调用、候选 revision 零行、byte source 四路径各一次、`integrity_check=ok`,并限制耗时、RSS delta 与数据库 logical/allocated growth。它已进入 128 MiB router stress、256 MiB Edge release 和统一 physical recorder;CI cgroup 报告永久保持 `supported=false`,只有固定型号、无虚拟化设备在声明数据盘生成的统一私有总报告才是物理候选,且仍不证明断电。Node `v24.18.0` arm64 开发机本轮观测 14.390 ms、RSS delta 3,014,656 bytes、SQLite 增长 8,192 bytes,只作为可运行性样本。阶段完整验证为 18-package clean build/test 退出 0、backend 1,203 pass/2 skip/0 fail、相关 backend/发布契约 34/34,package boundary 仍为 18 个且 `singleSourcePackages=[]`、`shallowSourcePackages=[]`,cluster dependency/deployment、Edge import 和 service bridge import 审计零 finding。固定设备统一报告与远端 Kubernetes 成功记录仍是 ADR-0398 转 Accepted 的阻断项。
|
||||
- D-302/ADR-0390(已接受)
|
||||
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
|
||||
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
|
||||
@@ -5911,7 +5912,7 @@ ADR-0088 将 Linux CI 拆为三个不能互相替代的档位:
|
||||
|
||||
三档都固定 Node `24.18.0` 和 Debian slim,只在原生 Linux x64/arm64 runner 上执行。`scripts/ql3-linux-resource-gate.cjs` 必须从容器内部证明 cgroup v2 的 memory/swap/cpu/PID 精确限额、工作前后零 `max`/OOM 事件、非 root、只读根与 workspace、有界可写 `/tmp`、seccomp 和 `NoNewPrivs`;Docker CLI 参数、`os.totalmem()`、QEMU 或一次成功退出均不能替代这些事实。Workflow/Prompt crash report 永久保持 `physicalPowerLossProven=false`;固定设备受控断电前,它只能证明进程崩溃恢复。Docker tmpfs 上的 logical/allocated growth 也不能推导闪存 FTL 写放大。
|
||||
|
||||
当前 Edge 产品候选仍是 1 CPU、256 MiB RAM、1 GiB 可用持久空间,推荐 512 MiB,但正式支持阈值必须来自固定物理设备。`scripts/ql3-physical-edge-evidence.cjs` 已提供 fail-closed candidate recorder:绑定 exact-shape 设备 manifest、实际架构/内存/kernel/libc/文件系统/容量、Edge 与真实数据盘 Node SQLite 基准,拒绝容器/VM 指示、symlink/路径漂移和覆盖已有输出,以 `0600` + fsync 发布 SHA-256 绑定报告,并永久输出 `supported=false`。D-87 又增加了同设备同 boot 的进程 idle sampler 与专用文件系统 fault probe;D-88 增加了通过正式 TaskDefinition Repository 在 fresh v14 SQLite 上写入并完整扫描 100/1000/10000 三档的规模记录器,同时记录 RSS、耗时与数据库 logical/allocated bytes。基础记录器只能导入私有、摘要有效且 scope 精确的同设备同 boot 报告。规模报告不证明 scheduler 吞吐,也不替代 2.x adopted migration 的时间与磁盘峰值。物理支持仍需补齐冷启动/首次 ready、整机指标、adopted migration、application recovery、断电和 release signature;规模协议虽已实现,仍待固定实机采集。Cluster 必须另行记录副本数、PostgreSQL/连接池、claim/ACK/completion 吞吐、重复率、队列深度和 failover;不得从 512 MiB 空载门禁推导生产规格。
|
||||
当前 Edge 产品候选仍是 1 CPU、256 MiB RAM、1 GiB 可用持久空间,推荐 512 MiB,但正式支持阈值必须来自固定物理设备。`scripts/ql3-physical-edge-evidence.cjs` 已提供 fail-closed candidate recorder:绑定 exact-shape 设备 manifest、实际架构/内存/kernel/libc/文件系统/容量、Edge 与真实数据盘 Node SQLite 基准,拒绝容器/VM 指示、symlink/路径漂移和覆盖已有输出,以 `0600` + fsync 发布 SHA-256 绑定报告,并永久输出 `supported=false`。D-87 又增加了同设备同 boot 的进程 idle sampler 与专用文件系统 fault probe;D-88 增加了通过正式 TaskDefinition Repository 在 fresh v14 SQLite 上写入并完整扫描 100/1000/10000 三档的规模记录器,同时记录 RSS、耗时与数据库 logical/allocated bytes。D-306B2/ADR-0398 进一步把真实 Plugin Package 失败升级加入基础 workload:在同一 scratch 数据盘的 fresh production migration SQLite 上先激活 generation 1,再以正式 recovery coordinator、候选物化 prerequisite 和 SQLite repositories 恢复含循环 Workflow 的 generation 2;只有 v2 进入 `failed(activation_fact_conflict)`、旧 active digest 双重保留、publisher 零调用、候选 materialized revision 零行、`integrity_check=ok` 且耗时/RSS/logical 与 allocated 增长均未越界才通过。该 workload 也进入 128/256 MiB cgroup 门,但容器结果仍只属于 stress;统一 physical recorder 在无虚拟化固定设备上采集的总报告才属于物理候选。基础记录器只能导入私有、摘要有效且 scope 精确的同设备同 boot 报告。规模报告不证明 scheduler 吞吐,也不替代 2.x adopted migration 的时间与磁盘峰值;失败升级报告也永久声明 `physical_power_loss_not_proven`。物理支持仍需补齐冷启动/首次 ready、整机指标、adopted migration、application recovery、断电和 release signature;规模与失败升级协议虽已实现,仍待固定实机采集。Cluster 必须另行记录副本数、PostgreSQL/连接池、claim/ACK/completion 吞吐、重复率、队列深度和 failover;不得从 512 MiB 空载门禁推导生产规格。
|
||||
|
||||
冷启动/首次 ready 只能对最终 application artifact 与冻结的 readiness contract 计时,不能用 Executor benchmark 或模块 import 代替。TaskDefinition schema、独立 execution revision digest、版本化 Trigger schema、Repository 与读取路径现已由 local SQLite capability v16 冻结,规模记录器只能调用正式端口;它生成的仍是物理 candidate,固定设备报告采集前不形成支持结论。fresh schema migration 计时只属于该规模报告的局部度量,2.x adopted database 仍须单独记录 migration 时间、替表额外磁盘峰值与采样精度。对应证据未完成前,candidate report 保留其余 required evidence。
|
||||
|
||||
|
||||
@@ -152,6 +152,19 @@ pnpm evidence:physical-edge -- \
|
||||
|
||||
记录器拒绝 macOS、容器/VM 指示、架构/内存/文件系统漂移、symlink、根目录数据路径、已有输出和非 canonical 路径;SQLite 基准的临时库实际位于声明的数据文件系统。输出以 `0600`、no-replace、fsync 发布,并绑定 SHA-256。`storageMedium`、设备型号和 SoC 仍是 operator-declared provenance,SHA-256 也不是签名,因此报告始终为 `supported=false`。
|
||||
|
||||
基础记录器现在还在同一真实数据文件系统的私有 scratch 中运行
|
||||
`plugin_package_failed_upgrade_edge_candidate`:在 fresh production migration SQLite
|
||||
上先激活 generation 1,再持久化一个包含循环 Workflow 的 generation 2 staged
|
||||
升级,并通过正式 recovery coordinator、正式候选资源物化 prerequisite 和正式 SQLite
|
||||
repositories 恢复。报告只有在 generation 2 精确进入
|
||||
`failed(activation_fact_conflict)`、旧 `activeLockDigest` 与
|
||||
`previousActiveLockDigest` 均保留、activation publisher 零调用、候选 materialized
|
||||
revision 零行、SQLite `integrity_check=ok`,且耗时、RSS delta、logical/allocated
|
||||
数据库增长均处于固定上限内时才通过。相同 workload 同时进入 128 MiB router stress
|
||||
与 256 MiB Edge release cgroup 门,前者仍只是 CI stress;只有由本记录器在无虚拟化
|
||||
指示的固定设备和声明数据盘上生成时,才属于物理候选证据。两条路径都明确保留
|
||||
`physical_power_loss_not_proven`,不能推出断电安全或正式最低配置。
|
||||
|
||||
### 补充 idle 证据
|
||||
|
||||
idle sampler 只观察一个已经启动的 QingLong Node 进程,采样窗口为 30 至 3600 秒,间隔为 1 至 60 秒且必须整除窗口。manifest 必须绑定 device、PID 与期望 executable;采样期间 PID、boot ID、进程 start ticks、UID、executable 或命令摘要任一漂移都失败关闭:
|
||||
|
||||
@@ -48,7 +48,9 @@ ConfigMap 和数据库 head 之间会出现第二次分布式提交窗口,并
|
||||
- Local 与 Cluster 组合测试证明恢复顺序一致,既有 active publication/reconciliation 不回归。
|
||||
- 完整 18-package、backend、package/dependency/deployment/edge/import 审计通过。
|
||||
- 真实 PostgreSQL/Kubernetes 门证明失败升级没有移动 active ConfigMap/head;physical HA 门通过。
|
||||
- 固定物理低配设备证据仍由 ADR-0396 单独阻断,不能用开发机观测替代。
|
||||
- 固定物理低配设备必须运行真实 SQLite 失败升级 workload,证明旧 active 保留、候选
|
||||
revision 零写入、publisher 零调用、数据库完整性及耗时/RSS/数据盘增长上限;容器
|
||||
cgroup 结果只能作为前置 stress,不能用开发机或虚拟化观测替代。
|
||||
|
||||
## 影响与替代方案
|
||||
|
||||
@@ -77,8 +79,8 @@ ConfigMap 和数据库 head 之间会出现第二次分布式提交窗口,并
|
||||
镜像 provenance、六段单调 ordering、数据库精确计数、OCI 18 次认证请求、ConfigMap-only RBAC、双节点 runtime 绑定、全部
|
||||
11 个 gate 与三项 limitation 做 exact-shape 校验,并以 `O_NOFOLLOW`/inode/mode/size 复验私有报告。CI 在独立 job 内先审计,
|
||||
再上传固定 14 天的 source-bound evidence artifact;这一证据链只属于验收,不新增产品 package、依赖或运行时常驻开销。
|
||||
- 18-package clean build/test 在允许 loopback TLS 的环境退出 0;backend 1203 项为
|
||||
1201 pass/2 条件 skip/0 fail。新增/更新的 recovery E2E producer/离线审计契约 14/14,Runtime Core 定向 21/21。package boundary 保持 18 个 package 且
|
||||
- 18-package clean build/test 在允许 loopback TLS 的环境退出 0;backend 1205 项为
|
||||
1203 pass/2 条件 skip/0 fail。新增/更新的 recovery E2E producer/离线审计契约 14/14,Runtime Core 定向 21/21。package boundary 保持 18 个 package 且
|
||||
`singleSourcePackages=[]`、`shallowSourcePackages=[]`;cluster dependency、cluster deployment
|
||||
与 edge import 审计均无 finding。
|
||||
- PostgreSQL `18.4` arm64 physical HA 通过 125 项门,timeline `1→2`,报告 SHA-256
|
||||
@@ -86,3 +88,18 @@ ConfigMap 和数据库 head 之间会出现第二次分布式提交窗口,并
|
||||
资源已清理。本机已成功构建现场门所需 admin/control 镜像,但固定 `kindest/node:v1.32.8` 不在本地缓存,受限网络拉取数分钟无进度;
|
||||
门在创建任何 Kind 节点前被中止,并确认没有遗留集群或容器。因此 v2 门、私有报告与离线审计代码已完成,但仍不能计为真实 Kubernetes
|
||||
现场通过;远端 CI 成功记录与固定物理低配设备证据仍待完成,本 ADR 保持 Proposed。
|
||||
- 固定低配资格门的产品工作负载已落地:`evidence:plugin-package-recovery-edge`
|
||||
使用 fresh production migration SQLite、正式 install/materialized repositories、正式
|
||||
recovery coordinator 与正式语义物化门,实测无效 generation 2 从 staged 进入 failed,
|
||||
旧 active generation 保留、publisher 0 次、候选 materialized revision 0 行、数据库
|
||||
`integrity_check=ok`。该工作负载已接入 128/256 MiB Linux 资源门和统一 physical Edge
|
||||
recorder;统一聚合器会 exact-shape 拒绝缺失、改名或失败报告,并把
|
||||
`plugin_package_failed_upgrade_retains_active_generation` 列为独立 collected/remaining
|
||||
evidence。当前本机 arm64 执行仅是开发验证,尚未产生固定型号、无虚拟化设备上的
|
||||
owner-private 总报告,也未证明受控断电,因此不能把本 ADR 转为 Accepted。
|
||||
本轮真实 workload 在 Node `v24.18.0` arm64 开发机观测为 14.390 ms、RSS delta
|
||||
3,014,656 bytes、SQLite logical/allocated growth 各 8,192 bytes,均低于
|
||||
10,000 ms、96 MiB、4 MiB 的候选上限;这些数值只证明脚本与门禁可运行,不是
|
||||
固定设备支持结论。对应 backend 新增路径、物理聚合和 Linux release 契约 34/34;
|
||||
package boundary、cluster dependency、cluster deployment、Edge import 与 service
|
||||
bridge import 审计均通过。
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"benchmark:worker": "node scripts/ql3-worker-resource-benchmark.cjs",
|
||||
"benchmark:ai-profile": "node scripts/ql3-ai-profile-benchmark.cjs",
|
||||
"evidence:physical-edge": "node scripts/ql3-physical-edge-evidence.cjs",
|
||||
"evidence:plugin-package-recovery-edge": "node scripts/ql3-plugin-package-recovery-edge-benchmark.cjs",
|
||||
"evidence:physical-edge-idle": "node scripts/ql3-physical-edge-idle-sampler.cjs",
|
||||
"evidence:physical-edge-fault": "node scripts/ql3-physical-edge-fault-probe.cjs",
|
||||
"evidence:physical-edge-task-scale": "node scripts/ql3-physical-edge-task-scale.cjs",
|
||||
|
||||
@@ -295,41 +295,41 @@ function createWorkloadPlans(root, tierName) {
|
||||
...(tierName === 'edge-release-ci'
|
||||
? ['edge', 'standalone'].map((profile) =>
|
||||
Object.freeze({
|
||||
name: `local-ai-prompt-durable-output-${profile}`,
|
||||
format: 'node_test',
|
||||
nodeArgs: Object.freeze([
|
||||
'--test',
|
||||
'--test-name-pattern=executes one active Package Prompt',
|
||||
path.join(
|
||||
root,
|
||||
'packages/ql3-local-application/test/activation.test.cjs',
|
||||
),
|
||||
]),
|
||||
env: Object.freeze({
|
||||
QL3_PROMPT_RESOURCE_PROFILE: profile,
|
||||
QL3_PROMPT_RESOURCE_OUTPUT_BYTES: String(
|
||||
PROMPT_RESOURCE_OUTPUT_BYTES,
|
||||
),
|
||||
}),
|
||||
maxProcessRssBytes: tier.promptMaxProcessRssMb * MIB,
|
||||
contract: Object.freeze({
|
||||
kind: 'durable_prompt_output_resource',
|
||||
profile,
|
||||
journalMode: profile === 'edge' ? 'delete' : 'wal',
|
||||
durableOutputBytes: PROMPT_RESOURCE_OUTPUT_BYTES,
|
||||
providerCalls: 2,
|
||||
keyLoads: 1,
|
||||
keyResolutions: 1,
|
||||
exactReplay: true,
|
||||
contentFree: true,
|
||||
maxLogicalWriteAmplificationPermille: 3_000,
|
||||
maxAllocatedWriteAmplificationPermille: 3_500,
|
||||
maxWalWriteAmplificationPermille:
|
||||
profile === 'standalone' ? 3_000 : 0,
|
||||
requireWalGrowth: profile === 'standalone',
|
||||
runAttempts: 0,
|
||||
physicalPowerLossProven: false,
|
||||
}),
|
||||
name: `local-ai-prompt-durable-output-${profile}`,
|
||||
format: 'node_test',
|
||||
nodeArgs: Object.freeze([
|
||||
'--test',
|
||||
'--test-name-pattern=executes one active Package Prompt',
|
||||
path.join(
|
||||
root,
|
||||
'packages/ql3-local-application/test/activation.test.cjs',
|
||||
),
|
||||
]),
|
||||
env: Object.freeze({
|
||||
QL3_PROMPT_RESOURCE_PROFILE: profile,
|
||||
QL3_PROMPT_RESOURCE_OUTPUT_BYTES: String(
|
||||
PROMPT_RESOURCE_OUTPUT_BYTES,
|
||||
),
|
||||
}),
|
||||
maxProcessRssBytes: tier.promptMaxProcessRssMb * MIB,
|
||||
contract: Object.freeze({
|
||||
kind: 'durable_prompt_output_resource',
|
||||
profile,
|
||||
journalMode: profile === 'edge' ? 'delete' : 'wal',
|
||||
durableOutputBytes: PROMPT_RESOURCE_OUTPUT_BYTES,
|
||||
providerCalls: 2,
|
||||
keyLoads: 1,
|
||||
keyResolutions: 1,
|
||||
exactReplay: true,
|
||||
contentFree: true,
|
||||
maxLogicalWriteAmplificationPermille: 3_000,
|
||||
maxAllocatedWriteAmplificationPermille: 3_500,
|
||||
maxWalWriteAmplificationPermille:
|
||||
profile === 'standalone' ? 3_000 : 0,
|
||||
requireWalGrowth: profile === 'standalone',
|
||||
runAttempts: 0,
|
||||
physicalPowerLossProven: false,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
: []),
|
||||
@@ -395,10 +395,7 @@ function createWorkloadPlans(root, tierName) {
|
||||
profiles: Object.freeze(['edge', 'standalone']),
|
||||
crashPointsPerProfile: 7,
|
||||
scenarios: 14,
|
||||
boundaries: Object.freeze([
|
||||
'model_start',
|
||||
'model_completion',
|
||||
]),
|
||||
boundaries: Object.freeze(['model_start', 'model_completion']),
|
||||
mechanism: 'process_sigkill_then_database_reopen',
|
||||
physicalPowerLossProven: false,
|
||||
}),
|
||||
@@ -426,6 +423,20 @@ function createWorkloadPlans(root, tierName) {
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
Object.freeze({
|
||||
name: 'plugin-package-failed-upgrade',
|
||||
format: 'json',
|
||||
script: path.join(
|
||||
root,
|
||||
'scripts/ql3-plugin-package-recovery-edge-benchmark.cjs',
|
||||
),
|
||||
args: Object.freeze([
|
||||
'--json',
|
||||
'--max-duration-ms=10000',
|
||||
`--max-rss-delta-mb=${tier.edgeMaxRssDeltaMb}`,
|
||||
`--max-database-growth-bytes=${4 * MIB}`,
|
||||
]),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return Object.freeze([
|
||||
@@ -489,8 +500,7 @@ function parseNodeTestReport(output, plan) {
|
||||
evidence.exactReplay !== contract.exactReplay ||
|
||||
evidence.contentFree !== contract.contentFree ||
|
||||
evidence.durableFacts?.attempts !== contract.runAttempts ||
|
||||
evidence.physicalPowerLossProven !==
|
||||
contract.physicalPowerLossProven ||
|
||||
evidence.physicalPowerLossProven !== contract.physicalPowerLossProven ||
|
||||
!Number.isSafeInteger(
|
||||
evidence.databaseLogicalWriteAmplificationPermille,
|
||||
) ||
|
||||
|
||||
@@ -5,6 +5,9 @@ const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const {
|
||||
validateReport: validatePluginPackageRecoveryEdgeReport,
|
||||
} = require('./ql3-plugin-package-recovery-edge-benchmark.cjs');
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const GIB = 1024 * MIB;
|
||||
@@ -710,12 +713,72 @@ function runEvidenceWorkloads(root, dataPath) {
|
||||
],
|
||||
{ TMPDIR: scratchPath },
|
||||
),
|
||||
runJsonWorkload(
|
||||
root,
|
||||
'plugin-package-failed-upgrade',
|
||||
'scripts/ql3-plugin-package-recovery-edge-benchmark.cjs',
|
||||
[
|
||||
'--json',
|
||||
'--max-duration-ms=10000',
|
||||
'--max-rss-delta-mb=96',
|
||||
`--max-database-growth-bytes=${4 * MIB}`,
|
||||
],
|
||||
{ TMPDIR: scratchPath },
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
fs.rmSync(scratchPath, { recursive: true, force: false });
|
||||
}
|
||||
}
|
||||
|
||||
function validateEvidenceWorkloads(workloads) {
|
||||
if (!Array.isArray(workloads) || workloads.length !== 3) {
|
||||
return Object.freeze(['physical evidence workloads are incomplete']);
|
||||
}
|
||||
const expectedNames = [
|
||||
'edge-executor',
|
||||
'node-sqlite-on-device-storage',
|
||||
'plugin-package-failed-upgrade',
|
||||
];
|
||||
if (
|
||||
workloads.some(
|
||||
(workload, index) =>
|
||||
!hasExactKeys(workload, ['name', 'report']) ||
|
||||
workload.name !== expectedNames[index],
|
||||
)
|
||||
) {
|
||||
return Object.freeze(['physical evidence workload identity is invalid']);
|
||||
}
|
||||
const violations = [];
|
||||
const edge = workloads[0].report;
|
||||
if (
|
||||
edge?.schemaVersion !== 1 ||
|
||||
edge?.profile !== 'edge' ||
|
||||
edge?.gates?.passed !== true ||
|
||||
!Array.isArray(edge?.gates?.violations) ||
|
||||
edge.gates.violations.length !== 0
|
||||
) {
|
||||
violations.push('edge executor workload did not pass exactly');
|
||||
}
|
||||
const sqlite = workloads[1].report;
|
||||
if (
|
||||
sqlite?.journalMode !== 'delete' ||
|
||||
sqlite?.synchronous !== 'full' ||
|
||||
sqlite?.integrityCheck !== 'ok' ||
|
||||
!Number.isSafeInteger(sqlite?.databaseBytes) ||
|
||||
sqlite.databaseBytes < 1 ||
|
||||
!isNonNegativeNumber(sqlite?.rssDeltaMb)
|
||||
) {
|
||||
violations.push('on-device SQLite workload is invalid');
|
||||
}
|
||||
violations.push(
|
||||
...validatePluginPackageRecoveryEdgeReport(workloads[2].report).map(
|
||||
(violation) => `Plugin Package recovery workload: ${violation}`,
|
||||
),
|
||||
);
|
||||
return Object.freeze(violations);
|
||||
}
|
||||
|
||||
function canonicalDigest(value) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
@@ -1402,7 +1465,10 @@ function buildEvidenceReport({
|
||||
supplementalEvidence = [],
|
||||
generatedAt,
|
||||
}) {
|
||||
const violations = validateObservedPlatform(manifest, observed);
|
||||
const violations = [
|
||||
...validateObservedPlatform(manifest, observed),
|
||||
...validateEvidenceWorkloads(workloads),
|
||||
];
|
||||
const idleEvidence = supplementalEvidence.find(
|
||||
({ evidenceClass }) => evidenceClass === 'physical_edge_idle_candidate',
|
||||
);
|
||||
@@ -1491,6 +1557,19 @@ function buildEvidenceReport({
|
||||
if (directServiceStopEvidence) {
|
||||
collectedEvidence.push('init_managed_graceful_application_stop');
|
||||
}
|
||||
const pluginPackageRecoveryEvidence = Array.isArray(workloads)
|
||||
? workloads.find(({ name }) => name === 'plugin-package-failed-upgrade')
|
||||
: undefined;
|
||||
const pluginPackageRecoveryQualified =
|
||||
pluginPackageRecoveryEvidence !== undefined &&
|
||||
validatePluginPackageRecoveryEdgeReport(
|
||||
pluginPackageRecoveryEvidence.report,
|
||||
).length === 0;
|
||||
if (pluginPackageRecoveryQualified) {
|
||||
collectedEvidence.push(
|
||||
'plugin_package_failed_upgrade_retains_active_generation',
|
||||
);
|
||||
}
|
||||
const remainingRequiredEvidence = [
|
||||
...(directServiceStartEvidence
|
||||
? ['firmware_and_bootloader_power_on_to_linux_kernel_clock']
|
||||
@@ -1513,6 +1592,13 @@ function buildEvidenceReport({
|
||||
'power_loss_restart',
|
||||
'release_archive_signature',
|
||||
];
|
||||
if (!pluginPackageRecoveryQualified) {
|
||||
remainingRequiredEvidence.splice(
|
||||
1,
|
||||
0,
|
||||
'plugin_package_failed_upgrade_retains_active_generation',
|
||||
);
|
||||
}
|
||||
if (!idleEvidence) {
|
||||
remainingRequiredEvidence.splice(
|
||||
1,
|
||||
@@ -1713,6 +1799,7 @@ module.exports = {
|
||||
validateServiceStartEvidenceReport,
|
||||
validateDirectServiceStartEvidenceReport,
|
||||
validateDirectServiceStopEvidenceReport,
|
||||
validateEvidenceWorkloads,
|
||||
validateTaskScaleWorkload,
|
||||
validateObservedPlatform,
|
||||
writeNoReplace,
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
|
||||
const MIB = 1024 * 1024;
|
||||
const DEFAULT_MAX_DURATION_MS = 10_000;
|
||||
const DEFAULT_MAX_RSS_DELTA_MB = 96;
|
||||
const DEFAULT_MAX_DATABASE_GROWTH_BYTES = 4 * MIB;
|
||||
|
||||
const REPORT_KEYS = Object.freeze([
|
||||
'calls',
|
||||
'database',
|
||||
'durable',
|
||||
'evidenceClass',
|
||||
'gates',
|
||||
'identity',
|
||||
'limitations',
|
||||
'measurement',
|
||||
'recovery',
|
||||
'schemaVersion',
|
||||
'supported',
|
||||
'thresholds',
|
||||
]);
|
||||
const CALL_KEYS = Object.freeze([
|
||||
'byteSourceClose',
|
||||
'byteSourceOpen',
|
||||
'byteSourceRead',
|
||||
'publisherInspect',
|
||||
'publisherPublish',
|
||||
'stage',
|
||||
]);
|
||||
const DATABASE_KEYS = Object.freeze([
|
||||
'allocatedBytes',
|
||||
'allocatedGrowthBytes',
|
||||
'files',
|
||||
'integrityCheck',
|
||||
'journalMode',
|
||||
'logicalBytes',
|
||||
'logicalGrowthBytes',
|
||||
'materializedCandidateRevisions',
|
||||
'synchronous',
|
||||
]);
|
||||
const DATABASE_FILE_KEYS = Object.freeze([
|
||||
'allocatedBytes',
|
||||
'logicalBytes',
|
||||
'suffix',
|
||||
]);
|
||||
const DURABLE_KEYS = Object.freeze([
|
||||
'activeLockDigestPreserved',
|
||||
'failedFrom',
|
||||
'failureReason',
|
||||
'headInstallationIsCandidate',
|
||||
'previousActiveLockDigestPreserved',
|
||||
'state',
|
||||
]);
|
||||
const GATE_KEYS = Object.freeze(['passed', 'violations']);
|
||||
const IDENTITY_KEYS = Object.freeze(['architecture', 'node', 'platform']);
|
||||
const MEASUREMENT_KEYS = Object.freeze([
|
||||
'durationMs',
|
||||
'rssAfterBytes',
|
||||
'rssBeforeBytes',
|
||||
'rssDeltaBytes',
|
||||
]);
|
||||
const RECOVERY_KEYS = Object.freeze([
|
||||
'deferred',
|
||||
'manualRequired',
|
||||
'pages',
|
||||
'remaining',
|
||||
'retry',
|
||||
'safeToAdmit',
|
||||
'scanned',
|
||||
'settled',
|
||||
'superseded',
|
||||
]);
|
||||
const THRESHOLD_KEYS = Object.freeze([
|
||||
'maxDatabaseGrowthBytes',
|
||||
'maxDurationMs',
|
||||
'maxRssDeltaBytes',
|
||||
]);
|
||||
const EXPECTED_LIMITATIONS = Object.freeze([
|
||||
'container_or_vm_resource_limits_are_not_physical_support_evidence',
|
||||
'physical_power_loss_not_proven',
|
||||
]);
|
||||
|
||||
class QingLong3PluginPackageRecoveryEdgeBenchmarkError extends Error {
|
||||
constructor(message) {
|
||||
super(
|
||||
`QingLong 3.0 Plugin Package recovery Edge benchmark failed: ${message}`,
|
||||
);
|
||||
this.name = 'QingLong3PluginPackageRecoveryEdgeBenchmarkError';
|
||||
}
|
||||
}
|
||||
|
||||
function hasExactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value).sort()) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function positiveNumber(value, label) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`${label} must be a positive number`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function positiveSafeInteger(value, label) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`${label} must be a positive safe integer`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const options = {
|
||||
json: false,
|
||||
maxDatabaseGrowthBytes: DEFAULT_MAX_DATABASE_GROWTH_BYTES,
|
||||
maxDurationMs: DEFAULT_MAX_DURATION_MS,
|
||||
maxRssDeltaBytes: DEFAULT_MAX_RSS_DELTA_MB * MIB,
|
||||
};
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--json') {
|
||||
options.json = true;
|
||||
continue;
|
||||
}
|
||||
const [name, raw] = argument.split('=', 2);
|
||||
if (raw === undefined) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`unsupported argument ${argument}`,
|
||||
);
|
||||
}
|
||||
if (name === '--max-duration-ms') {
|
||||
options.maxDurationMs = positiveNumber(raw, name);
|
||||
} else if (name === '--max-rss-delta-mb') {
|
||||
options.maxRssDeltaBytes = positiveNumber(raw, name) * MIB;
|
||||
} else if (name === '--max-database-growth-bytes') {
|
||||
options.maxDatabaseGrowthBytes = positiveSafeInteger(raw, name);
|
||||
} else {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`unsupported argument ${name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Object.freeze(options);
|
||||
}
|
||||
|
||||
function round(value) {
|
||||
return Math.round(value * 1_000) / 1_000;
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function storageSnapshot(databasePath) {
|
||||
const files = [
|
||||
['database', databasePath],
|
||||
['-journal', `${databasePath}-journal`],
|
||||
['-wal', `${databasePath}-wal`],
|
||||
['-shm', `${databasePath}-shm`],
|
||||
]
|
||||
.filter(([, filePath]) => fs.existsSync(filePath))
|
||||
.map(([suffix, filePath]) => {
|
||||
const stat = fs.statSync(filePath);
|
||||
return Object.freeze({
|
||||
suffix,
|
||||
logicalBytes: stat.size,
|
||||
allocatedBytes: stat.blocks * 512,
|
||||
});
|
||||
});
|
||||
return Object.freeze({
|
||||
files: Object.freeze(files),
|
||||
logicalBytes: files.reduce((sum, file) => sum + file.logicalBytes, 0),
|
||||
allocatedBytes: files.reduce((sum, file) => sum + file.allocatedBytes, 0),
|
||||
});
|
||||
}
|
||||
|
||||
function packageFixture(runtime, generation, previous, invalidWorkflow) {
|
||||
const packageArchitecture =
|
||||
process.arch === 'x64'
|
||||
? 'amd64'
|
||||
: process.arch === 'arm'
|
||||
? 'arm/v7'
|
||||
: process.arch;
|
||||
const task = (id) => ({
|
||||
schema: 'qinglong/plugin-package-task-resource@v1',
|
||||
id,
|
||||
name: `Recovery ${id}`,
|
||||
labels: { 'plugin.qinglong.io/source': 'edge-recovery-benchmark' },
|
||||
enabled: true,
|
||||
kind: 'command',
|
||||
spec: {
|
||||
schema: 'qinglong/command@v1',
|
||||
config: {
|
||||
command: {
|
||||
kind: 'argv',
|
||||
file: '/usr/bin/printf',
|
||||
args: [id],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const workflow = {
|
||||
schema: 'qinglong/plugin-package-workflow-resource@v1',
|
||||
id: 'recovery',
|
||||
name: 'Recovery qualification',
|
||||
enabled: true,
|
||||
steps: invalidWorkflow
|
||||
? [
|
||||
{ id: 'collect', task: 'collect', needs: ['report'] },
|
||||
{ id: 'report', task: 'report', needs: ['collect'] },
|
||||
]
|
||||
: [
|
||||
{ id: 'collect', task: 'collect', needs: [] },
|
||||
{ id: 'report', task: 'report', needs: ['collect'] },
|
||||
],
|
||||
};
|
||||
const values = {
|
||||
'tasks/collect.json': task('collect'),
|
||||
'tasks/report.json': task('report'),
|
||||
'workflows/recovery.json': workflow,
|
||||
};
|
||||
const bytes = Object.fromEntries(
|
||||
Object.entries(values).map(([resourcePath, value]) => [
|
||||
resourcePath,
|
||||
Buffer.from(JSON.stringify(value)),
|
||||
]),
|
||||
);
|
||||
const descriptors = Object.entries(bytes)
|
||||
.map(([resourcePath, material]) => ({
|
||||
path: resourcePath,
|
||||
bytes: material.byteLength,
|
||||
digest: sha256(material),
|
||||
}))
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const manifest = {
|
||||
apiVersion: runtime.PLUGIN_PACKAGE_API_VERSION,
|
||||
kind: runtime.PLUGIN_PACKAGE_KIND,
|
||||
metadata: {
|
||||
name: 'edge-recovery-benchmark',
|
||||
displayName: 'Edge recovery benchmark',
|
||||
version: `${generation}.0.0`,
|
||||
description: 'Bounded failed-upgrade qualification workload',
|
||||
license: 'Apache-2.0',
|
||||
},
|
||||
spec: {
|
||||
compatibility: {
|
||||
qinglong: '>=3.0.0-0 <4.0.0',
|
||||
architectures: [packageArchitecture],
|
||||
deploymentProfiles: ['edge'],
|
||||
},
|
||||
runtimes: [],
|
||||
resources: {
|
||||
memory: { recommended: '16Mi' },
|
||||
disk: { install: '4Mi', working: '8Mi' },
|
||||
},
|
||||
permissions: {
|
||||
network: { allowedHosts: [] },
|
||||
secrets: [],
|
||||
tools: ['system.command'],
|
||||
},
|
||||
contents: {
|
||||
tasks: ['tasks/collect.json', 'tasks/report.json'],
|
||||
workflows: ['workflows/recovery.json'],
|
||||
prompts: [],
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const environment = {
|
||||
qinglongVersion: '3.0.0-alpha.0',
|
||||
architecture: packageArchitecture,
|
||||
deploymentProfile: 'edge',
|
||||
runtimes: [],
|
||||
availableMemoryBytes: 128 * MIB,
|
||||
availableDiskBytes: 256 * MIB,
|
||||
};
|
||||
const plan = runtime.planPluginPackageInstall(
|
||||
manifest,
|
||||
environment,
|
||||
previous?.manifest,
|
||||
);
|
||||
const artifactDigest = sha256(`edge-recovery-artifact-${generation}`);
|
||||
const action = {
|
||||
lockId: `edge-recovery-lock-${generation}`,
|
||||
projectId: 'default',
|
||||
manifest,
|
||||
plan,
|
||||
environment,
|
||||
...(previous ? { previousManifest: previous.manifest } : {}),
|
||||
source: {
|
||||
kind: 'offline',
|
||||
locator: `offline:sha256:${artifactDigest}`,
|
||||
artifactDigest,
|
||||
artifactBytes: Object.values(bytes).reduce(
|
||||
(sum, material) => sum + material.byteLength,
|
||||
0,
|
||||
),
|
||||
contentDigest: runtime.pluginPackageContentTreeDigest(descriptors),
|
||||
},
|
||||
architecture: packageArchitecture,
|
||||
deploymentProfile: 'edge',
|
||||
targetGeneration: generation,
|
||||
...(previous ? { previousLockDigest: previous.lock.lockDigest } : {}),
|
||||
};
|
||||
const lock = runtime.createPluginPackageLock({
|
||||
...action,
|
||||
approval: {
|
||||
requestId: `edge-recovery-approval-${generation}`,
|
||||
requestVersion: 1,
|
||||
dispatchId: `edge-recovery-dispatch-${generation}`,
|
||||
actionDigest: runtime.pluginPackageInstallActionDigest(action),
|
||||
previewDigest: runtime.pluginPackageInstallPlanDigest(plan),
|
||||
approvedBy: { type: 'user', id: 'edge-owner' },
|
||||
approvedAtMs: 100 + generation,
|
||||
expiresAtMs: 100_000,
|
||||
fence: { projectVersion: 1, bindingVersion: 1 },
|
||||
},
|
||||
createdAtMs: 200 + generation,
|
||||
});
|
||||
return Object.freeze({
|
||||
bytes: Object.freeze(bytes),
|
||||
lock,
|
||||
manifest,
|
||||
manifestBytes: Buffer.from(
|
||||
runtime.serializePluginPackageManifest(manifest),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function stage(runtime, value, record, occurredAtMs) {
|
||||
return runtime.transitionPluginPackageInstall(value.lock, record, {
|
||||
type: 'stage_completed',
|
||||
mutationId: `edge-recovery-stage-${value.lock.targetGeneration}`,
|
||||
occurredAtMs,
|
||||
stageRef: `edge-recovery:${value.lock.lockDigest}`,
|
||||
artifactDigest: value.lock.source.artifactDigest,
|
||||
manifestDigest: value.lock.manifestDigest,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
evidenceDigest: sha256(
|
||||
`edge-recovery-stage-${value.lock.targetGeneration}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function createActiveGeneration(runtime, repository, value) {
|
||||
const queued = runtime.createPluginPackageInstall(value.lock, {
|
||||
installationId: 'edge-recovery-install-1',
|
||||
mutationId: 'edge-recovery-create-1',
|
||||
occurredAtMs: 301,
|
||||
});
|
||||
await repository.create(
|
||||
runtime.pluginPackageInstallCreate(value.lock, queued, null),
|
||||
);
|
||||
const staged = stage(runtime, value, queued, 302);
|
||||
await repository.commit(runtime.pluginPackageInstallCommit(queued, staged));
|
||||
const activating = runtime.transitionPluginPackageInstall(
|
||||
value.lock,
|
||||
staged,
|
||||
{
|
||||
type: 'activation_started',
|
||||
mutationId: 'edge-recovery-activate-1',
|
||||
occurredAtMs: 303,
|
||||
},
|
||||
);
|
||||
await repository.commit(
|
||||
runtime.pluginPackageInstallCommit(staged, activating),
|
||||
);
|
||||
const active = runtime.transitionPluginPackageInstall(
|
||||
value.lock,
|
||||
activating,
|
||||
{
|
||||
type: 'activation_committed',
|
||||
mutationId: 'edge-recovery-commit-1',
|
||||
occurredAtMs: 304,
|
||||
activationRef: `edge-active:${value.lock.lockDigest}`,
|
||||
intentDigest: runtime.pluginPackageActivationIntentDigest(
|
||||
value.lock,
|
||||
activating,
|
||||
),
|
||||
generation: 1,
|
||||
contentDigest: value.lock.source.contentDigest,
|
||||
},
|
||||
);
|
||||
await repository.commit(
|
||||
runtime.pluginPackageInstallCommit(activating, active),
|
||||
);
|
||||
return active;
|
||||
}
|
||||
|
||||
async function createStagedUpgrade(runtime, repository, value, active) {
|
||||
const queued = runtime.createPluginPackageInstall(value.lock, {
|
||||
installationId: 'edge-recovery-install-2',
|
||||
mutationId: 'edge-recovery-create-2',
|
||||
occurredAtMs: 401,
|
||||
});
|
||||
await repository.create(
|
||||
runtime.pluginPackageInstallCreate(value.lock, queued, active),
|
||||
);
|
||||
const staged = stage(runtime, value, queued, 402);
|
||||
await repository.commit(runtime.pluginPackageInstallCommit(queued, staged));
|
||||
return staged;
|
||||
}
|
||||
|
||||
function runtimeDependencies() {
|
||||
const pluginPackage = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackage');
|
||||
const install = require('../packages/ql3-runtime-core/dist/plugin-package/installation/pluginPackageInstall');
|
||||
const bundle = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackageBundle');
|
||||
const recovery = require('../packages/ql3-runtime-core/dist/plugin-package/installation/pluginPackageRecovery');
|
||||
const materialization = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceMaterialization');
|
||||
const generation = require('../packages/ql3-runtime-core/dist/plugin-package/pluginPackageResourceGeneration');
|
||||
const semantics = require('../packages/ql3-runtime-core/dist/task-definition/taskSpecSemantic');
|
||||
return {
|
||||
...pluginPackage,
|
||||
...install,
|
||||
...bundle,
|
||||
...recovery,
|
||||
...materialization,
|
||||
...generation,
|
||||
...semantics,
|
||||
};
|
||||
}
|
||||
|
||||
async function runBenchmark(options) {
|
||||
if (Number(process.versions.node.split('.')[0]) < 24) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
'Node.js 24 or newer is required',
|
||||
);
|
||||
}
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const runtime = runtimeDependencies();
|
||||
const {
|
||||
LocalSqlitePluginPackageInstallRepository,
|
||||
} = require('../packages/ql3-local-sqlite/dist/plugin-package/pluginPackageInstallRepository');
|
||||
const {
|
||||
LocalSqlitePluginPackageMaterializedRevisionRepository,
|
||||
} = require('../packages/ql3-local-sqlite/dist/plugin-package/pluginPackageMaterializedRevisionRepository');
|
||||
const {
|
||||
migrateLocalSqliteDatabase,
|
||||
} = require('../packages/ql3-local-sqlite/dist/migration/migration');
|
||||
const temporaryRoot = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-package-recovery-edge-'),
|
||||
);
|
||||
fs.chmodSync(temporaryRoot, 0o700);
|
||||
const databasePath = path.join(temporaryRoot, 'recovery.sqlite');
|
||||
let database;
|
||||
try {
|
||||
database = new DatabaseSync(databasePath, {
|
||||
allowExtension: false,
|
||||
allowUnknownNamedParameters: false,
|
||||
defensive: true,
|
||||
enableDoubleQuotedStringLiterals: false,
|
||||
enableForeignKeyConstraints: true,
|
||||
timeout: 1_000,
|
||||
});
|
||||
await migrateLocalSqliteDatabase(database);
|
||||
database.exec('PRAGMA journal_mode = DELETE; PRAGMA synchronous = FULL;');
|
||||
const baseline = storageSnapshot(databasePath);
|
||||
const repository = new LocalSqlitePluginPackageInstallRepository(database);
|
||||
const registry = runtime.createBuiltInTaskSpecSemanticRegistry();
|
||||
const materializedRepository =
|
||||
new LocalSqlitePluginPackageMaterializedRevisionRepository(
|
||||
database,
|
||||
registry,
|
||||
);
|
||||
const first = packageFixture(runtime, 1, null, false);
|
||||
const second = packageFixture(runtime, 2, first, true);
|
||||
const calls = {
|
||||
byteSourceClose: 0,
|
||||
byteSourceOpen: 0,
|
||||
byteSourceRead: 0,
|
||||
publisherInspect: 0,
|
||||
publisherPublish: 0,
|
||||
stage: 0,
|
||||
};
|
||||
const rssBeforeBytes = process.memoryUsage().rss;
|
||||
const startedAt = performance.now();
|
||||
const active = await createActiveGeneration(runtime, repository, first);
|
||||
const staged = await createStagedUpgrade(
|
||||
runtime,
|
||||
repository,
|
||||
second,
|
||||
active,
|
||||
);
|
||||
const prerequisite =
|
||||
new runtime.PluginPackageResourceActivationPrerequisite({
|
||||
byteSource: {
|
||||
async open(generation) {
|
||||
if (
|
||||
generation.lockDigest !== second.lock.lockDigest ||
|
||||
generation.installationId !== staged.installationId
|
||||
) {
|
||||
throw new Error('byte source received another generation');
|
||||
}
|
||||
calls.byteSourceOpen += 1;
|
||||
return {
|
||||
async read(resourcePath, maximumBytes) {
|
||||
calls.byteSourceRead += 1;
|
||||
const material =
|
||||
resourcePath === 'package.json'
|
||||
? second.manifestBytes
|
||||
: second.bytes[resourcePath];
|
||||
if (!material || material.byteLength > maximumBytes) {
|
||||
throw new Error('byte source path or bound is invalid');
|
||||
}
|
||||
return new Uint8Array(material);
|
||||
},
|
||||
async close() {
|
||||
calls.byteSourceClose += 1;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
materializedRepository,
|
||||
taskSpecSemanticRegistry: registry,
|
||||
});
|
||||
const coordinator = new runtime.PluginPackageRecoveryCoordinator({
|
||||
repository,
|
||||
stageProvider: {
|
||||
async stage() {
|
||||
calls.stage += 1;
|
||||
throw new Error('staged upgrade must not be staged again');
|
||||
},
|
||||
},
|
||||
publisher: {
|
||||
async publish() {
|
||||
calls.publisherPublish += 1;
|
||||
throw new Error('invalid candidate must not be published');
|
||||
},
|
||||
async inspect() {
|
||||
calls.publisherInspect += 1;
|
||||
throw new Error('invalid candidate must not be inspected');
|
||||
},
|
||||
},
|
||||
activationPrerequisite: prerequisite,
|
||||
now: () => 500,
|
||||
});
|
||||
const recovery = await coordinator.recover({ pageSize: 1, maxPages: 2 });
|
||||
const durationMs = performance.now() - startedAt;
|
||||
const rssAfterBytes = process.memoryUsage().rss;
|
||||
const durable = await repository.find(staged.projectId, staged.packageName);
|
||||
const candidateGeneration =
|
||||
runtime.createPluginPackageResourceGenerationFromReferences({
|
||||
installationId: staged.installationId,
|
||||
projectId: staged.projectId,
|
||||
packageName: staged.packageName,
|
||||
lockDigest: second.lock.lockDigest,
|
||||
generation: second.lock.targetGeneration,
|
||||
previousActiveLockDigest: first.lock.lockDigest,
|
||||
contentDigest: second.lock.source.contentDigest,
|
||||
resources: second.lock.resources,
|
||||
});
|
||||
const materializedCandidateRevisions = database
|
||||
.prepare(
|
||||
`SELECT count(*) AS count
|
||||
FROM "QingLong3PluginPackageMaterializedRevisions"
|
||||
WHERE generation_digest = ?`,
|
||||
)
|
||||
.get(candidateGeneration.generationDigest).count;
|
||||
const integrityCheck = database
|
||||
.prepare('PRAGMA integrity_check')
|
||||
.get().integrity_check;
|
||||
const finalStorage = storageSnapshot(databasePath);
|
||||
const logicalGrowthBytes = Math.max(
|
||||
0,
|
||||
finalStorage.logicalBytes - baseline.logicalBytes,
|
||||
);
|
||||
const allocatedGrowthBytes = Math.max(
|
||||
0,
|
||||
finalStorage.allocatedBytes - baseline.allocatedBytes,
|
||||
);
|
||||
const rssDeltaBytes = Math.max(0, rssAfterBytes - rssBeforeBytes);
|
||||
const violations = [];
|
||||
if (
|
||||
!durable ||
|
||||
durable.installationId !== staged.installationId ||
|
||||
durable.state !== 'failed' ||
|
||||
durable.failure?.reason !== 'activation_fact_conflict' ||
|
||||
durable.failure?.failedFrom !== 'staged'
|
||||
) {
|
||||
violations.push('candidate did not fail closed from staged');
|
||||
}
|
||||
if (
|
||||
durable?.previousActiveLockDigest !== first.lock.lockDigest ||
|
||||
durable?.activeLockDigest !== first.lock.lockDigest
|
||||
) {
|
||||
violations.push('previous active lock was not preserved');
|
||||
}
|
||||
if (
|
||||
calls.stage !== 0 ||
|
||||
calls.publisherPublish !== 0 ||
|
||||
calls.publisherInspect !== 0
|
||||
) {
|
||||
violations.push(
|
||||
'invalid candidate reached stage or activation publisher',
|
||||
);
|
||||
}
|
||||
if (
|
||||
calls.byteSourceOpen !== 1 ||
|
||||
calls.byteSourceRead !== 4 ||
|
||||
calls.byteSourceClose !== 1
|
||||
) {
|
||||
violations.push('candidate bytes were not read exactly once');
|
||||
}
|
||||
if (materializedCandidateRevisions !== 0) {
|
||||
violations.push('invalid candidate materialized revision was published');
|
||||
}
|
||||
if (integrityCheck !== 'ok') {
|
||||
violations.push(`SQLite integrity_check returned ${integrityCheck}`);
|
||||
}
|
||||
if (durationMs > options.maxDurationMs) {
|
||||
violations.push('duration exceeded the configured bound');
|
||||
}
|
||||
if (rssDeltaBytes > options.maxRssDeltaBytes) {
|
||||
violations.push('RSS delta exceeded the configured bound');
|
||||
}
|
||||
if (
|
||||
Math.max(logicalGrowthBytes, allocatedGrowthBytes) >
|
||||
options.maxDatabaseGrowthBytes
|
||||
) {
|
||||
violations.push('database growth exceeded the configured bound');
|
||||
}
|
||||
const report = Object.freeze({
|
||||
schemaVersion: 1,
|
||||
evidenceClass: 'plugin_package_failed_upgrade_edge_candidate',
|
||||
supported: false,
|
||||
identity: Object.freeze({
|
||||
node: process.version,
|
||||
architecture: process.arch,
|
||||
platform: process.platform,
|
||||
}),
|
||||
measurement: Object.freeze({
|
||||
durationMs: round(durationMs),
|
||||
rssBeforeBytes,
|
||||
rssAfterBytes,
|
||||
rssDeltaBytes,
|
||||
}),
|
||||
thresholds: Object.freeze({
|
||||
maxDurationMs: options.maxDurationMs,
|
||||
maxRssDeltaBytes: options.maxRssDeltaBytes,
|
||||
maxDatabaseGrowthBytes: options.maxDatabaseGrowthBytes,
|
||||
}),
|
||||
database: Object.freeze({
|
||||
journalMode: 'delete',
|
||||
synchronous: 'full',
|
||||
integrityCheck,
|
||||
logicalBytes: finalStorage.logicalBytes,
|
||||
allocatedBytes: finalStorage.allocatedBytes,
|
||||
logicalGrowthBytes,
|
||||
allocatedGrowthBytes,
|
||||
files: finalStorage.files,
|
||||
materializedCandidateRevisions,
|
||||
}),
|
||||
durable: Object.freeze({
|
||||
state: durable?.state ?? null,
|
||||
failureReason: durable?.failure?.reason ?? null,
|
||||
failedFrom: durable?.failure?.failedFrom ?? null,
|
||||
headInstallationIsCandidate:
|
||||
durable?.installationId === staged.installationId,
|
||||
previousActiveLockDigestPreserved:
|
||||
durable?.previousActiveLockDigest === first.lock.lockDigest,
|
||||
activeLockDigestPreserved:
|
||||
durable?.activeLockDigest === first.lock.lockDigest,
|
||||
}),
|
||||
recovery: Object.freeze(recovery),
|
||||
calls: Object.freeze(calls),
|
||||
limitations: EXPECTED_LIMITATIONS,
|
||||
gates: Object.freeze({
|
||||
passed: violations.length === 0,
|
||||
violations: Object.freeze(violations),
|
||||
}),
|
||||
});
|
||||
const reportViolations = validateReport(report);
|
||||
if (reportViolations.length > 0) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
`report contract rejected: ${reportViolations.join('; ')}`,
|
||||
);
|
||||
}
|
||||
if (!report.gates.passed) {
|
||||
throw new QingLong3PluginPackageRecoveryEdgeBenchmarkError(
|
||||
report.gates.violations.join('; '),
|
||||
);
|
||||
}
|
||||
return report;
|
||||
} finally {
|
||||
database?.close();
|
||||
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function validateReport(report) {
|
||||
const violations = [];
|
||||
if (!hasExactKeys(report, REPORT_KEYS)) {
|
||||
return Object.freeze(['report shape is invalid']);
|
||||
}
|
||||
if (
|
||||
report.schemaVersion !== 1 ||
|
||||
report.evidenceClass !== 'plugin_package_failed_upgrade_edge_candidate' ||
|
||||
report.supported !== false
|
||||
) {
|
||||
violations.push('report identity is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.identity, IDENTITY_KEYS) ||
|
||||
typeof report.identity.node !== 'string' ||
|
||||
!['arm64', 'x64', 'arm'].includes(report.identity.architecture) ||
|
||||
typeof report.identity.platform !== 'string'
|
||||
) {
|
||||
violations.push('runtime identity is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.measurement, MEASUREMENT_KEYS) ||
|
||||
!Number.isFinite(report.measurement.durationMs) ||
|
||||
report.measurement.durationMs < 0 ||
|
||||
!Number.isSafeInteger(report.measurement.rssBeforeBytes) ||
|
||||
report.measurement.rssBeforeBytes < 1 ||
|
||||
!Number.isSafeInteger(report.measurement.rssAfterBytes) ||
|
||||
report.measurement.rssAfterBytes < 1 ||
|
||||
!Number.isSafeInteger(report.measurement.rssDeltaBytes) ||
|
||||
report.measurement.rssDeltaBytes < 0
|
||||
) {
|
||||
violations.push('measurement is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.thresholds, THRESHOLD_KEYS) ||
|
||||
!Number.isFinite(report.thresholds.maxDurationMs) ||
|
||||
report.thresholds.maxDurationMs <= 0 ||
|
||||
!Number.isSafeInteger(report.thresholds.maxRssDeltaBytes) ||
|
||||
report.thresholds.maxRssDeltaBytes < 1 ||
|
||||
!Number.isSafeInteger(report.thresholds.maxDatabaseGrowthBytes) ||
|
||||
report.thresholds.maxDatabaseGrowthBytes < 1
|
||||
) {
|
||||
violations.push('thresholds are invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.database, DATABASE_KEYS) ||
|
||||
report.database.journalMode !== 'delete' ||
|
||||
report.database.synchronous !== 'full' ||
|
||||
report.database.integrityCheck !== 'ok' ||
|
||||
report.database.materializedCandidateRevisions !== 0 ||
|
||||
![
|
||||
'logicalBytes',
|
||||
'allocatedBytes',
|
||||
'logicalGrowthBytes',
|
||||
'allocatedGrowthBytes',
|
||||
].every(
|
||||
(key) =>
|
||||
Number.isSafeInteger(report.database[key]) && report.database[key] >= 0,
|
||||
) ||
|
||||
!Array.isArray(report.database.files) ||
|
||||
report.database.files.length < 1 ||
|
||||
report.database.files.length > 4 ||
|
||||
report.database.files.some(
|
||||
(file) =>
|
||||
!hasExactKeys(file, DATABASE_FILE_KEYS) ||
|
||||
!['database', '-journal', '-wal', '-shm'].includes(file.suffix) ||
|
||||
!Number.isSafeInteger(file.logicalBytes) ||
|
||||
file.logicalBytes < 0 ||
|
||||
!Number.isSafeInteger(file.allocatedBytes) ||
|
||||
file.allocatedBytes < 0,
|
||||
)
|
||||
) {
|
||||
violations.push('database evidence is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.durable, DURABLE_KEYS) ||
|
||||
report.durable.state !== 'failed' ||
|
||||
report.durable.failureReason !== 'activation_fact_conflict' ||
|
||||
report.durable.failedFrom !== 'staged' ||
|
||||
report.durable.headInstallationIsCandidate !== true ||
|
||||
report.durable.previousActiveLockDigestPreserved !== true ||
|
||||
report.durable.activeLockDigestPreserved !== true
|
||||
) {
|
||||
violations.push('durable recovery facts are invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.recovery, RECOVERY_KEYS) ||
|
||||
report.recovery.pages !== 1 ||
|
||||
report.recovery.scanned !== 1 ||
|
||||
report.recovery.settled !== 1 ||
|
||||
report.recovery.retry !== 0 ||
|
||||
report.recovery.manualRequired !== 0 ||
|
||||
report.recovery.superseded !== 0 ||
|
||||
report.recovery.deferred !== 0 ||
|
||||
report.recovery.remaining !== false ||
|
||||
report.recovery.safeToAdmit !== true
|
||||
) {
|
||||
violations.push('recovery outcome is invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.calls, CALL_KEYS) ||
|
||||
report.calls.stage !== 0 ||
|
||||
report.calls.publisherPublish !== 0 ||
|
||||
report.calls.publisherInspect !== 0 ||
|
||||
report.calls.byteSourceOpen !== 1 ||
|
||||
report.calls.byteSourceRead !== 4 ||
|
||||
report.calls.byteSourceClose !== 1
|
||||
) {
|
||||
violations.push('authority call evidence is invalid');
|
||||
}
|
||||
if (
|
||||
JSON.stringify(report.limitations) !== JSON.stringify(EXPECTED_LIMITATIONS)
|
||||
) {
|
||||
violations.push('limitations are invalid');
|
||||
}
|
||||
if (
|
||||
!hasExactKeys(report.gates, GATE_KEYS) ||
|
||||
report.gates.passed !== true ||
|
||||
!Array.isArray(report.gates.violations) ||
|
||||
report.gates.violations.length !== 0
|
||||
) {
|
||||
violations.push('gates did not pass exactly');
|
||||
}
|
||||
if (
|
||||
report.measurement?.durationMs > report.thresholds?.maxDurationMs ||
|
||||
report.measurement?.rssDeltaBytes > report.thresholds?.maxRssDeltaBytes ||
|
||||
Math.max(
|
||||
report.database?.logicalGrowthBytes ?? Number.POSITIVE_INFINITY,
|
||||
report.database?.allocatedGrowthBytes ?? Number.POSITIVE_INFINITY,
|
||||
) > report.thresholds?.maxDatabaseGrowthBytes
|
||||
) {
|
||||
violations.push('measurement exceeded its declared threshold');
|
||||
}
|
||||
return Object.freeze(violations);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArguments(process.argv.slice(2));
|
||||
const report = await runBenchmark(options);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(report, null, options.json ? 0 : 2)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QingLong3PluginPackageRecoveryEdgeBenchmarkError,
|
||||
parseArguments,
|
||||
runBenchmark,
|
||||
validateReport,
|
||||
};
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -117,6 +117,7 @@ test('builds tier-specific workload plans without shell commands', () => {
|
||||
'local-workflow-sqlite-lock',
|
||||
'local-workflow-admission-crash-recovery',
|
||||
'local-workflow-control-crash-recovery',
|
||||
'plugin-package-failed-upgrade',
|
||||
],
|
||||
);
|
||||
assert.match(edge[0].script, /ql3-edge-benchmark\.cjs$/);
|
||||
@@ -135,10 +136,7 @@ test('builds tier-specific workload plans without shell commands', () => {
|
||||
),
|
||||
true,
|
||||
);
|
||||
const edgeRelease = createWorkloadPlans(
|
||||
'/workspace',
|
||||
'edge-release-ci',
|
||||
);
|
||||
const edgeRelease = createWorkloadPlans('/workspace', 'edge-release-ci');
|
||||
assert.deepEqual(
|
||||
edgeRelease.map(({ name }) => name),
|
||||
[
|
||||
@@ -152,6 +150,7 @@ test('builds tier-specific workload plans without shell commands', () => {
|
||||
'local-workflow-control-crash-recovery',
|
||||
'local-ai-prompt-model-invocation-crash-recovery',
|
||||
'local-ai-prompt-outer-transaction-crash-recovery',
|
||||
'plugin-package-failed-upgrade',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
@@ -185,17 +184,11 @@ test('builds tier-specific workload plans without shell commands', () => {
|
||||
assert.equal(edgeRelease[3].contract.exactReplay, true);
|
||||
assert.equal(edgeRelease[3].contract.contentFree, true);
|
||||
assert.equal(edgeRelease[3].contract.durableOutputBytes, 512 * 1024);
|
||||
assert.equal(
|
||||
edgeRelease[3].contract.maxWalWriteAmplificationPermille,
|
||||
0,
|
||||
);
|
||||
assert.equal(edgeRelease[3].contract.maxWalWriteAmplificationPermille, 0);
|
||||
assert.equal(edgeRelease[4].contract.profile, 'standalone');
|
||||
assert.equal(edgeRelease[4].contract.journalMode, 'wal');
|
||||
assert.equal(edgeRelease[4].contract.requireWalGrowth, true);
|
||||
assert.match(
|
||||
edge[3].script,
|
||||
/ql3-local-workflow-resource-benchmark\.cjs$/,
|
||||
);
|
||||
assert.match(edge[3].script, /ql3-local-workflow-resource-benchmark\.cjs$/);
|
||||
assert.ok(edge[3].args.includes('--lock-samples=16'));
|
||||
assert.ok(edge[3].args.includes('--max-lock-p95-ms=500'));
|
||||
assert.equal(edge[4].contract.scenarios, 16);
|
||||
@@ -221,6 +214,12 @@ test('builds tier-specific workload plans without shell commands', () => {
|
||||
true,
|
||||
);
|
||||
assert.equal(edgeRelease[9].contract.physicalPowerLossProven, false);
|
||||
assert.match(
|
||||
edge.at(-1).script,
|
||||
/ql3-plugin-package-recovery-edge-benchmark\.cjs$/,
|
||||
);
|
||||
assert.ok(edge.at(-1).args.includes('--max-rss-delta-mb=64'));
|
||||
assert.ok(edgeRelease.at(-1).args.includes('--max-rss-delta-mb=96'));
|
||||
const cluster = createWorkloadPlans('/workspace', 'cluster-control-ci');
|
||||
assert.deepEqual(
|
||||
cluster.map(({ name }) => name),
|
||||
@@ -248,7 +247,9 @@ test('fails closed when durable Prompt resource evidence drifts', () => {
|
||||
const [edgePlan, standalonePlan] = createWorkloadPlans(
|
||||
'/workspace',
|
||||
'edge-release-ci',
|
||||
).filter(({ contract }) => contract?.kind === 'durable_prompt_output_resource');
|
||||
).filter(
|
||||
({ contract }) => contract?.kind === 'durable_prompt_output_resource',
|
||||
);
|
||||
const evidence = {
|
||||
profile: 'edge',
|
||||
journalMode: 'delete',
|
||||
@@ -268,7 +269,9 @@ test('fails closed when durable Prompt resource evidence drifts', () => {
|
||||
physicalPowerLossProven: false,
|
||||
};
|
||||
const output = (value) =>
|
||||
`tests 1\npass 1\nfail 0\nskipped 0\nQL3_RESOURCE_EVIDENCE=${JSON.stringify(value)}\n`;
|
||||
`tests 1\npass 1\nfail 0\nskipped 0\nQL3_RESOURCE_EVIDENCE=${JSON.stringify(
|
||||
value,
|
||||
)}\n`;
|
||||
assert.equal(
|
||||
parseNodeTestReport(output(evidence), edgePlan).evidence.profile,
|
||||
'edge',
|
||||
@@ -301,12 +304,8 @@ test('fails closed when durable Prompt resource evidence drifts', () => {
|
||||
});
|
||||
|
||||
test('fails closed when authenticated Workflow cancellation evidence drifts', () => {
|
||||
const plan = createWorkloadPlans(
|
||||
'/workspace',
|
||||
'edge-release-ci',
|
||||
).find(
|
||||
({ contract }) =>
|
||||
contract?.kind === 'local_workflow_product_lifecycle',
|
||||
const plan = createWorkloadPlans('/workspace', 'edge-release-ci').find(
|
||||
({ contract }) => contract?.kind === 'local_workflow_product_lifecycle',
|
||||
);
|
||||
assert.ok(plan);
|
||||
const completionEvidence = {
|
||||
@@ -332,10 +331,12 @@ test('fails closed when authenticated Workflow cancellation evidence drifts', ()
|
||||
physicalPowerLossProven: false,
|
||||
};
|
||||
const output = (value) =>
|
||||
`tests 2\npass 2\nfail 0\nskipped 0\nQL3_RESOURCE_EVIDENCE=${JSON.stringify(completionEvidence)}\nQL3_RESOURCE_EVIDENCE=${JSON.stringify(value)}\n`;
|
||||
`tests 2\npass 2\nfail 0\nskipped 0\nQL3_RESOURCE_EVIDENCE=${JSON.stringify(
|
||||
completionEvidence,
|
||||
)}\nQL3_RESOURCE_EVIDENCE=${JSON.stringify(value)}\n`;
|
||||
assert.equal(
|
||||
parseNodeTestReport(output(cancellationEvidence), plan)
|
||||
.evidenceRecords[1].processExited,
|
||||
parseNodeTestReport(output(cancellationEvidence), plan).evidenceRecords[1]
|
||||
.processExited,
|
||||
true,
|
||||
);
|
||||
assert.throws(
|
||||
|
||||
@@ -24,6 +24,9 @@ const {
|
||||
validateObservedPlatform,
|
||||
writeNoReplace,
|
||||
} = require('../../scripts/ql3-physical-edge-evidence.cjs');
|
||||
const {
|
||||
runBenchmark: runPluginPackageRecoveryEdgeBenchmark,
|
||||
} = require('../../scripts/ql3-plugin-package-recovery-edge-benchmark.cjs');
|
||||
const {
|
||||
buildReport: buildAdoptionScaleReport,
|
||||
normalizeManifest: normalizeAdoptionScaleManifest,
|
||||
@@ -168,15 +171,49 @@ test('fails closed on architecture, memory, filesystem and virtualization drift'
|
||||
);
|
||||
});
|
||||
|
||||
test('produces digest-bound candidate evidence but never supported status', () => {
|
||||
test('produces digest-bound candidate evidence but never supported status', async () => {
|
||||
const pluginPackageRecovery = await runPluginPackageRecoveryEdgeBenchmark({
|
||||
maxDatabaseGrowthBytes: 4 * 1024 * 1024,
|
||||
maxDurationMs: 10_000,
|
||||
maxRssDeltaBytes: 96 * 1024 * 1024,
|
||||
});
|
||||
const workloads = [
|
||||
{
|
||||
name: 'edge-executor',
|
||||
report: {
|
||||
schemaVersion: 1,
|
||||
profile: 'edge',
|
||||
gates: { passed: true, violations: [] },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'node-sqlite-on-device-storage',
|
||||
report: {
|
||||
journalMode: 'delete',
|
||||
synchronous: 'full',
|
||||
integrityCheck: 'ok',
|
||||
databaseBytes: 16_384,
|
||||
rssDeltaMb: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'plugin-package-failed-upgrade',
|
||||
report: pluginPackageRecovery,
|
||||
},
|
||||
];
|
||||
const report = buildEvidenceReport({
|
||||
manifest: manifest(),
|
||||
observed: observed(),
|
||||
workloads: [{ name: 'edge-executor', report: { gates: { passed: true } } }],
|
||||
workloads,
|
||||
generatedAt: '2026-07-22T00:00:00.000Z',
|
||||
});
|
||||
assert.equal(report.supported, false);
|
||||
assert.equal(report.qualification.physicalCandidate, true);
|
||||
assert.ok(
|
||||
report.qualification.collectedEvidence.includes(
|
||||
'plugin_package_failed_upgrade_retains_active_generation',
|
||||
),
|
||||
);
|
||||
assert.equal(report.sha256.length, 64);
|
||||
const changed = buildEvidenceReport({
|
||||
manifest: manifest({ deviceId: 'router-a2' }),
|
||||
@@ -184,6 +221,12 @@ test('produces digest-bound candidate evidence but never supported status', () =
|
||||
workloads: [],
|
||||
generatedAt: '2026-07-22T00:00:00.000Z',
|
||||
});
|
||||
assert.equal(changed.qualification.physicalCandidate, false);
|
||||
assert.ok(
|
||||
changed.qualification.remainingRequiredEvidence.includes(
|
||||
'plugin_package_failed_upgrade_retains_active_generation',
|
||||
),
|
||||
);
|
||||
assert.notEqual(changed.sha256, report.sha256);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const {
|
||||
parseArguments,
|
||||
runBenchmark,
|
||||
validateReport,
|
||||
} = require('../../scripts/ql3-plugin-package-recovery-edge-benchmark.cjs');
|
||||
|
||||
test('parses bounded failed-upgrade benchmark thresholds', () => {
|
||||
assert.deepEqual(
|
||||
parseArguments([
|
||||
'--json',
|
||||
'--max-duration-ms=5000',
|
||||
'--max-rss-delta-mb=64',
|
||||
'--max-database-growth-bytes=2097152',
|
||||
]),
|
||||
{
|
||||
json: true,
|
||||
maxDatabaseGrowthBytes: 2 * 1024 * 1024,
|
||||
maxDurationMs: 5_000,
|
||||
maxRssDeltaBytes: 64 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArguments(['--max-rss-delta-mb=0']),
|
||||
/positive number/,
|
||||
);
|
||||
assert.throws(() => parseArguments(['--unknown=1']), /unsupported argument/);
|
||||
});
|
||||
|
||||
test('uses real SQLite recovery to reject an invalid upgrade before publication', async () => {
|
||||
const report = await runBenchmark({
|
||||
maxDatabaseGrowthBytes: 4 * 1024 * 1024,
|
||||
maxDurationMs: 10_000,
|
||||
maxRssDeltaBytes: 96 * 1024 * 1024,
|
||||
});
|
||||
|
||||
assert.deepEqual(validateReport(report), []);
|
||||
assert.equal(report.durable.state, 'failed');
|
||||
assert.equal(report.durable.activeLockDigestPreserved, true);
|
||||
assert.equal(report.database.materializedCandidateRevisions, 0);
|
||||
assert.equal(report.calls.publisherPublish, 0);
|
||||
assert.equal(report.gates.passed, true);
|
||||
|
||||
assert.deepEqual(validateReport({ ...report, supported: true }), [
|
||||
'report identity is invalid',
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user