120 lines
7.4 KiB
PowerShell
120 lines
7.4 KiB
PowerShell
param(
|
|
[ValidateRange(1, 100)][int]$Count = 100,
|
|
[ValidateRange(10, 600)][int]$CompletionTimeoutSeconds = 180
|
|
)
|
|
|
|
$ErrorActionPreference = 'Stop'
|
|
$PSNativeCommandUseErrorActionPreference = $true
|
|
|
|
$projectRoot = Split-Path -Parent $PSScriptRoot
|
|
Set-Location -LiteralPath $projectRoot
|
|
|
|
$apiBase = 'http://localhost:58080'
|
|
$tokenUrl = 'http://localhost:58180/realms/voice-local/protocol/openid-connect/token'
|
|
$tenantId = '10000000-0000-0000-0000-000000000010'
|
|
$run = [Guid]::NewGuid().ToString('N')
|
|
$prefix = "privacy-capacity-$run"
|
|
$fixtureFileName = ".privacy-delete-$run.json"
|
|
$fixtureFile = Join-Path $projectRoot "test/load/$fixtureFileName"
|
|
$householdPublicId = $null
|
|
$householdInternalId = $null
|
|
|
|
function Invoke-Sql([string]$Sql) {
|
|
$result = $Sql | docker compose exec -T postgres psql -U voice -d voice_platform -v ON_ERROR_STOP=1 -q -t -A
|
|
if ($LASTEXITCODE -ne 0) { throw 'Privacy capacity SQL failed' }
|
|
return ($result | Out-String).Trim()
|
|
}
|
|
|
|
function Get-OwnerToken {
|
|
return (Invoke-RestMethod -Method Post -Uri $tokenUrl -ContentType 'application/x-www-form-urlencoded' -Body @{
|
|
grant_type = 'password'; client_id = 'voice-platform-web'; username = 'owner'; password = 'LocalOnly123!'; scope = 'openid'
|
|
}).access_token
|
|
}
|
|
|
|
try {
|
|
$ownerToken = Get-OwnerToken
|
|
$householdResponse = Invoke-RestMethod -Method Post -Uri "$apiBase/api/v1/households" -Headers @{
|
|
Authorization = "Bearer $ownerToken"
|
|
'X-Tenant-Id' = $tenantId
|
|
} -ContentType 'application/json' -Body (@{ name = "$prefix-household" } | ConvertTo-Json -Compress)
|
|
$householdPublicId = [string]$householdResponse.data.id
|
|
$householdEvidence = Invoke-Sql "SELECT h.id::text||'|'||h.owner_user_id::text||'|'||h.tenant_id::text FROM household h WHERE h.public_id='$householdPublicId'::uuid"
|
|
$householdParts = $householdEvidence -split '\|'
|
|
if ($householdParts.Count -ne 3) { throw "Cannot resolve capacity household: $householdEvidence" }
|
|
$householdInternalId = $householdParts[0]
|
|
$ownerInternalId = $householdParts[1]
|
|
$tenantId = $householdParts[2]
|
|
|
|
$setup = @"
|
|
INSERT INTO subject_profile(tenant_id,household_id,preferred_name,language,timezone,profile_data,conversation_preferences,created_by,updated_by)
|
|
SELECT '$tenantId'::uuid,'$householdInternalId'::uuid,'并发删除主体-'||lpad(g::text,3,'0'),'zh-CN','Asia/Shanghai',jsonb_build_object('capacity_run','$run'),'{}'::jsonb,'$ownerInternalId'::uuid,'$ownerInternalId'::uuid
|
|
FROM generate_series(1,$Count) g;
|
|
SELECT json_agg(json_build_object('subject_id',public_id::text,'preferred_name',preferred_name) ORDER BY preferred_name)::text
|
|
FROM subject_profile WHERE household_id='$householdInternalId'::uuid;
|
|
"@
|
|
$fixtureJson = ((Invoke-Sql $setup) -split "`n" | Select-Object -Last 1).Trim()
|
|
$fixtures = $fixtureJson | ConvertFrom-Json
|
|
if ($fixtures.Count -ne $Count) { throw "Expected $Count subjects but created $($fixtures.Count)" }
|
|
[IO.File]::WriteAllText($fixtureFile, $fixtureJson, [Text.UTF8Encoding]::new($false))
|
|
|
|
$startedAt = [DateTime]::UtcNow
|
|
docker run --rm -v "${projectRoot}/test/load:/scripts:ro" grafana/k6 run `
|
|
-e "API_BASE=http://host.docker.internal:58080" `
|
|
-e "ACCESS_TOKEN=$ownerToken" `
|
|
-e "FIXTURE_FILE=/scripts/$fixtureFileName" `
|
|
-e "VUS=$Count" `
|
|
/scripts/privacy-deletes.js
|
|
if ($LASTEXITCODE -ne 0) { throw 'Concurrent privacy delete submission thresholds failed' }
|
|
$submissionSeconds = ([DateTime]::UtcNow - $startedAt).TotalSeconds
|
|
|
|
$deadline = [DateTime]::UtcNow.AddSeconds($CompletionTimeoutSeconds)
|
|
do {
|
|
$evidence = Invoke-Sql @"
|
|
SELECT count(*)||'|'||
|
|
count(*) FILTER(WHERE pr.status='COMPLETED')||'|'||
|
|
count(*) FILTER(WHERE pr.status='PARTIAL_FAILED')||'|'||
|
|
count(*) FILTER(WHERE pr.status NOT IN('COMPLETED','PARTIAL_FAILED'))||'|'||
|
|
(SELECT count(*) FROM privacy_subtask ps JOIN privacy_request px ON px.id=ps.privacy_request_id JOIN subject_profile spx ON spx.id=px.subject_id WHERE spx.household_id='$householdInternalId'::uuid)||'|'||
|
|
(SELECT count(*) FROM tombstone t JOIN privacy_request px ON px.id=t.deletion_request_id JOIN subject_profile spx ON spx.id=px.subject_id WHERE spx.household_id='$householdInternalId'::uuid)
|
|
FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id
|
|
WHERE sp.household_id='$householdInternalId'::uuid;
|
|
"@
|
|
$parts = $evidence -split '\|'
|
|
if ($parts.Count -eq 6 -and [int]$parts[1] -eq $Count) { break }
|
|
Start-Sleep -Milliseconds 500
|
|
} while ([DateTime]::UtcNow -lt $deadline)
|
|
|
|
$completionSeconds = ([DateTime]::UtcNow - $startedAt).TotalSeconds
|
|
$expectedSubtasks = $Count * 6
|
|
if ($parts.Count -ne 6 -or [int]$parts[0] -ne $Count -or [int]$parts[1] -ne $Count -or [int]$parts[2] -ne 0 -or [int]$parts[3] -ne 0 -or [int]$parts[4] -ne $expectedSubtasks -or [int]$parts[5] -ne $Count) {
|
|
$failed = Invoke-Sql "SELECT pr.status||':'||count(*) FROM privacy_request pr JOIN subject_profile sp ON sp.id=pr.subject_id WHERE sp.household_id='$householdInternalId'::uuid GROUP BY pr.status ORDER BY pr.status"
|
|
throw "Privacy delete reconciliation mismatch: evidence=$evidence statuses=$failed"
|
|
}
|
|
$profileEvidence = Invoke-Sql "SELECT count(*)||'|'||count(*) FILTER(WHERE status='deleted' AND preferred_name='[已删除]' AND profile_data='{}'::jsonb AND conversation_preferences='{}'::jsonb) FROM subject_profile WHERE household_id='$householdInternalId'::uuid"
|
|
if ($profileEvidence -ne "$Count|$Count") { throw "Subject redaction mismatch: $profileEvidence" }
|
|
$subtaskEvidence = Invoke-Sql "SELECT count(*)||'|'||count(*) FILTER(WHERE ps.status IN('COMPLETED','SKIPPED')) FROM privacy_subtask ps JOIN privacy_request pr ON pr.id=ps.privacy_request_id JOIN subject_profile sp ON sp.id=pr.subject_id WHERE sp.household_id='$householdInternalId'::uuid"
|
|
if ($subtaskEvidence -ne "$expectedSubtasks|$expectedSubtasks") { throw "Privacy subtask convergence mismatch: $subtaskEvidence" }
|
|
|
|
Write-Host ("Concurrent privacy deletion passed: requests={0}, submitted_seconds={1:N2}, completed_seconds={2:N2}, profiles_redacted={3}, subtasks={4}, tombstones={5}" -f $Count, $submissionSeconds, $completionSeconds, $Count, $expectedSubtasks, $Count)
|
|
}
|
|
finally {
|
|
if ($householdInternalId) {
|
|
$cleanup = @"
|
|
BEGIN;
|
|
CREATE TEMP TABLE capacity_subject ON COMMIT DROP AS SELECT id,public_id FROM subject_profile WHERE household_id='$householdInternalId'::uuid;
|
|
CREATE TEMP TABLE capacity_privacy ON COMMIT DROP AS SELECT id,public_id FROM privacy_request WHERE subject_id IN(SELECT id FROM capacity_subject);
|
|
DELETE FROM alert_incident WHERE resource_public_id IN(SELECT public_id FROM capacity_privacy);
|
|
DELETE FROM tombstone WHERE deletion_request_id IN(SELECT id FROM capacity_privacy);
|
|
DELETE FROM outbox_event WHERE aggregate_id IN(SELECT id FROM capacity_privacy);
|
|
DELETE FROM audit_event WHERE resource_id IN(SELECT public_id FROM capacity_privacy UNION SELECT public_id FROM capacity_subject UNION SELECT '$householdPublicId'::uuid);
|
|
DELETE FROM privacy_request WHERE id IN(SELECT id FROM capacity_privacy);
|
|
DELETE FROM subject_profile WHERE id IN(SELECT id FROM capacity_subject);
|
|
DELETE FROM household_member WHERE household_id='$householdInternalId'::uuid;
|
|
DELETE FROM household WHERE id='$householdInternalId'::uuid;
|
|
COMMIT;
|
|
"@
|
|
try { Invoke-Sql $cleanup | Out-Null } catch { Write-Warning "Capacity cleanup failed: $_" }
|
|
}
|
|
if (Test-Path -LiteralPath $fixtureFile) { Remove-Item -LiteralPath $fixtureFile -Force }
|
|
}
|