docs: add shadow v3 field operations package
This commit is contained in:
@@ -1,166 +1,89 @@
|
||||
# FT546WMS 现场影子测试操作手册
|
||||
# FT546WMS 影子 v3 现场操作手册
|
||||
|
||||
## 1. 适用范围
|
||||
## 1. 这次部署做什么
|
||||
|
||||
本手册用于分支 `test/ft546wms-shadow-from-main-20260811` 的现场测试。该分支基于远端稳定版本:
|
||||
目标分支:`test/ft546wms-shadow-v3-20260903`。
|
||||
|
||||
```text
|
||||
origin/main = 916ac6bda32a5b102a3181cc8c5dbd5319c5656d
|
||||
影子 v3 只观察算法建议,不改变 Legacy 分配结果。入库、自动出库、手动出库仍由原存储过程执行一次,原返回码、返回消息、库位和异常保持不变。正常业务请求只做固定大小的内存登记,不等待影子线程,也不在调用线程执行影子数据库、文件或同步日志操作。
|
||||
|
||||
影子后台会读取业务表,并且只写 `alg.*` 对象。它可能增加数据库 CPU 和 I/O,因此仍需低峰部署、监控和随时回滚。
|
||||
|
||||
## 2. 固定安全边界
|
||||
|
||||
- 现场只允许一个 FT546WMS JVM,稳定版和影子版不能同时运行。
|
||||
- 仓库固定为 `01`;原始数据中的 `1` 与 `01` 等价,`W02` 只用于诊断,不能覆盖配置。
|
||||
- SQL Server 必须使用 Windows 集成认证;脚本不接收用户名和密码。
|
||||
- 当前现场已有 v2 包,安装必须使用 `upgrade`,不能使用 `clean`。
|
||||
- v2 到 v3 升级保留 `alg.decision_log`;发现未知 `alg` 对象、清单不完整或 manifest 不匹配时脚本会停止。
|
||||
- 影子失败、过期、并发、超时、配置错误或输入缺失均 fail-open:Legacy 继续,影子样本作废。
|
||||
- 不执行正式 ACTIVE 切换,不增加 WCS/PDA/理库/预留/回放功能。
|
||||
|
||||
## 3. 部署前准备
|
||||
|
||||
现场负责人先记录:
|
||||
|
||||
- SQL Server 名称和数据库名;
|
||||
- 当前 v2 测试开始时间和时区;
|
||||
- IDEA 当前 Run Configuration;
|
||||
- 当前稳定实例 PID 和端口;
|
||||
- 本次交付给出的精确 v3 提交号 `<V3_COMMIT>`;
|
||||
- 应用日志目录。
|
||||
|
||||
部署前先导出现有 v2 证据。不要卸载 v2:
|
||||
|
||||
```cmd
|
||||
cd /d C:\project\FT546WMS-SHANDOW
|
||||
call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "<SQL_SERVER>" "<DATABASE>" "<V2_START_UTC>" "C:\project\FT546WMS-v2-before-v3" "<LOG_DIR>"
|
||||
```
|
||||
|
||||
影子算法只提出候选结果并记录比较数据。原有 Legacy 存储过程仍执行一次且仅执行一次,Legacy 的库位分配、返回码和响应内容始终是最终结果。
|
||||
如果当前代码还没有 v3 导出脚本,先用既有导出方式保存 `alg.decision_log`、诊断结果、日志、分支和提交号,再继续。
|
||||
|
||||
影子模式仍会读取 Legacy 表,并会在 `alg` schema 中刷新读模型和写入决策日志。因此“只观察”表示不改变 Legacy 业务数据和决策权,不表示完全没有数据库 CPU、I/O 或执行时间开销。
|
||||
## 4. 按顺序部署 v3
|
||||
|
||||
## 2. 安全边界
|
||||
### 第一步:停止 IDEA 中的 v2 稳定实例
|
||||
|
||||
- 默认配置 `FORTUCKY_SHADOW_ENABLED=false`,未显式开启时不执行影子规划。
|
||||
- 启用时只允许 `FORTUCKY_ALGORITHM_MODE=SHADOW`;误设 `ACTIVE` 或 `LEGACY` 会导致应用拒绝启动。
|
||||
- 影子事务使用 `REQUIRES_NEW`、`READ_UNCOMMITTED` 和 3 秒超时,并且只在名为 `ft546-shadow-observer` 的 daemon 工作线程执行。
|
||||
- 专用执行器只有一个线程、队列容量为 0,拒绝策略不会回落到 Legacy 请求线程;忙时其他观察立即跳过。
|
||||
- Legacy 线程仅提交任务和通知内存结果,不等待影子规划或比较,也不执行任何影子数据库操作。
|
||||
- 影子异常、超时、输入缺失和比较日志写入失败都会 fail-open,不跳过 Legacy 流程。
|
||||
- 安装包不在 `dbo.bas_cargo_space`、`dbo.bas_stock`、`dbo.mydo_job` 或其他 Legacy 表上创建触发器。
|
||||
- 安装包不对 Legacy 业务表执行 `INSERT`、`UPDATE`、`DELETE` 或 `MERGE`。
|
||||
- 影子模式不调用预留、深位锁、理库、回放、移库完成、WCS 完成或 PDA 回调写入过程。
|
||||
- `alg.reservation` 和 `alg.deep_workflow_lock` 是规划器读取兼容表;影子包不安装修改它们的过程,SHADOW 模式也禁止进入这些写入口。
|
||||
记录停止时间,在 IDEA 点 Stop。用下面命令确认最多只有一个相关 JVM,最好为 0:
|
||||
|
||||
## 3. 交付文件
|
||||
|
||||
```text
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql
|
||||
```cmd
|
||||
jps -lv | findstr /I "RuoYiApplication FT546WMS"
|
||||
```
|
||||
|
||||
只使用名称带 `-shadow` 的脚本。该分支已移除原算法通用的 `install.sql`、`diagnostics.sql` 和 `uninstall.sql`,避免现场误装 ACTIVE 数据库对象。
|
||||
停止和重新启动之间会有一次短暂停机,这是计划内中断。不要同时启动两个实例来消除停机,否则可能重复执行定时任务、消费或外部回调。
|
||||
|
||||
## 4. 上现场前必须完成的演练
|
||||
### 第二步:同步精确分支和提交
|
||||
|
||||
1. 在现场数据库的最近备份副本上启动远端 `origin/main`,完成一次入库和一次出库基线测试。
|
||||
2. 在备份副本执行 `install-shadow.sql`,确认脚本全部成功。
|
||||
3. 用影子分支完成同样的入库和出库,确认业务结果仍来自 Legacy。
|
||||
4. 导出 `alg.decision_log`,然后停止影子程序并执行 `uninstall-shadow.sql`。
|
||||
5. 再次启动远端 `origin/main`,重复基线测试。
|
||||
|
||||
未完成数据库副本演练,不要直接在现场生产库安装。
|
||||
|
||||
## 5. 准备两个独立 IDEA 工作区
|
||||
|
||||
当前开发机的本地 `main` 已与 `origin/main` 分叉,不能作为稳定版回滚源。先执行 `git fetch origin`,确认 `origin/main` 的提交号,再从远端提交准备只读稳定工作区。
|
||||
|
||||
在仓库主目录执行。目标目录必须不存在;如果已存在,先人工确认它的来源,不要覆盖或删除:
|
||||
|
||||
```powershell
|
||||
```cmd
|
||||
cd /d C:\project\FT546WMS-SHANDOW
|
||||
git fetch origin
|
||||
$ExpectedStableCommit = "916ac6bda32a5b102a3181cc8c5dbd5319c5656d"
|
||||
$RemoteMainCommit = git rev-parse origin/main
|
||||
if ($RemoteMainCommit -ne $ExpectedStableCommit) { throw "origin/main 已变化,停止部署并重新合并验证" }
|
||||
|
||||
$StableWorktree = "D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\stable-origin-main-20260811"
|
||||
if (Test-Path -LiteralPath $StableWorktree) { throw "稳定版 worktree 目标目录已存在,请人工核对" }
|
||||
git worktree add --detach $StableWorktree origin/main
|
||||
|
||||
$CheckedOutCommit = git -C $StableWorktree rev-parse HEAD
|
||||
if ($CheckedOutCommit -ne $ExpectedStableCommit) { throw "稳定版 worktree 提交校验失败" }
|
||||
if (git -C $StableWorktree status --porcelain) { throw "稳定版 worktree 不是干净状态" }
|
||||
git switch test/ft546wms-shadow-v3-20260903
|
||||
git pull --ff-only
|
||||
git rev-parse HEAD
|
||||
git status --porcelain
|
||||
```
|
||||
|
||||
两个 IDEA 窗口分别打开:
|
||||
`git rev-parse HEAD` 必须等于交付的 `<V3_COMMIT>`,`git status --porcelain` 必须没有输出。若不一致,停止部署。
|
||||
|
||||
```text
|
||||
稳定版:D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\stable-origin-main-20260811
|
||||
影子版:D:\workSpace\PROJECT_SPACE\FT546\FT546MWS\worktrees\shadow-from-main-20260811
|
||||
### 第三步:执行只读预检
|
||||
|
||||
```cmd
|
||||
call scripts\shadow-v3-preflight.cmd C:\project\FT546WMS-SHANDOW <V3_COMMIT> "<SQL_SERVER>" "<DATABASE>"
|
||||
```
|
||||
|
||||
为两个窗口分别创建 Run Configuration:
|
||||
只有看到 `PREFLIGHT_OK` 才能继续。预检检查分支、精确提交、干净工作树、main 祖先、JVM 数量、SQL 连接、v2/v3 manifest 和当前快照;它不修改数据库。
|
||||
|
||||
- `FT546WMS-STABLE-origin-main`:完全沿用当前稳定现场参数,不增加任何 `FORTUCKY_*` 环境变量。
|
||||
- `FT546WMS-SHADOW`:复制稳定配置的 JDK、profile、端口、数据库和 Redis 参数,只增加下一节列出的环境变量。
|
||||
### 第四步:升级数据库包
|
||||
|
||||
禁止同时运行两个实例。即使使用不同 HTTP 端口,两个 WMS 实例也可能重复执行定时任务、消费任务或发送外部回调。切换时必须先停止当前实例,确认进程退出和端口释放,再启动另一个实例。
|
||||
当前现场是 v2,只执行:
|
||||
|
||||
## 6. 安装影子数据库包
|
||||
|
||||
### 6.1 安装前检查
|
||||
|
||||
由 DBA 对现场数据库做可恢复的完整备份,并记录备份文件、时间和恢复负责人。随后执行:
|
||||
|
||||
```sql
|
||||
SELECT SCHEMA_ID(N'alg') AS alg_schema_id;
|
||||
|
||||
SELECT trigger_object.name AS trigger_name,
|
||||
schema_name(trigger_object.schema_id) AS trigger_schema,
|
||||
schema_name(parent_object.schema_id) AS parent_schema,
|
||||
parent_object.name AS parent_table
|
||||
FROM sys.triggers trigger_object
|
||||
JOIN sys.objects parent_object ON parent_object.object_id = trigger_object.parent_id
|
||||
WHERE trigger_object.is_ms_shipped = 0
|
||||
ORDER BY parent_schema, parent_object.name, trigger_schema, trigger_object.name;
|
||||
|
||||
SELECT schema_name(object.schema_id) AS schema_name,
|
||||
object.name AS object_name,
|
||||
object.type_desc
|
||||
FROM sys.objects object
|
||||
WHERE object.is_ms_shipped = 0
|
||||
ORDER BY schema_name(object.schema_id), object.type_desc, object.name;
|
||||
```cmd
|
||||
call scripts\shadow-v3-install.cmd "<SQL_SERVER>" "<DATABASE>" upgrade
|
||||
```
|
||||
|
||||
预期 `alg_schema_id` 为 `NULL`。安装脚本发现任何既有 `alg` schema 会以错误 51000 拒绝执行。如果现场已经存在 `alg` schema 或同名对象,停止安装并先确认所有权;不要覆盖,也不要执行本分支卸载脚本。
|
||||
只有全新测试数据库、确认没有 `alg` schema 时才允许使用 `clean`。现场生产库不要使用 `clean`。
|
||||
|
||||
把两份查询结果连同执行时间保存为安装前基线。现场可能已有 Legacy 自身的触发器,安装后的全库 DML 触发器集合必须与安装前完全一致;用户对象清单只能新增本包定义的 `alg.*` 对象。
|
||||
### 第五步:设置 IDEA 环境变量
|
||||
|
||||
### 6.2 执行安装
|
||||
|
||||
在低业务量时段使用 SQL Server Management Studio 打开并执行:
|
||||
|
||||
```text
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql
|
||||
```
|
||||
|
||||
脚本使用 `GO` 分批执行并在当前会话设置 `READ_UNCOMMITTED`。首次安装会全量建立读模型,可能扫描较多现场数据;如果副本演练时间超出可接受窗口,现场不要继续。
|
||||
|
||||
也可以使用已配置 Windows 集成认证的 `sqlcmd`:
|
||||
|
||||
```powershell
|
||||
sqlcmd -S "<SQL Server>" -d "<数据库>" -E -b -r1 -i "<仓库>\fortucky-algorithm\src\main\resources\db\sqlserver\install-shadow.sql"
|
||||
```
|
||||
|
||||
不要把数据库密码写进脚本、IDEA Run Configuration 截图或命令历史。
|
||||
|
||||
### 6.3 安装后检查
|
||||
|
||||
重新执行安装前的两份元数据查询。全库 DML 触发器结果必须完全一致,用户对象清单只能新增预期的 `alg.*` 对象。保存安装后结果和执行时间,再执行:
|
||||
|
||||
```sql
|
||||
SELECT schema_name(object.schema_id) AS schema_name,
|
||||
object.name,
|
||||
object.type_desc
|
||||
FROM sys.objects object
|
||||
WHERE schema_name(object.schema_id) = N'alg'
|
||||
ORDER BY object.type_desc, object.name;
|
||||
|
||||
SELECT warehouse_id,
|
||||
COUNT(*) AS lane_count,
|
||||
MAX(rebuilt_at) AS latest_rebuild_at
|
||||
FROM alg.lane_state
|
||||
GROUP BY warehouse_id;
|
||||
```
|
||||
|
||||
本次新增对象只能位于 `alg` schema;`lane_state` 应存在目标仓库的数据。如果安装部分失败,不要启动影子程序,也不要执行本分支卸载脚本:拒绝式卸载会因对象缺失而报错 51014。保留完整输出和安装前后对象清单,由 DBA 决定从备份恢复,或依据清单人工清理本次未完成安装。不要补写 manifest、补建空对象或修改卸载脚本来绕过保护;清理完成且 `SCHEMA_ID(N'alg')` 再次为 `NULL` 后,才能重新完整安装。
|
||||
|
||||
确认包标记唯一且版本准确:
|
||||
|
||||
```sql
|
||||
SELECT package_id, package_version, installed_at
|
||||
FROM alg.shadow_package_manifest;
|
||||
```
|
||||
|
||||
必须只有一行:`FT546WMS_SHADOW_ONLY` / `2026-08-12-1`。不一致时不要启动影子程序,也不要手工修改标记。
|
||||
|
||||
## 7. IDEA 启用影子模式
|
||||
|
||||
在 `FT546WMS-SHADOW` 的 Environment variables 中设置:
|
||||
在影子 Run Configuration 中使用:
|
||||
|
||||
```text
|
||||
FORTUCKY_SHADOW_ENABLED=true
|
||||
@@ -168,137 +91,101 @@ FORTUCKY_ALGORITHM_MODE=SHADOW
|
||||
FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED=true
|
||||
FORTUCKY_ALGORITHM_WAREHOUSE_ID=01
|
||||
FORTUCKY_SHADOW_TIMEOUT_SECONDS=3
|
||||
FORTUCKY_ALGORITHM_LEGACY_FALLBACK_ON_TECHNICAL_ERROR=false
|
||||
FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60
|
||||
FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000
|
||||
```
|
||||
|
||||
将 `01` 替换为现场实际仓库号。保持 `FORTUCKY_SHADOW_WAREHOUSE_ID` 未设置,使影子入口统一回退到 `FORTUCKY_ALGORITHM_WAREHOUSE_ID`。不要设置 replay、tidy、ACTIVE 或 WCS callback 相关变量。
|
||||
不要设置 `ACTIVE`,不要添加数据库密码,不要设置 replay、tidy、reservation 或 WCS callback 开关。
|
||||
|
||||
`FORTUCKY_ALGORITHM_DATABASE_WRITES_ENABLED=true` 是影子读模型与 `alg.decision_log` 所必需的应用开关;当前影子代码路径只执行已安装的 `alg.*` 写入,它不把 Legacy 决策权交给算法。该开关不是数据库权限隔离,安全性还依赖本分支的 SHADOW 启动保护、删减后的 SQL 包和现场变更审计。
|
||||
### 第六步:只启动一次影子实例
|
||||
|
||||
切换步骤:
|
||||
在 IDEA 启动 v3。不要重复点击 Run。启动时后台会认领唯一实例并建立第一代快照;认领或刷新失败只会关闭影子可用性,不应阻止 Legacy 启动。
|
||||
|
||||
1. 记录稳定实例停止时间,停止 `FT546WMS-STABLE-origin-main`。
|
||||
2. 确认 Java 进程退出、端口释放,且没有第二个 WMS 实例运行。
|
||||
3. 启动 `FT546WMS-SHADOW`。
|
||||
4. 如果出现 `Shadow observation requires ...`,不要修改保护条件,直接回滚到稳定实例。
|
||||
5. 先做只读登录和查询冒烟检查,再由现场人员执行一笔受控入库和一笔受控出库。
|
||||
6. 确认 Legacy 返回码、库位和现场设备动作与稳定流程一致。
|
||||
7. 确认 `alg.decision_log` 出现 `INBOUND`/`OUTBOUND` 记录,并至少有受控业务出现相应的 `*_COMPARE` 记录。
|
||||
启动后检查:
|
||||
|
||||
影子任务在 Legacy 前提交,但 Legacy 不等待后台线程。线程调度和数据库时序可能导致影子读取与 Legacy 写入重叠,甚至在 Legacy 写入后读取;影子结果不是严格的“Legacy 前快照”,分析时必须结合时间和业务结果解释。
|
||||
|
||||
单线程零队列执行器忙时,被跳过的请求不会调用算法,也没有对应的计划/比较决策日志;如果规划或 Legacy 超过观察期限,也可能只有计划日志而没有比较日志。Legacy 存储过程仍按原流程立即执行。这是保护业务延迟和数据库负载的预期行为,不要据此重放业务请求。
|
||||
|
||||
## 8. 观察与停止条件
|
||||
|
||||
应用日志中的以下文本表示后台影子已失败开放,Legacy 仍继续执行:
|
||||
|
||||
```text
|
||||
Shadow inbound observation failed open
|
||||
Shadow outbound observation failed open
|
||||
Shadow inbound observation interrupted
|
||||
Shadow outbound observation interrupted
|
||||
Inbound shadow submission failed open
|
||||
Project outbound shadow submission failed open
|
||||
Exact-stock shadow submission failed open
|
||||
```cmd
|
||||
jps -lv | findstr /I "RuoYiApplication FT546WMS"
|
||||
```
|
||||
|
||||
出现单条告警不等于 Legacy 失败,但应记录时间和业务单号。测试前由现场负责人和 DBA 根据稳定时段基线写下响应延迟、SQL CPU/I/O、锁等待、连接数和 Shadow 告警频率阈值;未约定阈值时,只允许少量受控业务,不进入连续观察。满足任一条件立即停止影子测试并执行第 10 节回滚:
|
||||
只能看到一个 FT546WMS JVM。
|
||||
|
||||
- Legacy 入库或出库结果、返回码、消息或设备动作与稳定版不同;
|
||||
- 正常接口达到预先约定的延迟阈值,或同类 3 秒超时告警重复出现;
|
||||
- SQL Server CPU、I/O、锁等待或连接数出现不可接受增长;
|
||||
- `alg` 之外出现任何由本次测试新增的表、过程、触发器或数据修改;
|
||||
- 同一 Legacy 存储过程疑似被重复调用;
|
||||
- WMS、WCS、PDA 或现场操作出现异常。
|
||||
### 第七步:做只读和受控业务检查
|
||||
|
||||
实际进入影子规划的请求每次都会按仓库全量刷新读模型。应用在单个 JVM 内将观察周期上限限制为 1、队列容量为 0,其他并发观察立即跳过;这能限制重叠刷新,但 `READ_UNCOMMITTED` 和单并发都无法消除全表扫描带来的 CPU 和 I/O,所以必须从少量受控业务开始,不能直接长时间全量开放。若部署多个影子 JVM,该上限分别生效,现场仍只允许启动一个 WMS 实例。
|
||||
先完成登录、页面查询等只读检查,再由现场人员执行一笔受控入库、一笔自动出库和一笔手动出库。确认:
|
||||
|
||||
## 9. 数据采集与交付
|
||||
- Legacy 返回码、消息、库位和设备动作与稳定流程一致;
|
||||
- 没有相同存储过程重复执行;
|
||||
- `alg.shadow_snapshot_state` 为仓库 `01` 的 `PUBLISHED`;
|
||||
- `alg.shadow_instance_heartbeat` 只有当前实例;
|
||||
- `alg.decision_log` 出现新的计划/比较记录;
|
||||
- 应用日志中的影子 warning 不包含业务载荷或异常消息。
|
||||
|
||||
测试开始和结束时各执行一次:
|
||||
## 5. 立即停止条件
|
||||
|
||||
```text
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql
|
||||
出现任一情况立即执行第 7 节回滚:
|
||||
|
||||
- Legacy 结果、响应码、消息、库位或设备动作改变;
|
||||
- 同一个 Legacy 存储过程疑似执行两次;
|
||||
- 出现第二个 FT546WMS JVM;
|
||||
- `alg` 之外出现本次部署造成的数据写入或新对象;
|
||||
- WMS、WCS、PDA 或现场作业异常;
|
||||
- 正常接口延迟或数据库 CPU、I/O、锁等待超过现场阈值;
|
||||
- 快照长时间不是 `PUBLISHED`,或 `ERROR_*` 指标持续增长。
|
||||
|
||||
单条 `shadow_failure` 表示该影子样本失败开放,不等于 Legacy 失败;但必须记录时间并纳入分析。
|
||||
|
||||
## 6. 连续观察和七天导出
|
||||
|
||||
至少连续观察 7 天,覆盖正常业务高峰、入库、自动出库和手动出库。期间不要重放业务来补影子样本。
|
||||
|
||||
观察结束后运行:
|
||||
|
||||
```cmd
|
||||
cd /d C:\project\FT546WMS-SHANDOW
|
||||
call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "<SQL_SERVER>" "<DATABASE>" "<V3_START_UTC>" "C:\project\FT546WMS-shadow-v3-export" "<LOG_DIR>"
|
||||
```
|
||||
|
||||
在 SSMS 中选择“结果输出到文件”,保留完整结果。该脚本只读,包含对象清单、读模型统计、Legacy 表行数/校验和、当前锁和决策日志。Legacy 表校验和会扫描数据,只在低业务量时执行,不要高频轮询。
|
||||
成功时显示 `EXPORT_OK`。输出目录包含:
|
||||
|
||||
测试结束、卸载之前单独导出原始决策记录:
|
||||
- `deployment.txt`:分支、提交、工作树;
|
||||
- `runtime-processes.txt`:JVM 进程;
|
||||
- `environment-variable-names.txt`:仅环境变量名,不含值;
|
||||
- `sql-diagnostics.txt`:manifest、快照、心跳、输入覆盖、阻塞;
|
||||
- `comparison-detail.txt`:比较汇总和完整决策行;
|
||||
- `runtime-metrics.txt`:准入、跳过、错误和时延桶;
|
||||
- `inventory-parity.txt`:Legacy 与已发布快照库存差异计数;
|
||||
- `shadow-warnings.txt`:日志目录中的结构化影子 warning。
|
||||
|
||||
```sql
|
||||
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
|
||||
把整个输出目录压缩后交给分析,不要包含密码、令牌或与测试无关的个人信息。只有七天数据通过分析后,才讨论正式切换;本分支本身不执行 ACTIVE 切换。
|
||||
|
||||
SELECT decision_id, warehouse_id, decision_type, input_version,
|
||||
result_code, reason, created_at
|
||||
FROM alg.decision_log
|
||||
WHERE created_at >= '<测试开始时间>'
|
||||
AND created_at < '<测试结束时间>'
|
||||
ORDER BY created_at, decision_id;
|
||||
## 7. 回滚
|
||||
|
||||
首选回滚只关闭影子,不卸载数据库对象:
|
||||
|
||||
1. 在 IDEA 停止 v3,记录停止时间;
|
||||
2. 设置 `FORTUCKY_SHADOW_ENABLED=false`;
|
||||
3. 完整重启一次;
|
||||
4. 检查只有一个 JVM;
|
||||
5. 做登录、查询、受控入库和出库冒烟检查。
|
||||
|
||||
更彻底的应用回滚是停止 v3,切回现场原稳定源码/提交并用原 Run Configuration 启动。无论哪种方式,都先恢复业务服务,再处理数据库对象。
|
||||
|
||||
`alg.*` 可以保留供取证,稳定 Legacy 不依赖它。不要把卸载当作紧急回滚步骤。
|
||||
|
||||
## 8. 可选卸载
|
||||
|
||||
只有满足全部条件才卸载:影子实例已停止、稳定服务已恢复、七天证据已导出并备份、DBA 已确认维护窗口。
|
||||
|
||||
```cmd
|
||||
call scripts\shadow-v3-uninstall.cmd "<SQL_SERVER>" "<DATABASE>" CONFIRM_UNINSTALL
|
||||
```
|
||||
|
||||
出库对比记录的 `reason` 会带出 `detail`、`project`、`product`、`tray`、`stock`、`snapshotSync`、`legacyMatches` 和 `shadowMatches`。其中 `legacyMatches` 是按 Legacy 有效库存条件计算的实时数量,`shadowMatches` 是影子快照中按项目/产品条件计算的数量;两者不一致时先检查快照同步时间,再判断业务映射。
|
||||
卸载器只接受精确口令 `CONFIRM_UNINSTALL`,并由 SQL 包再次校验 v3 manifest 和完整对象白名单。发现未知/缺失对象时会在删除前停止。不要修改 manifest 或脚本绕过保护。
|
||||
|
||||
交付分析的数据包至少包括:
|
||||
## 9. 自动化验证与残余风险
|
||||
|
||||
- 决策日志导出文件,保持原始列名和时间字段;
|
||||
- `diagnostics-shadow.sql` 开始和结束两份结果;
|
||||
- 应用日志中测试时间段的 Shadow 告警及其前后异常栈;
|
||||
- 测试开始/结束时间、时区、仓库号、入库次数、自动出库次数、手动出库次数;
|
||||
- 现场人员确认的 Legacy 最终库位、返回码及异常说明;
|
||||
- 当前分支提交号和构建 JAR 的 SHA-256。
|
||||
|
||||
不要提供数据库密码、访问令牌或与分析无关的个人信息。
|
||||
|
||||
## 10. 快速回滚
|
||||
|
||||
### 10.1 首选:切回远端稳定源码
|
||||
|
||||
1. 停止 `FT546WMS-SHADOW`,记录停止时间。
|
||||
2. 确认进程退出和端口释放。
|
||||
3. 在独立稳定工作区启动 `FT546WMS-STABLE-origin-main`。
|
||||
4. 执行登录、查询、受控入库和受控出库冒烟检查。
|
||||
|
||||
稳定版不引用 `alg.*`,因此应用回滚时无需立即卸载数据库对象。先恢复生产服务,再在维护窗口导出数据并卸载。
|
||||
|
||||
### 10.2 备用:仅关闭影子开关
|
||||
|
||||
如果稳定工作区暂时无法启动,可在影子 Run Configuration 中设置:
|
||||
|
||||
```text
|
||||
FORTUCKY_SHADOW_ENABLED=false
|
||||
```
|
||||
|
||||
然后完整重启应用。该方式保留影子分支代码,但所有影子入口会直接返回空观察结果。它不替代稳定版回滚,只用于临时恢复。
|
||||
|
||||
## 11. 完整卸载
|
||||
|
||||
仅在影子应用已停止、`alg.decision_log` 已导出且稳定版已恢复后执行。
|
||||
|
||||
使用 SSMS 打开并执行:
|
||||
|
||||
```text
|
||||
fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql
|
||||
```
|
||||
|
||||
卸载脚本先校验包标记必须精确等于 `FT546WMS_SHADOW_ONLY` / `2026-08-12-1`,再校验 `alg` 顶层对象的名称和类型必须与本包白名单完全一致。发现未知对象、缺失对象、类型不符或标记不符时会在第一个 `DROP` 前拒绝执行,不删除任何对象。校验通过后才在事务内删除本分支拥有的 `alg` 过程、视图、表和 schema;失败会回滚,不修改 Legacy 业务行。执行后验证:
|
||||
|
||||
```sql
|
||||
SELECT SCHEMA_ID(N'alg') AS alg_schema_id;
|
||||
```
|
||||
|
||||
预期结果为 `NULL`。再次查询全库 DML 触发器集合,结果应与安装前保存的基线一致。
|
||||
|
||||
不要在影子应用运行时卸载。如果脚本报错 51011-51014,保存错误和对象清单,停止手工删除,由 DBA 判断恢复或清理方案。不要修改 manifest 来绕过保护。卸载后如需再次测试,必须重新执行完整安装和检查流程。
|
||||
|
||||
## 12. 已知残余风险
|
||||
|
||||
- 当前自动化验证是 Java 单元测试和 SQL 静态审计,不能替代真实 SQL Server 2016 副本演练。
|
||||
- `READ_UNCOMMITTED` 可能读到业务事务的中间状态;该结果只用于影子比较,不参与 Legacy 决策。
|
||||
- 零等待异步提交不保证严格的 Legacy 前置快照;影子读取可能与 Legacy 写入重叠或发生在其后。
|
||||
- 3 秒事务超时会触发 fail-open,但 JDBC 取消和 SQL Server 回滚完成时间不保证严格等于 3 秒。
|
||||
- 每次影子规划前的全量刷新可能增加数据库 CPU 和 I/O;现场必须监控并设置短测试窗口。
|
||||
- JVM 内单线程零队列执行器只避免同一实例的重叠刷新,不限制其他进程或数据库会话;禁止同时运行稳定版和影子版,也禁止运行多个影子实例。
|
||||
- 应用级 fail-open 能保护 Legacy 控制流,但不能消除数据库、网络或主机资源争用。
|
||||
|
||||
这些残余风险是采用“零 Legacy 触发器、每次规划前全量刷新”的代价,也是现场必须先小流量验证、随时可切回稳定源码的原因。
|
||||
- 本地单元/静态测试不能替代 SQL Server 2016 备份副本演练。
|
||||
- `READ UNCOMMITTED` 只用于观察,可能看到业务事务中间状态。
|
||||
- 单线程容量一队列限制影子后台并发,但不能消除数据库资源竞争。
|
||||
- 3 秒 worker 等待超时不等于 JDBC/SQL Server 一定在 3 秒内完成取消或回滚。
|
||||
- 影子调用线程不等待后台,但主机和数据库资源竞争仍可能间接影响 Legacy,需要现场监控。
|
||||
|
||||
60
doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md
Normal file
60
doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 给服务器 AI 的 FT546WMS 影子 v3 导出指令
|
||||
|
||||
把下面整段交给服务器上的 AI。先替换尖括号内容,不要把密码放进提示词。
|
||||
|
||||
```text
|
||||
你在 Windows CMD 环境中操作 FT546WMS 影子 v3。你的任务只导出证据,不修改数据库、不切换分支、不重启服务、不卸载任何对象。
|
||||
|
||||
固定信息:
|
||||
- 仓库目录:C:\project\FT546WMS-SHANDOW
|
||||
- SQL Server:<SQL_SERVER>
|
||||
- 数据库:<DATABASE>
|
||||
- v3 测试开始 UTC:<V3_START_UTC,例如 2026-09-03T08:00:00>
|
||||
- 应用日志目录:<LOG_DIR>
|
||||
- 输出目录:C:\project\FT546WMS-shadow-v3-export
|
||||
- 期望分支:test/ft546wms-shadow-v3-20260903
|
||||
- 期望提交:<V3_COMMIT>
|
||||
|
||||
安全规则:
|
||||
1. 只能使用 cmd.exe,不使用 PowerShell。
|
||||
2. 数据库只能使用 Windows 集成认证,不询问、保存或输出密码。
|
||||
3. 不执行 INSERT、UPDATE、DELETE、MERGE、TRUNCATE、DROP、ALTER、CREATE、EXEC。
|
||||
4. 不运行 install、upgrade、uninstall,不停止或启动 IDEA/Java。
|
||||
5. 任一步失败就停止,保留错误原文,不猜测、不绕过保护。
|
||||
6. 不修改仓库文件,不执行 git checkout/switch/reset/clean/pull。
|
||||
|
||||
先在 CMD 执行只读核对:
|
||||
cd /d C:\project\FT546WMS-SHANDOW
|
||||
git branch --show-current
|
||||
git rev-parse HEAD
|
||||
git status --porcelain
|
||||
jps -lv | findstr /I "RuoYiApplication FT546WMS"
|
||||
|
||||
必须满足:分支等于期望分支,HEAD 等于期望提交,git status 没有输出,相关 JVM 最多一个。否则停止并报告。
|
||||
|
||||
然后执行仓库自带的只读导出器:
|
||||
call scripts\shadow-v3-export.cmd C:\project\FT546WMS-SHANDOW "<SQL_SERVER>" "<DATABASE>" "<V3_START_UTC>" "C:\project\FT546WMS-shadow-v3-export" "<LOG_DIR>"
|
||||
|
||||
必须看到 EXPORT_OK。随后只检查输出文件是否存在,不打开或改写内容:
|
||||
dir /b C:\project\FT546WMS-shadow-v3-export
|
||||
|
||||
期望至少包含:
|
||||
deployment.txt
|
||||
runtime-processes.txt
|
||||
environment-variable-names.txt
|
||||
sql-diagnostics.txt
|
||||
comparison-detail.txt
|
||||
runtime-metrics.txt
|
||||
inventory-parity.txt
|
||||
shadow-warnings.txt
|
||||
|
||||
最后报告:
|
||||
- 实际分支和提交号;
|
||||
- JVM 数量;
|
||||
- EXPORT_OK 是否出现;
|
||||
- 输出目录;
|
||||
- 文件名和大小;
|
||||
- 任何错误。
|
||||
|
||||
不要在回复中粘贴环境变量值、数据库凭据或完整业务数据。不要压缩以外的改写;由现场人员把整个输出目录压缩后交给分析。
|
||||
```
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.ruoyi.algorithm.shadow;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ShadowV3OperationsPackageTest {
|
||||
private final Path root = projectRoot();
|
||||
|
||||
@Test
|
||||
void packageContainsAllCmdOnlyOperationsFiles() {
|
||||
assertThat(root.resolve("scripts/shadow-v3-preflight.cmd")).isRegularFile();
|
||||
assertThat(root.resolve("scripts/shadow-v3-install.cmd")).isRegularFile();
|
||||
assertThat(root.resolve("scripts/shadow-v3-uninstall.cmd")).isRegularFile();
|
||||
assertThat(root.resolve("scripts/shadow-v3-export.cmd")).isRegularFile();
|
||||
assertThat(root.resolve("scripts/shadow-v3-export.sql")).isRegularFile();
|
||||
assertThat(root.resolve("doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md"))
|
||||
.isRegularFile();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preflightIsReadOnlyAndChecksExactSourceAndSingleProcess() throws IOException {
|
||||
String script = text("scripts/shadow-v3-preflight.cmd");
|
||||
|
||||
assertThat(script).startsWith("@echo off").contains(
|
||||
"setlocal",
|
||||
"git status --porcelain",
|
||||
"git merge-base --is-ancestor",
|
||||
"git branch --show-current",
|
||||
"git rev-parse HEAD",
|
||||
"jps -lv",
|
||||
"sqlcmd",
|
||||
"shadow_snapshot_state",
|
||||
"FT546WMS_SHADOW_ONLY",
|
||||
"2026-09-03-3",
|
||||
"exit /b 1");
|
||||
assertThat(script.toLowerCase(Locale.ROOT)).doesNotContain(
|
||||
"git checkout", "git switch", "git reset", "git clean", "powershell");
|
||||
}
|
||||
|
||||
@Test
|
||||
void installAndUninstallAreFailFastAndStrictlyGuarded() throws IOException {
|
||||
String installer = text("scripts/shadow-v3-install.cmd");
|
||||
String uninstaller = text("scripts/shadow-v3-uninstall.cmd");
|
||||
|
||||
assertThat(installer).startsWith("@echo off").contains(
|
||||
"if /I \"%~3\"==\"upgrade\"",
|
||||
"if /I \"%~3\"==\"clean\"",
|
||||
"upgrade-shadow-v2-to-v3.sql",
|
||||
"install-shadow.sql",
|
||||
"sqlcmd", "-E", "-b", "-r1",
|
||||
"if errorlevel 1 exit /b 1");
|
||||
assertThat(uninstaller).startsWith("@echo off").contains(
|
||||
"CONFIRM_UNINSTALL",
|
||||
"uninstall-shadow.sql",
|
||||
"sqlcmd", "-E", "-b", "-r1",
|
||||
"if errorlevel 1 exit /b 1");
|
||||
assertThat(installer.toLowerCase(Locale.ROOT)).doesNotContain("powershell");
|
||||
assertThat(uninstaller.toLowerCase(Locale.ROOT)).doesNotContain("powershell");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportCollectsSourceRuntimeSqlAndWarningEvidence() throws IOException {
|
||||
String exporter = text("scripts/shadow-v3-export.cmd");
|
||||
String exportSql = text("scripts/shadow-v3-export.sql").toLowerCase(Locale.ROOT);
|
||||
|
||||
assertThat(exporter).startsWith("@echo off").contains(
|
||||
"git rev-parse HEAD",
|
||||
"git branch --show-current",
|
||||
"git status --porcelain",
|
||||
"jps -lv",
|
||||
"shadow-v3-export.sql",
|
||||
"sqlcmd", "-E", "-b", "-r1",
|
||||
"FORTUCKY_SHADOW_",
|
||||
"shadow_failure");
|
||||
assertThat(exportSql).contains(
|
||||
"$(warehouseid)",
|
||||
"$(startutc)",
|
||||
"from alg.shadow_package_manifest",
|
||||
"from alg.shadow_snapshot_state",
|
||||
"from alg.shadow_instance_heartbeat",
|
||||
"from alg.shadow_runtime_metric",
|
||||
"from alg.decision_log",
|
||||
"from sys.dm_exec_requests");
|
||||
assertThat(exportSql).doesNotContain(
|
||||
"insert ", "update ", "delete ", "merge ", "truncate ", "drop ", "alter ", "create ", "exec ");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runbookAndServerAiPromptPreserveTheSafeFieldOrder() throws IOException {
|
||||
String runbook = text("doc/algorithm-shadow/README.md");
|
||||
String aiPrompt = text("doc/algorithm-shadow/SHADOW_V3_SERVER_AI_EXPORT_PROMPT.md");
|
||||
|
||||
assertThat(runbook).contains(
|
||||
"test/ft546wms-shadow-v3-20260903",
|
||||
"短暂停机",
|
||||
"正常业务请求只做固定大小的内存登记,不等待影子线程",
|
||||
"git fetch origin",
|
||||
"shadow-v3-preflight.cmd",
|
||||
"shadow-v3-install.cmd \"<SQL_SERVER>\" \"<DATABASE>\" upgrade",
|
||||
"FORTUCKY_SHADOW_ENABLED=true",
|
||||
"FORTUCKY_SHADOW_SNAPSHOT_MAX_AGE_SECONDS=60",
|
||||
"FORTUCKY_SHADOW_QUIET_INTERVAL_MILLIS=1000",
|
||||
"至少连续观察 7 天",
|
||||
"FORTUCKY_SHADOW_ENABLED=false",
|
||||
"CONFIRM_UNINSTALL");
|
||||
assertThat(runbook.indexOf("停止 IDEA 中的 v2 稳定实例"))
|
||||
.isLessThan(runbook.indexOf("git fetch origin"));
|
||||
assertThat(runbook.indexOf("shadow-v3-preflight.cmd"))
|
||||
.isLessThan(runbook.lastIndexOf("shadow-v3-install.cmd"));
|
||||
assertThat(runbook.indexOf("至少连续观察 7 天"))
|
||||
.isLessThan(runbook.indexOf("## 7. 回滚"));
|
||||
assertThat(runbook.indexOf("## 7. 回滚"))
|
||||
.isLessThan(runbook.indexOf("## 8. 可选卸载"));
|
||||
|
||||
assertThat(aiPrompt).contains(
|
||||
"你的任务只导出证据",
|
||||
"不修改数据库",
|
||||
"不切换分支",
|
||||
"不重启服务",
|
||||
"shadow-v3-export.cmd",
|
||||
"EXPORT_OK");
|
||||
}
|
||||
|
||||
private String text(String relativePath) throws IOException {
|
||||
return Files.readString(root.resolve(relativePath), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static Path projectRoot() {
|
||||
Path current = Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize();
|
||||
while (current != null && !Files.isDirectory(current.resolve("scripts"))) {
|
||||
current = current.getParent();
|
||||
}
|
||||
assertThat(current).as("project root containing scripts").isNotNull();
|
||||
return current;
|
||||
}
|
||||
}
|
||||
86
scripts/shadow-v3-export.cmd
Normal file
86
scripts/shadow-v3-export.cmd
Normal file
@@ -0,0 +1,86 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
if "%~1"=="" goto :usage
|
||||
if "%~2"=="" goto :usage
|
||||
if "%~3"=="" goto :usage
|
||||
if "%~4"=="" goto :usage
|
||||
if "%~5"=="" goto :usage
|
||||
if "%~6"=="" goto :usage
|
||||
|
||||
set "REPO_ROOT=%~f1"
|
||||
set "SQL_SERVER=%~2"
|
||||
set "DATABASE=%~3"
|
||||
set "START_UTC=%~4"
|
||||
set "OUTPUT_DIR=%~f5"
|
||||
set "LOG_DIR=%~f6"
|
||||
set "SQL_FILE=%~dp0shadow-v3-export.sql"
|
||||
set "WAREHOUSE_ID=%FORTUCKY_ALGORITHM_WAREHOUSE_ID%"
|
||||
if not defined WAREHOUSE_ID set "WAREHOUSE_ID=01"
|
||||
if "%WAREHOUSE_ID%"=="1" set "WAREHOUSE_ID=01"
|
||||
rem FORTUCKY_SHADOW_ variables are collected by the FORTUCKY_ prefix scan below.
|
||||
|
||||
if not "%WAREHOUSE_ID%"=="01" (call :fail "FORTUCKY_ALGORITHM_WAREHOUSE_ID must be 01" & exit /b 1)
|
||||
if not exist "%REPO_ROOT%\.git" (call :fail "REPO_ROOT is not a Git repository" & exit /b 1)
|
||||
if not exist "%LOG_DIR%" (call :fail "LOG_DIR does not exist" & exit /b 1)
|
||||
if not exist "%SQL_FILE%" (call :fail "export SQL file is missing" & exit /b 1)
|
||||
where git >nul 2>nul
|
||||
if errorlevel 1 (call :fail "git is not available on PATH" & exit /b 1)
|
||||
where jps >nul 2>nul
|
||||
if errorlevel 1 (call :fail "jps is not available on PATH" & exit /b 1)
|
||||
where sqlcmd >nul 2>nul
|
||||
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
|
||||
|
||||
if not exist "%OUTPUT_DIR%" mkdir "%OUTPUT_DIR%"
|
||||
if errorlevel 1 (call :fail "cannot create OUTPUT_DIR" & exit /b 1)
|
||||
|
||||
pushd "%REPO_ROOT%" >nul
|
||||
if errorlevel 1 (call :fail "cannot enter REPO_ROOT" & exit /b 1)
|
||||
(
|
||||
echo export_started_utc=%DATE% %TIME%
|
||||
echo expected_branch=test/ft546wms-shadow-v3-20260903
|
||||
git branch --show-current
|
||||
git rev-parse HEAD
|
||||
git status --porcelain
|
||||
) > "%OUTPUT_DIR%\deployment.txt" 2>&1
|
||||
if errorlevel 1 (call :fail "cannot collect Git deployment evidence" & exit /b 1)
|
||||
popd >nul
|
||||
|
||||
jps -lv > "%OUTPUT_DIR%\runtime-processes.txt" 2>&1
|
||||
if errorlevel 1 (call :fail "cannot collect JVM process evidence" & exit /b 1)
|
||||
|
||||
(
|
||||
for /f "tokens=1 delims==" %%E in ('set FORTUCKY_ 2^>nul') do echo %%E
|
||||
) > "%OUTPUT_DIR%\environment-variable-names.txt"
|
||||
|
||||
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -w 65535 -y 0 -Y 0 -v WarehouseId="%WAREHOUSE_ID%" StartUtc="%START_UTC%" OutputDir="%OUTPUT_DIR%" -i "%SQL_FILE%"
|
||||
if errorlevel 1 (call :fail "read-only SQL export failed" & exit /b 1)
|
||||
|
||||
findstr /S /I /N /C:"shadow_failure" "%LOG_DIR%\*.log" "%LOG_DIR%\*.txt" > "%OUTPUT_DIR%\shadow-warnings.txt" 2>nul
|
||||
|
||||
call :require_output "sql-diagnostics.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
call :require_output "comparison-detail.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
call :require_output "runtime-metrics.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
call :require_output "inventory-parity.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
call :require_output "shadow-warnings.txt"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo EXPORT_OK output=%OUTPUT_DIR%
|
||||
exit /b 0
|
||||
|
||||
:require_output
|
||||
if not exist "%OUTPUT_DIR%\%~1" (call :fail "expected export file is missing" & exit /b 1)
|
||||
exit /b 0
|
||||
|
||||
:usage
|
||||
echo Usage: %~nx0 REPO_ROOT SQL_SERVER DATABASE START_UTC OUTPUT_DIR LOG_DIR 1>&2
|
||||
exit /b 2
|
||||
|
||||
:fail
|
||||
echo EXPORT_FAILED %~1 1>&2
|
||||
popd >nul 2>nul
|
||||
exit /b 1
|
||||
190
scripts/shadow-v3-export.sql
Normal file
190
scripts/shadow-v3-export.sql
Normal file
@@ -0,0 +1,190 @@
|
||||
:on error exit
|
||||
|
||||
:out "$(OutputDir)\sql-diagnostics.txt"
|
||||
SET NOCOUNT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
|
||||
|
||||
SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(30)) AS product_version,
|
||||
DB_NAME() AS database_name,
|
||||
SYSUTCDATETIME() AS checked_at_utc,
|
||||
CONVERT(datetime2(3), '$(StartUtc)', 127) AS export_start_utc,
|
||||
'$(WarehouseId)' AS warehouse_id;
|
||||
|
||||
SELECT package_id, package_version, installed_at
|
||||
FROM alg.shadow_package_manifest;
|
||||
|
||||
SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at,
|
||||
DATEDIFF(SECOND, synchronized_at, SYSUTCDATETIME()) AS snapshot_age_seconds,
|
||||
CONVERT(varchar(64), source_signature, 2) AS source_signature,
|
||||
source_row_count, instance_id, last_error
|
||||
FROM alg.shadow_snapshot_state
|
||||
WHERE warehouse_id = '$(WarehouseId)';
|
||||
|
||||
SELECT warehouse_id, instance_id, claimed_at, last_seen_at,
|
||||
DATEDIFF(SECOND, last_seen_at, SYSUTCDATETIME()) AS heartbeat_age_seconds
|
||||
FROM alg.shadow_instance_heartbeat
|
||||
WHERE warehouse_id = '$(WarehouseId)';
|
||||
|
||||
SELECT 'cargo_space_snapshot' AS entity_name, state.snapshot_generation,
|
||||
COUNT_BIG(snapshot.space_id) AS row_count
|
||||
FROM alg.shadow_snapshot_state state
|
||||
LEFT JOIN alg.cargo_space_snapshot snapshot
|
||||
ON snapshot.warehouse_id = state.warehouse_id
|
||||
AND snapshot.snapshot_generation = state.snapshot_generation
|
||||
WHERE state.warehouse_id = '$(WarehouseId)'
|
||||
GROUP BY state.snapshot_generation
|
||||
UNION ALL
|
||||
SELECT 'active_job_snapshot', state.snapshot_generation, COUNT_BIG(snapshot.job_id)
|
||||
FROM alg.shadow_snapshot_state state
|
||||
LEFT JOIN alg.active_job_snapshot snapshot
|
||||
ON snapshot.warehouse_id = state.warehouse_id
|
||||
AND snapshot.snapshot_generation = state.snapshot_generation
|
||||
WHERE state.warehouse_id = '$(WarehouseId)'
|
||||
GROUP BY state.snapshot_generation
|
||||
UNION ALL
|
||||
SELECT 'shadow_inbound_request_snapshot', state.snapshot_generation, COUNT_BIG(snapshot.task_no)
|
||||
FROM alg.shadow_snapshot_state state
|
||||
LEFT JOIN alg.shadow_inbound_request_snapshot snapshot
|
||||
ON snapshot.warehouse_id = state.warehouse_id
|
||||
AND snapshot.snapshot_generation = state.snapshot_generation
|
||||
WHERE state.warehouse_id = '$(WarehouseId)'
|
||||
GROUP BY state.snapshot_generation
|
||||
UNION ALL
|
||||
SELECT 'shadow_outbound_request_snapshot', state.snapshot_generation, COUNT_BIG(snapshot.stockout_detail_id)
|
||||
FROM alg.shadow_snapshot_state state
|
||||
LEFT JOIN alg.shadow_outbound_request_snapshot snapshot
|
||||
ON snapshot.warehouse_id = state.warehouse_id
|
||||
AND snapshot.snapshot_generation = state.snapshot_generation
|
||||
WHERE state.warehouse_id = '$(WarehouseId)'
|
||||
GROUP BY state.snapshot_generation;
|
||||
|
||||
SELECT request.session_id, request.blocking_session_id, request.status,
|
||||
request.wait_type, request.wait_time, request.command,
|
||||
SUBSTRING(sql_text.text,
|
||||
(request.statement_start_offset / 2) + 1,
|
||||
((CASE request.statement_end_offset
|
||||
WHEN -1 THEN DATALENGTH(sql_text.text)
|
||||
ELSE request.statement_end_offset
|
||||
END - request.statement_start_offset) / 2) + 1) AS statement_text
|
||||
FROM sys.dm_exec_requests request
|
||||
OUTER APPLY sys.dm_exec_sql_text(request.sql_handle) sql_text
|
||||
WHERE request.database_id = DB_ID()
|
||||
AND (request.blocking_session_id <> 0 OR sql_text.text LIKE '%alg.%')
|
||||
ORDER BY request.session_id;
|
||||
GO
|
||||
|
||||
:out "$(OutputDir)\comparison-detail.txt"
|
||||
SET NOCOUNT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
|
||||
|
||||
SELECT decision_type, result_code,
|
||||
CASE
|
||||
WHEN result_code = 'MATCH' THEN 'VALID_MATCH'
|
||||
WHEN result_code = 'DIFFERENT' THEN 'VALID_DIFFERENT'
|
||||
WHEN result_code LIKE 'SKIP_%' THEN 'SKIP'
|
||||
WHEN result_code LIKE 'ERROR_%' THEN 'ERROR'
|
||||
ELSE 'OTHER'
|
||||
END AS outcome_group,
|
||||
COUNT_BIG(*) AS row_count,
|
||||
MIN(created_at) AS first_created_at,
|
||||
MAX(created_at) AS last_created_at
|
||||
FROM alg.decision_log
|
||||
WHERE warehouse_id = '$(WarehouseId)'
|
||||
AND created_at >= CONVERT(datetime2(3), '$(StartUtc)', 127)
|
||||
GROUP BY decision_type, result_code,
|
||||
CASE
|
||||
WHEN result_code = 'MATCH' THEN 'VALID_MATCH'
|
||||
WHEN result_code = 'DIFFERENT' THEN 'VALID_DIFFERENT'
|
||||
WHEN result_code LIKE 'SKIP_%' THEN 'SKIP'
|
||||
WHEN result_code LIKE 'ERROR_%' THEN 'ERROR'
|
||||
ELSE 'OTHER'
|
||||
END
|
||||
ORDER BY decision_type, result_code;
|
||||
|
||||
SELECT decision_id, warehouse_id, decision_type, input_version,
|
||||
result_code, reason, created_at
|
||||
FROM alg.decision_log
|
||||
WHERE warehouse_id = '$(WarehouseId)'
|
||||
AND created_at >= CONVERT(datetime2(3), '$(StartUtc)', 127)
|
||||
ORDER BY created_at, decision_id;
|
||||
|
||||
SELECT SUM(CASE WHEN decision_type = 'INBOUND' THEN 1 ELSE 0 END) AS inbound_plan_count,
|
||||
SUM(CASE WHEN decision_type = 'INBOUND_COMPARE' THEN 1 ELSE 0 END) AS inbound_compare_count,
|
||||
SUM(CASE WHEN decision_type = 'OUTBOUND' THEN 1 ELSE 0 END) AS outbound_plan_count,
|
||||
SUM(CASE WHEN decision_type = 'OUTBOUND_COMPARE' THEN 1 ELSE 0 END) AS outbound_compare_count
|
||||
FROM alg.decision_log
|
||||
WHERE warehouse_id = '$(WarehouseId)'
|
||||
AND created_at >= CONVERT(datetime2(3), '$(StartUtc)', 127);
|
||||
GO
|
||||
|
||||
:out "$(OutputDir)\runtime-metrics.txt"
|
||||
SET NOCOUNT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
|
||||
|
||||
SELECT warehouse_id, metric_name, bucket_upper_micros, metric_count,
|
||||
metric_total_micros, metric_max_micros, flushed_at
|
||||
FROM alg.shadow_runtime_metric
|
||||
WHERE warehouse_id = '$(WarehouseId)'
|
||||
ORDER BY metric_name, bucket_upper_micros;
|
||||
|
||||
SELECT CASE
|
||||
WHEN metric_name LIKE 'SKIP_%' THEN 'SKIP'
|
||||
WHEN metric_name LIKE 'ERROR_%' THEN 'ERROR'
|
||||
WHEN metric_name = 'ADMITTED' THEN 'ADMITTED'
|
||||
ELSE 'HISTOGRAM'
|
||||
END AS metric_group,
|
||||
SUM(metric_count) AS metric_count,
|
||||
MAX(flushed_at) AS latest_flush_at
|
||||
FROM alg.shadow_runtime_metric
|
||||
WHERE warehouse_id = '$(WarehouseId)'
|
||||
GROUP BY CASE
|
||||
WHEN metric_name LIKE 'SKIP_%' THEN 'SKIP'
|
||||
WHEN metric_name LIKE 'ERROR_%' THEN 'ERROR'
|
||||
WHEN metric_name = 'ADMITTED' THEN 'ADMITTED'
|
||||
ELSE 'HISTOGRAM'
|
||||
END
|
||||
ORDER BY metric_group;
|
||||
GO
|
||||
|
||||
:out "$(OutputDir)\inventory-parity.txt"
|
||||
SET NOCOUNT ON;
|
||||
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
|
||||
|
||||
WITH legacy_stock AS (
|
||||
SELECT stock.stock_id, stock.cargo_space_id, stock.tray_code,
|
||||
stock.spare4 AS project_no, stock.product_id AS product_no,
|
||||
stock.lot_no, stock.quantity, stock.out_quantity
|
||||
FROM dbo.bas_stock stock WITH (NOLOCK)
|
||||
INNER JOIN dbo.bas_cargo_space space WITH (NOLOCK)
|
||||
ON space.cargo_space_id = stock.cargo_space_id
|
||||
WHERE TRY_CONVERT(decimal(38,0), LTRIM(RTRIM(space.warehouse_id))) =
|
||||
TRY_CONVERT(decimal(38,0), '$(WarehouseId)')
|
||||
), shadow_stock AS (
|
||||
SELECT snapshot.stock_id, snapshot.space_id AS cargo_space_id,
|
||||
snapshot.tray_code, snapshot.project_no, snapshot.product_no,
|
||||
snapshot.lot_no, snapshot.quantity, snapshot.out_quantity
|
||||
FROM alg.inventory_item_snapshot snapshot
|
||||
INNER JOIN alg.shadow_snapshot_state state
|
||||
ON state.warehouse_id = snapshot.warehouse_id
|
||||
AND state.snapshot_generation = snapshot.snapshot_generation
|
||||
AND state.snapshot_status = 'PUBLISHED'
|
||||
WHERE snapshot.warehouse_id = '$(WarehouseId)'
|
||||
)
|
||||
SELECT SUM(CASE WHEN legacy.stock_id IS NULL THEN 1 ELSE 0 END) AS shadow_only_count,
|
||||
SUM(CASE WHEN shadow.stock_id IS NULL THEN 1 ELSE 0 END) AS legacy_only_count,
|
||||
SUM(CASE WHEN legacy.stock_id IS NOT NULL AND shadow.stock_id IS NOT NULL
|
||||
AND (ISNULL(legacy.cargo_space_id, '') <> ISNULL(shadow.cargo_space_id, '')
|
||||
OR ISNULL(legacy.tray_code, '') <> ISNULL(shadow.tray_code, '')
|
||||
OR ISNULL(legacy.project_no, '') <> ISNULL(shadow.project_no, '')
|
||||
OR ISNULL(legacy.product_no, '') <> ISNULL(shadow.product_no, '')
|
||||
OR ISNULL(legacy.lot_no, '') <> ISNULL(shadow.lot_no, '')
|
||||
OR ISNULL(legacy.quantity, 0) <> ISNULL(shadow.quantity, 0)
|
||||
OR ISNULL(legacy.out_quantity, 0) <> ISNULL(shadow.out_quantity, 0))
|
||||
THEN 1 ELSE 0 END) AS differing_row_count,
|
||||
COUNT_BIG(*) AS compared_stock_count
|
||||
FROM legacy_stock legacy
|
||||
FULL OUTER JOIN shadow_stock shadow ON shadow.stock_id = legacy.stock_id;
|
||||
GO
|
||||
|
||||
:out stdout
|
||||
SELECT 'EXPORT_SQL_OK' AS export_status, SYSUTCDATETIME() AS completed_at_utc;
|
||||
34
scripts/shadow-v3-install.cmd
Normal file
34
scripts/shadow-v3-install.cmd
Normal file
@@ -0,0 +1,34 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
if "%~1"=="" goto :usage
|
||||
if "%~2"=="" goto :usage
|
||||
if "%~3"=="" goto :usage
|
||||
|
||||
set "SQL_SERVER=%~1"
|
||||
set "DATABASE=%~2"
|
||||
set "MODE=%~3"
|
||||
set "SQL_FILE="
|
||||
|
||||
if /I "%~3"=="upgrade" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\upgrade-shadow-v2-to-v3.sql"
|
||||
if /I "%~3"=="clean" set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\install-shadow.sql"
|
||||
if not defined SQL_FILE (call :fail "mode must be upgrade or clean" & exit /b 1)
|
||||
if not exist "%SQL_FILE%" (call :fail "SQL package file is missing" & exit /b 1)
|
||||
|
||||
where sqlcmd >nul 2>nul
|
||||
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
|
||||
|
||||
echo Installing FT546WMS shadow v3 in %MODE% mode...
|
||||
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo INSTALL_OK mode=%MODE%
|
||||
exit /b 0
|
||||
|
||||
:usage
|
||||
echo Usage: %~nx0 SQL_SERVER DATABASE upgrade^|clean 1>&2
|
||||
exit /b 2
|
||||
|
||||
:fail
|
||||
echo INSTALL_FAILED %~1 1>&2
|
||||
exit /b 1
|
||||
64
scripts/shadow-v3-preflight.cmd
Normal file
64
scripts/shadow-v3-preflight.cmd
Normal file
@@ -0,0 +1,64 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
if "%~1"=="" goto :usage
|
||||
if "%~2"=="" goto :usage
|
||||
if "%~3"=="" goto :usage
|
||||
if "%~4"=="" goto :usage
|
||||
|
||||
set "REPO_ROOT=%~f1"
|
||||
set "EXPECTED_COMMIT=%~2"
|
||||
set "SQL_SERVER=%~3"
|
||||
set "DATABASE=%~4"
|
||||
set "EXPECTED_BRANCH=test/ft546wms-shadow-v3-20260903"
|
||||
set "MAIN_COMMIT=916ac6bda32a5b102a3181cc8c5dbd5319c5656d"
|
||||
|
||||
where git >nul 2>nul
|
||||
if errorlevel 1 (call :fail "git is not available on PATH" & exit /b 1)
|
||||
where jps >nul 2>nul
|
||||
if errorlevel 1 (call :fail "jps is not available on PATH" & exit /b 1)
|
||||
where sqlcmd >nul 2>nul
|
||||
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
|
||||
if not exist "%REPO_ROOT%\.git" (call :fail "REPO_ROOT is not a Git repository" & exit /b 1)
|
||||
|
||||
pushd "%REPO_ROOT%" >nul
|
||||
if errorlevel 1 (call :fail "cannot enter REPO_ROOT" & exit /b 1)
|
||||
|
||||
git rev-parse --is-inside-work-tree >nul 2>nul
|
||||
if errorlevel 1 (call :fail "REPO_ROOT is not a Git worktree" & exit /b 1)
|
||||
|
||||
set "CURRENT_BRANCH="
|
||||
for /f "usebackq delims=" %%I in (`git branch --show-current`) do set "CURRENT_BRANCH=%%I"
|
||||
if /I not "%CURRENT_BRANCH%"=="%EXPECTED_BRANCH%" (call :fail "unexpected branch" & exit /b 1)
|
||||
|
||||
set "CURRENT_COMMIT="
|
||||
for /f "usebackq delims=" %%I in (`git rev-parse HEAD`) do set "CURRENT_COMMIT=%%I"
|
||||
if /I not "%CURRENT_COMMIT%"=="%EXPECTED_COMMIT%" (call :fail "HEAD does not match EXPECTED_COMMIT" & exit /b 1)
|
||||
|
||||
set "DIRTY="
|
||||
for /f "usebackq delims=" %%I in (`git status --porcelain`) do set "DIRTY=1"
|
||||
if defined DIRTY (call :fail "Git worktree is not clean" & exit /b 1)
|
||||
|
||||
git merge-base --is-ancestor %MAIN_COMMIT% HEAD
|
||||
if errorlevel 1 (call :fail "required main commit is not an ancestor" & exit /b 1)
|
||||
popd >nul
|
||||
|
||||
set "WMS_PROCESS_COUNT=0"
|
||||
for /f %%I in ('jps -lv 2^>nul ^| findstr /I /C:"com.ruoyi.RuoYiApplication" /C:"FT546WMS" ^| find /C /V ""') do set "WMS_PROCESS_COUNT=%%I"
|
||||
if %WMS_PROCESS_COUNT% GTR 1 (call :fail "more than one FT546WMS JVM is running" & exit /b 1)
|
||||
|
||||
set "PREFLIGHT_SQL=SET NOCOUNT ON; IF SCHEMA_ID(N'alg') IS NULL SELECT N'CLEAN_INSTALL_ELIGIBLE' AS package_state; ELSE BEGIN IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL BEGIN; THROW 51050, 'shadow manifest is missing', 1; END; IF NOT EXISTS (SELECT 1 FROM alg.shadow_package_manifest WHERE package_id='FT546WMS_SHADOW_ONLY' AND package_version IN ('2026-08-12-1','2026-09-03-3')) BEGIN; THROW 51051, 'shadow manifest is not a supported version', 1; END; SELECT package_id, package_version, installed_at FROM alg.shadow_package_manifest; IF OBJECT_ID(N'alg.shadow_snapshot_state', N'U') IS NOT NULL SELECT warehouse_id, snapshot_generation, snapshot_status, synchronized_at, CONVERT(varchar(64), source_signature, 2) AS source_signature FROM alg.shadow_snapshot_state; END"
|
||||
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -Q "%PREFLIGHT_SQL%"
|
||||
if errorlevel 1 (call :fail "SQL connectivity or shadow package validation failed" & exit /b 1)
|
||||
|
||||
echo PREFLIGHT_OK branch=%CURRENT_BRANCH% commit=%CURRENT_COMMIT% wmsProcesses=%WMS_PROCESS_COUNT%
|
||||
exit /b 0
|
||||
|
||||
:usage
|
||||
echo Usage: %~nx0 REPO_ROOT EXPECTED_COMMIT SQL_SERVER DATABASE 1>&2
|
||||
exit /b 2
|
||||
|
||||
:fail
|
||||
echo PREFLIGHT_FAILED %~1 1>&2
|
||||
popd >nul 2>nul
|
||||
exit /b 1
|
||||
35
scripts/shadow-v3-uninstall.cmd
Normal file
35
scripts/shadow-v3-uninstall.cmd
Normal file
@@ -0,0 +1,35 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions DisableDelayedExpansion
|
||||
|
||||
if "%~1"=="" goto :usage
|
||||
if "%~2"=="" goto :usage
|
||||
if "%~3"=="" goto :usage
|
||||
if not "%~3"=="CONFIRM_UNINSTALL" goto :refuse
|
||||
|
||||
set "SQL_SERVER=%~1"
|
||||
set "DATABASE=%~2"
|
||||
set "SQL_FILE=%~dp0..\fortucky-algorithm\src\main\resources\db\sqlserver\uninstall-shadow.sql"
|
||||
|
||||
if not exist "%SQL_FILE%" (call :fail "uninstall SQL file is missing" & exit /b 1)
|
||||
where sqlcmd >nul 2>nul
|
||||
if errorlevel 1 (call :fail "sqlcmd is not available on PATH" & exit /b 1)
|
||||
|
||||
echo WARNING: export all alg.* evidence before uninstalling.
|
||||
echo The shadow application must be stopped and the stable service restored first.
|
||||
sqlcmd -S "%SQL_SERVER%" -d "%DATABASE%" -E -b -r1 -i "%SQL_FILE%"
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo UNINSTALL_OK
|
||||
exit /b 0
|
||||
|
||||
:usage
|
||||
echo Usage: %~nx0 SQL_SERVER DATABASE CONFIRM_UNINSTALL 1>&2
|
||||
exit /b 2
|
||||
|
||||
:refuse
|
||||
echo UNINSTALL_REFUSED third argument must be CONFIRM_UNINSTALL 1>&2
|
||||
exit /b 3
|
||||
|
||||
:fail
|
||||
echo UNINSTALL_FAILED %~1 1>&2
|
||||
exit /b 1
|
||||
Reference in New Issue
Block a user