137 lines
7.0 KiB
PowerShell
137 lines
7.0 KiB
PowerShell
param(
|
|
[string]$Server = 'lpc:.',
|
|
[string]$Database = 'FT546-wms',
|
|
[ValidateSet('Check','Apply','Rollback')][string]$Mode = 'Check',
|
|
[string]$ExpectedSha256,
|
|
[string]$BackupFile
|
|
)
|
|
$ErrorActionPreference='Stop'
|
|
[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false)
|
|
if($Database -ne 'FT546-wms' -and $Database -notmatch '^FT546WMS_LaneLock_Test_[a-f0-9]{32}$') {
|
|
throw 'Only FT546-wms or the dedicated scratch database is supported'
|
|
}
|
|
if($Mode -ne 'Check' -and ($ExpectedSha256 -notmatch '^[a-fA-F0-9]{64}$' -or -not $BackupFile)) {
|
|
throw 'Apply/Rollback requires ExpectedSha256 and BackupFile'
|
|
}
|
|
$old="NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL"
|
|
$new="(NULLIF(LTRIM(RTRIM(cargo.locked)), '') IS NULL OR LTRIM(RTRIM(cargo.locked)) IN ('0', 'N'))"
|
|
function Hash([string]$value) {
|
|
$sha=[Security.Cryptography.SHA256]::Create()
|
|
try { ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($value)))).Replace('-','').ToLowerInvariant() }
|
|
finally { $sha.Dispose() }
|
|
}
|
|
function Canonical([string]$value) {
|
|
if($value -notmatch '(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE\s+alg\.usp_rebuild_lane_state\b') {
|
|
throw 'Unexpected procedure header'
|
|
}
|
|
[regex]::Replace($value,'(?i)\A\s*(CREATE|ALTER)\s+PROCEDURE','ALTER PROCEDURE')
|
|
}
|
|
$cs=[Data.SqlClient.SqlConnectionStringBuilder]::new()
|
|
$cs['Data Source']=$Server; $cs['Initial Catalog']=$Database; $cs['Integrated Security']=$true
|
|
$cs['Connect Timeout']=8; $cs['Application Name']='FT546ShadowLaneLockRepair'
|
|
$cn=[Data.SqlClient.SqlConnection]::new($cs.ConnectionString)
|
|
$transaction=$null
|
|
function Command([string]$sql) {
|
|
$cmd=$cn.CreateCommand(); $cmd.CommandTimeout=10; $cmd.CommandText=$sql
|
|
if($script:transaction){$cmd.Transaction=$script:transaction}
|
|
return $cmd
|
|
}
|
|
function Exec([string]$sql) {
|
|
$cmd=Command $sql
|
|
try { [void]$cmd.ExecuteNonQuery() } finally { $cmd.Dispose() }
|
|
}
|
|
function Scalar([string]$sql) {
|
|
$cmd=Command $sql
|
|
try { $cmd.ExecuteScalar() } finally { $cmd.Dispose() }
|
|
}
|
|
function ReadDefinition {
|
|
$cmd=Command @'
|
|
SELECT m.definition,m.uses_ansi_nulls,m.uses_quoted_identifier
|
|
FROM sys.sql_modules m JOIN sys.procedures p ON p.object_id=m.object_id
|
|
WHERE p.object_id=OBJECT_ID(N'alg.usp_rebuild_lane_state',N'P');
|
|
'@
|
|
try {
|
|
$reader=$cmd.ExecuteReader()
|
|
try {
|
|
if(-not $reader.Read() -or $reader.IsDBNull(0)){throw 'Procedure missing or encrypted'}
|
|
[pscustomobject]@{definition=$reader.GetString(0);ansi=$reader.GetBoolean(1);quoted=$reader.GetBoolean(2)}
|
|
} finally {$reader.Dispose()}
|
|
} finally {$cmd.Dispose()}
|
|
}
|
|
$cn.Open()
|
|
try {
|
|
Exec 'SET NOCOUNT ON; SET XACT_ABORT ON; SET LOCK_TIMEOUT 1500; SET DEADLOCK_PRIORITY LOW;'
|
|
$instance=[string](Scalar 'SELECT CONVERT(nvarchar(128),SERVERPROPERTY(''ServerName''))')
|
|
$before=ReadDefinition
|
|
$beforeHash=Hash $before.definition
|
|
if($ExpectedSha256 -and $beforeHash -ne $ExpectedSha256){throw 'Procedure hash mismatch; recapture and review before retrying'}
|
|
$null=Canonical $before.definition
|
|
$fixedCount=([regex]::Matches($before.definition,[regex]::Escape($new))).Count
|
|
$oldCount=([regex]::Matches($before.definition,[regex]::Escape($old))).Count
|
|
if($oldCount -ne 1 -or $fixedCount -gt 1){throw 'Unexpected lock predicate; no changes made'}
|
|
if($Mode -eq 'Check') {
|
|
[ordered]@{mode=$Mode;server=$instance;database=$Database;sha256=$beforeHash;state=$(if($fixedCount -eq 1){'ALREADY_FIXED'}else{'NEEDS_FIX'});ansi=$before.ansi;quoted=$before.quoted}|ConvertTo-Json -Compress
|
|
return
|
|
}
|
|
if($Mode -eq 'Apply' -and $fixedCount -eq 1) {
|
|
[ordered]@{mode=$Mode;status='ALREADY_FIXED';sha256=$beforeHash;database_written=$false}|ConvertTo-Json -Compress
|
|
return
|
|
}
|
|
if($Mode -eq 'Apply') {
|
|
$target=(Canonical $before.definition).Replace($old,$new)
|
|
$backup=[ordered]@{
|
|
format='FT546WMS_SHADOW_LANE_LOCK_20260914';server=$instance;database=$Database
|
|
utc=[DateTime]::UtcNow.ToString('o');definition=$before.definition;original_sha256=$beforeHash
|
|
fixed_canonical_sha256=(Hash $target);ansi=$before.ansi;quoted=$before.quoted
|
|
}
|
|
# Backup is durable before beginning DDL; no existing file can be overwritten.
|
|
$stream=[IO.File]::Open($BackupFile,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
|
|
try {
|
|
$bytes=[Text.Encoding]::UTF8.GetBytes(($backup|ConvertTo-Json -Depth 4))
|
|
$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)
|
|
} finally {$stream.Dispose()}
|
|
} else {
|
|
$backup=[IO.File]::ReadAllText($BackupFile)|ConvertFrom-Json
|
|
if($backup.format -ne 'FT546WMS_SHADOW_LANE_LOCK_20260914' -or $backup.server -cne $instance -or $backup.database -cne $Database) {
|
|
throw 'Backup identity mismatch'
|
|
}
|
|
if((Hash $backup.definition) -ne $backup.original_sha256){throw 'Backup integrity mismatch'}
|
|
if($fixedCount -ne 1 -or (Hash (Canonical $before.definition)) -ne $backup.fixed_canonical_sha256) {
|
|
throw 'Rollback refuses drift from the backed-up repair'
|
|
}
|
|
$target=Canonical $backup.definition
|
|
}
|
|
# Only the alg procedure is changed. No rebuild of the currently published generation,
|
|
# no dbo DML, no application restart, no full migration and no active session is killed.
|
|
$transaction=$cn.BeginTransaction()
|
|
$lockResult=Scalar "DECLARE @r int; EXEC @r=sys.sp_getapplock @Resource=N'FT546WMS_ShadowLaneLockRepair',@LockMode='Exclusive',@LockOwner='Transaction',@LockTimeout=0; SELECT @r;"
|
|
if($lockResult -lt 0){throw 'Another lane-lock repair is running'}
|
|
$recheck=ReadDefinition
|
|
if((Hash $recheck.definition) -ne $beforeHash -or $recheck.ansi -ne $before.ansi -or $recheck.quoted -ne $before.quoted) {
|
|
throw 'Procedure changed during preparation; no changes made'
|
|
}
|
|
# Refuse unreviewed database DDL hooks that could make ALTER write business data.
|
|
if((Scalar 'SELECT COUNT(*) FROM sys.triggers WHERE parent_class=0 AND is_disabled=0') -gt 0) {
|
|
throw 'Enabled database DDL triggers require review before this repair'
|
|
}
|
|
$ansi=if($backup.ansi){'ON'}else{'OFF'}
|
|
$quoted=if($backup.quoted){'ON'}else{'OFF'}
|
|
Exec "SET ANSI_NULLS $ansi; SET QUOTED_IDENTIFIER $quoted;"
|
|
$watch=[Diagnostics.Stopwatch]::StartNew()
|
|
Exec $target
|
|
$after=ReadDefinition
|
|
if((Canonical $after.definition) -cne $target -or $after.ansi -ne $backup.ansi -or $after.quoted -ne $backup.quoted) {
|
|
throw 'Definition verification failed; rolling back'
|
|
}
|
|
$transaction.Commit();$transaction.Dispose();$transaction=$null
|
|
$watch.Stop()
|
|
[ordered]@{
|
|
mode=$Mode;status='OK';server=$instance;database=$Database;object='alg.usp_rebuild_lane_state'
|
|
before_sha256=$beforeHash;after_sha256=(Hash $after.definition);ddl_ms=$watch.ElapsedMilliseconds
|
|
backup=$BackupFile;refresh_invoked=$false;utc=[DateTime]::UtcNow.ToString('o')
|
|
}|ConvertTo-Json -Compress
|
|
} finally {
|
|
if($transaction){try{$transaction.Rollback()}finally{$transaction.Dispose()}}
|
|
$cn.Dispose()
|
|
}
|