feat: publish atomic shadow snapshot generations

This commit is contained in:
zgw
2026-09-03 13:34:52 +08:00
parent 21cf0a5151
commit 3e9f61487c
5 changed files with 2426 additions and 450 deletions

View File

@@ -14,6 +14,36 @@ import static org.assertj.core.api.Assertions.assertThat;
class ShadowSqlServerMigrationScriptTest {
private static final Set<String> ALLOWED_INSTALL_OBJECTS = Set.of(
"table alg.shadow_package_manifest",
"table alg.warehouse_policy",
"table alg.project_policy",
"table alg.space_role",
"table alg.space_topology",
"table alg.inventory_item_snapshot",
"table alg.space_inventory_summary",
"table alg.lane_state",
"table alg.reservation",
"table alg.deep_workflow_lock",
"table alg.decision_log",
"table alg.shadow_snapshot_state",
"table alg.cargo_space_snapshot",
"table alg.active_job_snapshot",
"table alg.shadow_inbound_request_snapshot",
"table alg.shadow_outbound_request_snapshot",
"table alg.shadow_runtime_metric",
"table alg.shadow_instance_heartbeat",
"view alg.v_inventory_item",
"view alg.v_active_job",
"view alg.v_space_state",
"procedure alg.usp_rebuild_space_topology",
"procedure alg.usp_sync_inventory_snapshot",
"procedure alg.usp_compute_lane_semantic_fingerprint",
"procedure alg.usp_rebuild_lane_state",
"procedure alg.usp_refresh_dirty_lanes",
"procedure alg.usp_claim_shadow_instance",
"procedure alg.usp_refresh_shadow_snapshot");
private static final Set<String> V2_OBJECTS = Set.of(
"table alg.shadow_package_manifest",
"table alg.warehouse_policy",
"table alg.project_policy",
@@ -71,6 +101,11 @@ class ShadowSqlServerMigrationScriptTest {
Set<String> removed = droppedObjects(resource("db/sqlserver/uninstall-shadow.sql"));
assertThat(removed).containsExactlyInAnyOrderElementsOf(installed);
String uninstall = normalize(resource("db/sqlserver/uninstall-shadow.sql"));
installed.forEach(object -> assertThat(objectOccurrences(uninstall, object))
.as("uninstall should drop %s exactly once", object)
.isEqualTo(1));
}
@Test
@@ -98,10 +133,118 @@ class ShadowSqlServerMigrationScriptTest {
"inbound_compare",
"outbound_compare",
"from sys.dm_tran_locks",
"from alg.shadow_snapshot_state",
"from alg.shadow_runtime_metric",
"from alg.shadow_instance_heartbeat");
assertThat(diagnostics).doesNotContain("insert ", "update ", "delete ", "merge ");
}
@Test
void v3InstallUpgradeAndUninstallUseExactManifestVersionsAndObjects() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
String upgrade = normalize(resource("db/sqlserver/upgrade-shadow-v2-to-v3.sql"));
String uninstall = normalize(resource("db/sqlserver/uninstall-shadow.sql"));
assertThat(createdObjects(install)).contains(
"table alg.shadow_snapshot_state",
"table alg.cargo_space_snapshot",
"table alg.active_job_snapshot",
"table alg.shadow_inbound_request_snapshot",
"table alg.shadow_outbound_request_snapshot",
"table alg.shadow_runtime_metric",
"table alg.shadow_instance_heartbeat",
"procedure alg.usp_claim_shadow_instance",
"procedure alg.usp_refresh_shadow_snapshot");
assertThat(install).contains("'ft546wms_shadow_only', '2026-09-03-3'");
assertThat(upgrade).contains("'2026-08-12-1'").contains("'2026-09-03-3'");
assertThat(uninstall).contains("'2026-09-03-3'");
}
@Test
void upgradeValidatesTheCompleteV2AllowListBeforeAnyAlter() throws IOException {
String upgrade = normalize(resource("db/sqlserver/upgrade-shadow-v2-to-v3.sql"));
int firstAlter = upgrade.indexOf("alter ");
assertThat(firstAlter).isGreaterThanOrEqualTo(0);
V2_OBJECTS.forEach(object -> assertThat(upgrade.substring(0, firstAlter))
.as("v2 object %s must be validated before DDL", object)
.contains(object.substring(object.indexOf(' ') + 1)));
assertThat(upgrade.substring(0, firstAlter)).contains(
"object.parent_object_id = 0",
"unknown object exists in alg schema",
"expected object is missing or has wrong type");
}
@Test
void refreshCopiesInputsChecksSourceStabilityAndPublishesInsideOneTransaction() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
String refresh = install.substring(install.indexOf("create procedure alg.usp_refresh_shadow_snapshot"));
int xactAbort = refresh.indexOf("set xact_abort on");
int begin = refresh.indexOf("begin transaction");
int inputRefresh = refresh.indexOf("insert into alg.cargo_space_snapshot");
int publish = refresh.indexOf("merge alg.shadow_snapshot_state with (holdlock)");
int commit = refresh.indexOf("commit transaction");
assertThat(xactAbort).isGreaterThanOrEqualTo(0);
assertThat(begin).isGreaterThan(xactAbort);
assertThat(inputRefresh).isGreaterThan(begin);
assertThat(publish).isGreaterThan(inputRefresh);
assertThat(commit).isGreaterThan(publish);
assertThat(refresh).contains(
"from dbo.bas_cargo_space",
"from dbo.bas_stock",
"from dbo.mydo_job");
assertThat(diagnostics).doesNotContain("insert ", "update ", "delete ", "merge ");
"from dbo.mydo_job",
"from dbo.mydo_job_detail",
"from dbo.in_stockin_detail",
"from dbo.out_stockout_detail",
"hashbytes('sha2_256'",
"@pre_source_count",
"@post_source_count",
"@pre_source_signature",
"@post_source_signature",
"throw 51025",
"when matched then update set",
"snapshot_generation = @snapshot_generation");
}
@Test
void plannerViewsContainOnlyGenerationQualifiedAlgSnapshots() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
assertThat(viewBody(install, "v_inventory_item")).doesNotContain("dbo.");
assertThat(viewBody(install, "v_active_job")).doesNotContain("dbo.");
assertThat(viewBody(install, "v_space_state")).doesNotContain("dbo.");
assertThat(install).contains(
"snapshot_generation",
"where snapshot_generation = published_snapshot.snapshot_generation");
}
@Test
void v3UpgradePreservesDecisionLogAndDoesNotRefreshAtInstallTime() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
String upgrade = normalize(resource("db/sqlserver/upgrade-shadow-v2-to-v3.sql"));
assertThat(install).doesNotContain(
"if not exists (select 1 from alg.space_topology)",
"exec alg.usp_refresh_shadow_snapshot");
assertThat(upgrade).contains("decision_log").doesNotContain(
"drop table alg.decision_log",
"delete from alg.decision_log",
"truncate table alg.decision_log");
}
@Test
void claimUsesOnlyPackageHeartbeatAndA120SecondStaleThreshold() throws IOException {
String install = normalize(resource("db/sqlserver/install-shadow.sql"));
String claim = batchBody(install, "create procedure alg.usp_claim_shadow_instance");
assertThat(claim).contains(
"alg.shadow_instance_heartbeat",
"with (updlock, holdlock)",
"dateadd(second, -120",
"claimed");
assertThat(claim).doesNotContain("dbo.");
}
@Test
@@ -121,7 +264,7 @@ class ShadowSqlServerMigrationScriptTest {
"throw 51000",
"create table alg.shadow_package_manifest",
"ft546wms_shadow_only",
"2026-08-12-1");
"2026-09-03-3");
assertThat(install.indexOf("throw 51000")).isLessThan(install.indexOf("create schema alg"));
}
@@ -132,7 +275,7 @@ class ShadowSqlServerMigrationScriptTest {
assertThat(uninstall).contains(
"ft546wms_shadow_only",
"2026-08-12-1",
"2026-09-03-3",
"shadow package manifest does not match",
"unknown object exists in alg schema",
"object.parent_object_id = 0",
@@ -152,6 +295,17 @@ class ShadowSqlServerMigrationScriptTest {
"(?i)drop\\s+(table|view|procedure|trigger)\\s+([a-z0-9_]+\\.[a-z0-9_]+)"));
}
private long objectOccurrences(String sql, String object) {
String[] parts = object.split(" ");
Matcher matcher = Pattern.compile("(?i)\\b" + parts[0] + "\\s+" + parts[1].replace(".", "\\."))
.matcher(sql);
long count = 0;
while (matcher.find()) {
count++;
}
return count;
}
private Set<String> objects(String sql, Pattern pattern) {
Matcher matcher = pattern.matcher(sql);
Set<String> objects = new LinkedHashSet<>();
@@ -165,6 +319,22 @@ class ShadowSqlServerMigrationScriptTest {
return sql.toLowerCase(Locale.ROOT).replaceAll("\\s+", " ");
}
private String viewBody(String sql, String viewName) {
int start = sql.indexOf("create view alg." + viewName + " as");
assertThat(start).as("view %s", viewName).isGreaterThanOrEqualTo(0);
return batchBody(sql, "create view alg." + viewName + " as");
}
private String batchBody(String sql, String batchStart) {
int start = sql.indexOf(batchStart);
assertThat(start).as("batch %s", batchStart).isGreaterThanOrEqualTo(0);
int end = sql.indexOf(" go ", start);
if (end < 0) {
end = sql.length();
}
return sql.substring(start, end);
}
private String resource(String name) throws IOException {
try (var stream = getClass().getClassLoader().getResourceAsStream(name)) {
assertThat(stream).as("resource %s", name).isNotNull();