diff --git a/doc/algorithm-shadow/README.md b/doc/algorithm-shadow/README.md index 5be11c5..2840dee 100644 --- a/doc/algorithm-shadow/README.md +++ b/doc/algorithm-shadow/README.md @@ -16,8 +16,9 @@ origin/main = 916ac6bda32a5b102a3181cc8c5dbd5319c5656d - 默认配置 `FORTUCKY_SHADOW_ENABLED=false`,未显式开启时不执行影子规划。 - 启用时只允许 `FORTUCKY_ALGORITHM_MODE=SHADOW`;误设 `ACTIVE` 或 `LEGACY` 会导致应用拒绝启动。 -- 影子事务使用 `REQUIRES_NEW`、`READ_UNCOMMITTED` 和 3 秒超时。 -- 单个 JVM 同时最多执行一个影子规划;并发到达的其他观察会立即跳过,不排队、不等待,也不延迟 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`。 @@ -105,7 +106,7 @@ WHERE object.is_ms_shipped = 0 ORDER BY schema_name(object.schema_id), object.type_desc, object.name; ``` -预期 `alg_schema_id` 为 `NULL`。如果现场已经存在 `alg` schema 或同名对象,停止安装并先确认所有权;不要覆盖,也不要执行本分支卸载脚本。 +预期 `alg_schema_id` 为 `NULL`。安装脚本发现任何既有 `alg` schema 会以错误 51000 拒绝执行。如果现场已经存在 `alg` schema 或同名对象,停止安装并先确认所有权;不要覆盖,也不要执行本分支卸载脚本。 把两份查询结果连同执行时间保存为安装前基线。现场可能已有 Legacy 自身的触发器,安装后的全库 DML 触发器集合必须与安装前完全一致;用户对象清单只能新增本包定义的 `alg.*` 对象。 @@ -148,6 +149,15 @@ GROUP BY warehouse_id; 本次新增对象只能位于 `alg` schema;`lane_state` 应存在目标仓库的数据。如果安装部分失败,不要启动影子程序,也不要自动执行卸载脚本。保留完整输出和安装前后对象清单,由 DBA 决定从备份恢复,或在确认安装前 `alg` 不存在且失败期间无人创建其他 `alg` 对象后,才执行本分支卸载脚本。 +确认包标记唯一且版本准确: + +```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 中设置: @@ -173,20 +183,24 @@ FORTUCKY_ALGORITHM_LEGACY_FALLBACK_ON_TECHNICAL_ERROR=false 4. 如果出现 `Shadow observation requires ...`,不要修改保护条件,直接回滚到稳定实例。 5. 先做只读登录和查询冒烟检查,再由现场人员执行一笔受控入库和一笔受控出库。 6. 确认 Legacy 返回码、库位和现场设备动作与稳定流程一致。 -7. 确认 `alg.decision_log` 出现 `INBOUND`/`OUTBOUND` 及相应的 `*_COMPARE` 记录。 +7. 确认 `alg.decision_log` 出现 `INBOUND`/`OUTBOUND` 记录,并至少有受控业务出现相应的 `*_COMPARE` 记录。 -并发业务触发单 JVM 规划门时,被跳过的请求不会调用算法,也可能没有对应的计划/比较决策日志;Legacy 存储过程仍按原流程立即执行。这是保护现场数据库负载的预期行为,不要据此重放业务请求。 +影子任务在 Legacy 前提交,但 Legacy 不等待后台线程。线程调度和数据库时序可能导致影子读取与 Legacy 写入重叠,甚至在 Legacy 写入后读取;影子结果不是严格的“Legacy 前快照”,分析时必须结合时间和业务结果解释。 + +单线程零队列执行器忙时,被跳过的请求不会调用算法,也没有对应的计划/比较决策日志;如果规划或 Legacy 超过观察期限,也可能只有计划日志而没有比较日志。Legacy 存储过程仍按原流程立即执行。这是保护业务延迟和数据库负载的预期行为,不要据此重放业务请求。 ## 8. 观察与停止条件 -应用日志中的以下文本表示影子已失败开放,Legacy 仍继续执行: +应用日志中的以下文本表示后台影子已失败开放,Legacy 仍继续执行: ```text -Shadow inbound planning failed open -Shadow outbound planning failed open -Shadow exact-stock planning failed open -Shadow inbound comparison failed open -Shadow outbound comparison failed open +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 ``` 出现单条告警不等于 Legacy 失败,但应记录时间和业务单号。测试前由现场负责人和 DBA 根据稳定时段基线写下响应延迟、SQL CPU/I/O、锁等待、连接数和 Shadow 告警频率阈值;未约定阈值时,只允许少量受控业务,不进入连续观察。满足任一条件立即停止影子测试并执行第 10 节回滚: @@ -198,7 +212,7 @@ Shadow outbound comparison failed open - 同一 Legacy 存储过程疑似被重复调用; - WMS、WCS、PDA 或现场操作出现异常。 -实际进入影子规划的请求每次都会按仓库全量刷新读模型。应用在单个 JVM 内将并发规划上限限制为 1,其他并发观察立即跳过;这能限制重叠刷新,但 `READ_UNCOMMITTED` 和单并发都无法消除全表扫描带来的 CPU 和 I/O,所以必须从少量受控业务开始,不能直接长时间全量开放。若部署多个影子 JVM,该上限分别生效,现场仍只允许启动一个 WMS 实例。 +实际进入影子规划的请求每次都会按仓库全量刷新读模型。应用在单个 JVM 内将观察周期上限限制为 1、队列容量为 0,其他并发观察立即跳过;这能限制重叠刷新,但 `READ_UNCOMMITTED` 和单并发都无法消除全表扫描带来的 CPU 和 I/O,所以必须从少量受控业务开始,不能直接长时间全量开放。若部署多个影子 JVM,该上限分别生效,现场仍只允许启动一个 WMS 实例。 ## 9. 数据采集与交付 @@ -265,7 +279,7 @@ FORTUCKY_SHADOW_ENABLED=false fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql ``` -卸载脚本只删除本分支拥有的 `alg` 过程、视图、表和 schema,不修改 Legacy 业务行。执行后验证: +卸载脚本先校验包标记必须精确等于 `FT546WMS_SHADOW_ONLY` / `2026-08-12-1`,再校验 `alg` 顶层对象的名称和类型必须与本包白名单完全一致。发现未知对象、缺失对象、类型不符或标记不符时会在第一个 `DROP` 前拒绝执行,不删除任何对象。校验通过后才在事务内删除本分支拥有的 `alg` 过程、视图、表和 schema;失败会回滚,不修改 Legacy 业务行。执行后验证: ```sql SELECT SCHEMA_ID(N'alg') AS alg_schema_id; @@ -273,15 +287,16 @@ SELECT SCHEMA_ID(N'alg') AS alg_schema_id; 预期结果为 `NULL`。再次查询全库 DML 触发器集合,结果应与安装前保存的基线一致。 -不要在影子应用运行时卸载。不要对包含未知对象的既有 `alg` schema 执行本卸载脚本。卸载后如需再次测试,必须重新执行完整安装和检查流程。 +不要在影子应用运行时卸载。如果脚本报错 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 内单并发门只避免同一实例的重叠刷新,不限制其他进程或数据库会话;禁止同时运行稳定版和影子版,也禁止运行多个影子实例。 +- JVM 内单线程零队列执行器只避免同一实例的重叠刷新,不限制其他进程或数据库会话;禁止同时运行稳定版和影子版,也禁止运行多个影子实例。 - 应用级 fail-open 能保护 Legacy 控制流,但不能消除数据库、网络或主机资源争用。 这些残余风险是采用“零 Legacy 触发器、每次规划前全量刷新”的代价,也是现场必须先小流量验证、随时可切回稳定源码的原因。 diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql index 377ecdd..36a873b 100644 --- a/fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql +++ b/fortucky-algorithm/src/main/resources/db/sqlserver/diagnostics-shadow.sql @@ -6,6 +6,9 @@ SELECT CAST(SERVERPROPERTY('ProductVersion') AS varchar(30)) AS product_version, DB_NAME() AS database_name, SYSUTCDATETIME() AS checked_at_utc; +SELECT package_id, package_version, installed_at +FROM alg.shadow_package_manifest; + SELECT schema_name(object.schema_id) AS schema_name, object.name AS object_name, object.type_desc diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql index 4d2246b..2ebd809 100644 --- a/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql +++ b/fortucky-algorithm/src/main/resources/db/sqlserver/install-shadow.sql @@ -6,7 +6,20 @@ SET NOCOUNT ON; SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -IF SCHEMA_ID(N'alg') IS NULL EXEC(N'CREATE SCHEMA alg'); +IF SCHEMA_ID(N'alg') IS NOT NULL + THROW 51000, 'FT546 shadow install refused: alg schema already exists', 1; + +EXEC(N'CREATE SCHEMA alg'); +GO + +CREATE TABLE alg.shadow_package_manifest ( + package_id varchar(80) NOT NULL CONSTRAINT PK_alg_shadow_package_manifest PRIMARY KEY, + package_version varchar(40) NOT NULL, + installed_at datetime2 NOT NULL CONSTRAINT DF_alg_shadow_package_manifest_installed_at DEFAULT SYSUTCDATETIME() +); + +INSERT INTO alg.shadow_package_manifest (package_id, package_version) +VALUES ('FT546WMS_SHADOW_ONLY', '2026-08-12-1'); GO IF OBJECT_ID(N'alg.warehouse_policy', N'U') IS NULL diff --git a/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql b/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql index a563a40..a24af43 100644 --- a/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql +++ b/fortucky-algorithm/src/main/resources/db/sqlserver/uninstall-shadow.sql @@ -1,51 +1,103 @@ /* - Removes only the FT546 shadow package. Export alg.decision_log first. + Removes only the exact FT546 shadow package version after ownership checks. + Export alg.decision_log and stop the shadow application first. No Legacy WMS business row is modified by this script. */ SET NOCOUNT ON; +SET XACT_ABORT ON; + +IF SCHEMA_ID(N'alg') IS NULL + THROW 51010, 'FT546 shadow uninstall refused: alg schema does not exist', 1; + +IF OBJECT_ID(N'alg.shadow_package_manifest', N'U') IS NULL + THROW 51011, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1; + +IF (SELECT COUNT(*) FROM alg.shadow_package_manifest + WHERE package_id = 'FT546WMS_SHADOW_ONLY' + AND package_version = '2026-08-12-1') <> 1 + OR (SELECT COUNT(*) FROM alg.shadow_package_manifest) <> 1 + THROW 51012, 'FT546 shadow uninstall refused: shadow package manifest does not match', 1; + +DECLARE @expected_objects TABLE ( + object_name sysname NOT NULL PRIMARY KEY, + object_type char(2) NOT NULL +); + +INSERT INTO @expected_objects (object_name, object_type) VALUES + (N'shadow_package_manifest', 'U'), + (N'warehouse_policy', 'U'), + (N'project_policy', 'U'), + (N'space_role', 'U'), + (N'space_topology', 'U'), + (N'inventory_item_snapshot', 'U'), + (N'space_inventory_summary', 'U'), + (N'lane_state', 'U'), + (N'reservation', 'U'), + (N'deep_workflow_lock', 'U'), + (N'decision_log', 'U'), + (N'v_inventory_item', 'V'), + (N'v_active_job', 'V'), + (N'v_space_state', 'V'), + (N'usp_rebuild_space_topology', 'P'), + (N'usp_sync_inventory_snapshot', 'P'), + (N'usp_compute_lane_semantic_fingerprint', 'P'), + (N'usp_rebuild_lane_state', 'P'), + (N'usp_refresh_dirty_lanes', 'P'); + +IF EXISTS ( + SELECT 1 + FROM sys.objects object + LEFT JOIN @expected_objects expected + ON expected.object_name = object.name AND expected.object_type = object.type + WHERE object.schema_id = SCHEMA_ID(N'alg') + AND object.is_ms_shipped = 0 + AND object.parent_object_id = 0 + AND expected.object_name IS NULL +) + THROW 51013, 'FT546 shadow uninstall refused: unknown object exists in alg schema', 1; + +IF EXISTS ( + SELECT 1 + FROM @expected_objects expected + LEFT JOIN sys.objects object + ON object.schema_id = SCHEMA_ID(N'alg') + AND object.name = expected.object_name + AND object.type = expected.object_type + WHERE object.object_id IS NULL +) + THROW 51014, 'FT546 shadow uninstall refused: expected object is missing or has wrong type', 1; + +BEGIN TRY + BEGIN TRANSACTION; -IF OBJECT_ID(N'alg.usp_refresh_dirty_lanes', N'P') IS NOT NULL DROP PROCEDURE alg.usp_refresh_dirty_lanes; -IF OBJECT_ID(N'alg.usp_rebuild_lane_state', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_lane_state; -IF OBJECT_ID(N'alg.usp_compute_lane_semantic_fingerprint', N'P') IS NOT NULL DROP PROCEDURE alg.usp_compute_lane_semantic_fingerprint; -IF OBJECT_ID(N'alg.usp_sync_inventory_snapshot', N'P') IS NOT NULL DROP PROCEDURE alg.usp_sync_inventory_snapshot; -IF OBJECT_ID(N'alg.usp_rebuild_space_topology', N'P') IS NOT NULL DROP PROCEDURE alg.usp_rebuild_space_topology; -GO -IF OBJECT_ID(N'alg.v_space_state', N'V') IS NOT NULL DROP VIEW alg.v_space_state; -IF OBJECT_ID(N'alg.v_active_job', N'V') IS NOT NULL DROP VIEW alg.v_active_job; -IF OBJECT_ID(N'alg.v_inventory_item', N'V') IS NOT NULL DROP VIEW alg.v_inventory_item; -GO -IF OBJECT_ID(N'alg.decision_log', N'U') IS NOT NULL DROP TABLE alg.decision_log; -IF OBJECT_ID(N'alg.deep_workflow_lock', N'U') IS NOT NULL DROP TABLE alg.deep_workflow_lock; -IF OBJECT_ID(N'alg.reservation', N'U') IS NOT NULL DROP TABLE alg.reservation; -IF OBJECT_ID(N'alg.lane_state', N'U') IS NOT NULL DROP TABLE alg.lane_state; -IF OBJECT_ID(N'alg.space_inventory_summary', N'U') IS NOT NULL DROP TABLE alg.space_inventory_summary; -IF OBJECT_ID(N'alg.inventory_item_snapshot', N'U') IS NOT NULL DROP TABLE alg.inventory_item_snapshot; -IF OBJECT_ID(N'alg.space_topology', N'U') IS NOT NULL DROP TABLE alg.space_topology; -IF OBJECT_ID(N'alg.space_role', N'U') IS NOT NULL DROP TABLE alg.space_role; -IF OBJECT_ID(N'alg.project_policy', N'U') IS NOT NULL DROP TABLE alg.project_policy; -IF OBJECT_ID(N'alg.warehouse_policy', N'U') IS NOT NULL DROP TABLE alg.warehouse_policy; -GO + DROP TABLE alg.shadow_package_manifest; -IF SCHEMA_ID(N'alg') IS NOT NULL EXEC(N'DROP SCHEMA alg'); + COMMIT TRANSACTION; +END TRY +BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; +END CATCH; GO diff --git a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java index 768daf7..8fa640f 100644 --- a/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java +++ b/fortucky-algorithm/src/test/java/com/ruoyi/algorithm/repository/ShadowSqlServerMigrationScriptTest.java @@ -14,6 +14,7 @@ import static org.assertj.core.api.Assertions.assertThat; class ShadowSqlServerMigrationScriptTest { private static final Set ALLOWED_INSTALL_OBJECTS = Set.of( + "table alg.shadow_package_manifest", "table alg.warehouse_policy", "table alg.project_policy", "table alg.space_role", @@ -111,6 +112,36 @@ class ShadowSqlServerMigrationScriptTest { assertThat(normalize(resource("db/sqlserver/diagnostics-shadow.sql"))).contains(readUncommitted); } + @Test + void installRefusesAnyExistingAlgorithmSchemaAndCreatesExactManifest() throws IOException { + String install = normalize(resource("db/sqlserver/install-shadow.sql")); + + assertThat(install).contains( + "if schema_id(n'alg') is not null", + "throw 51000", + "create table alg.shadow_package_manifest", + "ft546wms_shadow_only", + "2026-08-12-1"); + assertThat(install.indexOf("throw 51000")).isLessThan(install.indexOf("create schema alg")); + } + + @Test + void uninstallValidatesManifestAndObjectAllowListBeforeAnyDrop() throws IOException { + String uninstall = normalize(resource("db/sqlserver/uninstall-shadow.sql")); + int firstDrop = uninstall.indexOf("drop procedure"); + + assertThat(uninstall).contains( + "ft546wms_shadow_only", + "2026-08-12-1", + "shadow package manifest does not match", + "unknown object exists in alg schema", + "object.parent_object_id = 0", + "begin transaction", + "rollback transaction"); + assertThat(uninstall.indexOf("shadow package manifest does not match")).isLessThan(firstDrop); + assertThat(uninstall.indexOf("unknown object exists in alg schema")).isLessThan(firstDrop); + } + private Set createdObjects(String sql) { return objects(sql, Pattern.compile( "(?i)create\\s+(table|view|procedure|trigger)\\s+([a-z0-9_]+\\.[a-z0-9_]+)"));