diff --git a/Sense/README.md b/Sense/README.md index bf675da..c856bcc 100644 --- a/Sense/README.md +++ b/Sense/README.md @@ -138,3 +138,19 @@ Linux amd64 使用同版 `mediamtx_v1.19.3_linux_amd64.tar.gz`,SHA-256 为 `a7 ``` `ip_camera.env` 必须保持在 Git 忽略范围内。调试时可把 `-ObservationMinutes` 降为 1;正式 T-006 证据必须使用默认 30 分钟,且最终 `maximum_unconverged`、`final_unconverged` 都为 0。 + +## T-014 本地 16 路容量基线 + +T-014 不访问摄像头或客户网络。脚本使用隔离 PostgreSQL v5、真实 Control API、两套 MediaMTX 和 16 个独立的 FFmpeg `-c copy` 合成 publisher,验证 17 路配额拒绝、三轮批量启停、`16 → 0 → 16` Path 收敛、固定四路故障隔离/恢复,以及 30 分钟稳定性和资源观测。 + +从仓库根目录执行预检、短窗口调试和正式验收: + +```powershell +./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly +./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -ObservationMinutes 1 -OutputPath (Join-Path $env:TEMP 'yovision-t014-smoke.json') +./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal.json') +``` + +只有默认不少于 30 分钟且输出 `formal_eligible=true` 的单次完整运行可作正式证据;调试窗口固定标记为 false。脚本要求 PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 8.1.2 和 `Sense/` 模块的 Go 1.26.5,只使用随机回环端口,运行后清理临时媒体、二进制、PGDATA 和秘密文件。结果 JSON 不含 token、DSN、端口、设备 ID 或流地址。 + +2026-08-10 正式结果为 `1800.1 s / 180` 个 10 秒样本,最大/最终 `unconverged=0`、最终在线 Path 16、帧错误 0;完整版本、资源数据、失败记录和适用边界见 [`../docs/research/sense-16-stream-capacity.md`](../docs/research/sense-16-stream-capacity.md)。该结果只是本机低码率合成负载的软件基线,不代表真实 16 摄像头、客户网络、录像、AI/GPU、64/128 路或生产 SLA。 diff --git a/Sense/scripts/t014-capacity.ps1 b/Sense/scripts/t014-capacity.ps1 new file mode 100644 index 0000000..10d818e --- /dev/null +++ b/Sense/scripts/t014-capacity.ps1 @@ -0,0 +1,973 @@ +[CmdletBinding()] +param( + [string]$PgRoot = 'D:\pgsql17', + [string]$RuntimeRoot = (Join-Path ([IO.Path]::GetTempPath()) 'yovision-t014'), + [ValidateRange(1, 1440)] + [int]$ObservationMinutes = 30, + [string]$OutputPath = '', + [switch]$PreflightOnly +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' +Set-StrictMode -Version Latest + +$sourceCount = 16 +$faultIndexes = @(5, 6, 7, 8) +$sampleSeconds = 10 +$mediaMTXVersion = 'v1.19.3' +$mediaMTXSHA256 = '5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe' +$mediaMTXExecutableSHA256 = '1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05' +$migrationNames = @( + '002_bell.sql', + '003_sense.sql', + '004_privileges.sql', + '005_area_policy.sql', + '006_device_operation_outbox.sql', + '007_privileges_area_audit.sql', + '008_control_api.sql', + '009_privileges_control_api.sql', + '010_reconcile_safety.sql', + '011_privileges_reconcile_safety.sql' +) + +function ConvertTo-Base64Url([byte[]]$Bytes) { + return [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') +} + +function New-RandomBytes([int]$Count) { + $bytes = [byte[]]::new($Count) + [Security.Cryptography.RandomNumberGenerator]::Fill($bytes) + return $bytes +} + +function New-SecretToken { + return ConvertTo-Base64Url (New-RandomBytes 32) +} + +function Get-SHA256Hex([string]$Value) { + $bytes = [Text.Encoding]::UTF8.GetBytes($Value) + $digest = [Security.Cryptography.SHA256]::HashData($bytes) + return ([Convert]::ToHexString($digest)).ToLowerInvariant() +} + +function Get-FreeTcpPort([Collections.Generic.HashSet[int]]$Reserved) { + while ($true) { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + $listener.Start() + $port = ([Net.IPEndPoint]$listener.LocalEndpoint).Port + $listener.Stop() + if ($Reserved.Add($port)) { + return $port + } + } +} + +function Wait-Port([int]$Port, [int]$TimeoutSeconds = 30) { + $watch = [Diagnostics.Stopwatch]::StartNew() + while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + $client = [Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync('127.0.0.1', $Port) + if ($task.Wait(500) -and $client.Connected) { + return + } + } + catch { + } + finally { + $client.Dispose() + } + Start-Sleep -Milliseconds 250 + } + throw "local process did not listen before timeout" +} + +function Start-ManagedProcess( + [string]$Name, + [string]$FilePath, + [string[]]$Arguments, + [hashtable]$Environment = @{} +) { + $start = [Diagnostics.ProcessStartInfo]::new() + $start.FileName = $FilePath + $start.WorkingDirectory = $script:session + $start.UseShellExecute = $false + $start.CreateNoWindow = $true + $start.RedirectStandardOutput = $true + $start.RedirectStandardError = $true + foreach ($argument in $Arguments) { + $start.ArgumentList.Add($argument) + } + foreach ($entry in $Environment.GetEnumerator()) { + $start.Environment[$entry.Key] = [string]$entry.Value + } + $process = [Diagnostics.Process]::new() + $process.StartInfo = $start + if (-not $process.Start()) { + throw "failed to start required process: $Name" + } + return [pscustomobject]@{ + Name = $Name + Process = $process + Stdout = $process.StandardOutput.ReadToEndAsync() + Stderr = $process.StandardError.ReadToEndAsync() + } +} + +function Stop-ManagedProcess($Managed) { + if ($null -eq $Managed -or $null -eq $Managed.Process) { + return + } + if (-not $Managed.Process.HasExited) { + $Managed.Process.Kill() + $Managed.Process.WaitForExit(10000) | Out-Null + } +} + +function Stop-AllManagedProcesses($Items) { + $processes = @($Items | Where-Object { $null -ne $_ -and $null -ne $_.Process }) + foreach ($item in $processes) { + try { + if (-not $item.Process.HasExited) { + $item.Process.Kill() + } + } + catch { + if (-not $item.Process.HasExited) { throw } + } + } + $cleanupWatch = [Diagnostics.Stopwatch]::StartNew() + while ($cleanupWatch.Elapsed.TotalSeconds -lt 10 -and + @($processes | Where-Object { -not $_.Process.HasExited }).Count -gt 0) { + Start-Sleep -Milliseconds 100 + } + $remaining = @($processes | Where-Object { -not $_.Process.HasExited }) + if ($remaining.Count -gt 0) { + Write-Warning "managed processes did not exit within the shared cleanup deadline: $($remaining.Count)" + } + foreach ($item in $processes) { + if ($item.Process.HasExited) { + $item.Stdout.Wait(2000) | Out-Null + $item.Stderr.Wait(2000) | Out-Null + $item.Process.Dispose() + } + } +} + +function Remove-SessionDirectory([string]$Path) { + Get-ChildItem -LiteralPath $Path -Recurse -Force -ErrorAction SilentlyContinue | + ForEach-Object { $_.Attributes = 'Normal' } + (Get-Item -LiteralPath $Path -Force).Attributes = 'Directory' + $deleteWatch = [Diagnostics.Stopwatch]::StartNew() + while (Test-Path -LiteralPath $Path) { + try { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } + catch { + if ($deleteWatch.Elapsed.TotalSeconds -ge 10) { throw } + Start-Sleep -Milliseconds 200 + } + } +} + +function Assert-Alive($Managed) { + if ($null -eq $Managed -or $Managed.Process.HasExited) { + $code = if ($null -eq $Managed) { 'not-started' } else { $Managed.Process.ExitCode } + throw "required process exited: $($Managed.Name), code=$code" + } +} + +function Invoke-Checked { + param( + [Parameter(Mandatory)] [string]$FilePath, + [Parameter(ValueFromRemainingArguments)] [string[]]$Arguments + ) + & $FilePath @Arguments 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "required command failed with exit code $LASTEXITCODE" + } +} + +function Invoke-ControlRequest( + [string]$Method, + [string]$Path, + [int[]]$ExpectedStatus, + $Body = $null, + [hashtable]$ExtraHeaders = @{} +) { + $headers = @{ + Authorization = "Bearer $script:controlToken" + Accept = 'application/json' + } + foreach ($entry in $ExtraHeaders.GetEnumerator()) { + $headers[$entry.Key] = $entry.Value + } + $parameters = @{ + Method = $Method + Uri = "http://127.0.0.1:$script:senseHTTPPort$Path" + Headers = $headers + TimeoutSec = 15 + NoProxy = $true + SkipHttpErrorCheck = $true + } + if ($null -ne $Body) { + $parameters.ContentType = 'application/json' + $parameters.Body = ($Body | ConvertTo-Json -Depth 10 -Compress) + } + $response = Invoke-WebRequest @parameters + $statusCode = [int]$response.StatusCode + $statusMatches = $false + foreach ($expected in $ExpectedStatus) { + if ([int]$expected -eq $statusCode) { + $statusMatches = $true + break + } + } + if (-not $statusMatches) { + $code = 'unknown' + try { + $code = ((Get-ResponseText $response) | ConvertFrom-Json).code + } + catch { + } + throw "Control API returned unexpected status $statusCode, expected=$($ExpectedStatus -join ','), code=$code" + } + return $response +} + +function Get-ResponseText($Response) { + if ($Response.Content -is [byte[]]) { + return [Text.Encoding]::UTF8.GetString($Response.Content) + } + return [string]$Response.Content +} + +function Convert-ResponseJSON($Response) { + return ((Get-ResponseText $Response) | ConvertFrom-Json) +} + +function Get-ResponseETag($Response) { + $value = [string]$Response.Headers.ETag + if ([string]::IsNullOrWhiteSpace($value)) { + throw 'Control API response did not contain ETag' + } + return $value +} + +function Get-DevicePage { + $response = Invoke-ControlRequest 'GET' '/api/v1/sites/site-t014/devices?limit=100' @(200) + return Convert-ResponseJSON $response +} + +function Get-DeviceEntries([string[]]$DeviceIDs) { + $entries = [Collections.Generic.List[object]]::new() + foreach ($id in $DeviceIDs) { + $response = Invoke-ControlRequest 'GET' "/api/v1/sites/site-t014/devices/$id" @(200) + $entries.Add([pscustomobject]@{ + DeviceID = $id + ETag = Get-ResponseETag $response + }) + } + return @($entries) +} + +function Invoke-BatchDesiredState( + [string]$DesiredState, + [object[]]$Entries, + [string]$KeySuffix +) { + $items = @($Entries | ForEach-Object { + [ordered]@{ + device_id = $_.DeviceID + etag = $_.ETag + desired_state = $DesiredState + } + }) + $watch = [Diagnostics.Stopwatch]::StartNew() + $response = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices:batchDesiredState' @(202) ([ordered]@{ + items = $items + reason = "T-014 laboratory $DesiredState baseline" + }) @{ 'Idempotency-Key' = "t014-batch-$KeySuffix-$script:runNonce" } + $operation = Convert-ResponseJSON $response + $results = @($operation.results) + if ($operation.status -ne 'succeeded' -or $results.Count -ne $sourceCount -or + @($results | Where-Object { $_.status -ne 'succeeded' }).Count -ne 0) { + $statusCounts = @($results | Group-Object status | Sort-Object Name | + ForEach-Object { "$($_.Name):$($_.Count)" }) -join ',' + $errorCounts = @($results | Where-Object { $_.error_code } | Group-Object error_code | Sort-Object Name | + ForEach-Object { "$($_.Name):$($_.Count)" }) -join ',' + throw "batch desired-state operation was not fully successful: operation=$($operation.status), items=$($results.Count), statuses=$statusCounts, errors=$errorCounts" + } + return [ordered]@{ + status = [string]$operation.status + item_count = $results.Count + request_seconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3) + } +} + +function Get-MediaInventory([int]$APIPort) { + $config = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$APIPort/v3/config/paths/list?page=0&itemsPerPage=100" -TimeoutSec 5 -NoProxy + $runtime = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$APIPort/v3/paths/list?page=0&itemsPerPage=100" -TimeoutSec 5 -NoProxy + $items = @($runtime.items) + [UInt64]$inboundBytes = 0 + [UInt64]$frameErrors = 0 + foreach ($item in $items) { + if ($null -ne $item.inboundBytes) { + $inboundBytes += [UInt64]$item.inboundBytes + } + if ($null -ne $item.inboundFramesInError) { + $frameErrors += [UInt64]$item.inboundFramesInError + } + } + return [pscustomobject]@{ + Configured = [int]$config.itemCount + Runtime = [int]$runtime.itemCount + Online = @($items | Where-Object { $_.online -eq $true -and $_.available -eq $true }).Count + InboundBytes = $inboundBytes + FrameErrors = $frameErrors + } +} + +function Get-SenseMetric([string]$Name, [string]$Labels) { + $response = Invoke-WebRequest -Method Get -Uri "http://127.0.0.1:$script:senseHTTPPort/metrics" -TimeoutSec 5 -NoProxy + $prefix = [Regex]::Escape($Name + $Labels) + $match = [Regex]::Match((Get-ResponseText $response), "(?m)^$prefix\s+([-+0-9.eE]+)$") + if (-not $match.Success) { + throw "required Sense metric was not found: $Name" + } + return [double]::Parse($match.Groups[1].Value, [Globalization.CultureInfo]::InvariantCulture) +} + +function Wait-SenseHealth($Managed, [int]$TimeoutSeconds = 45) { + $watch = [Diagnostics.Stopwatch]::StartNew() + while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + Assert-Alive $Managed + try { + $ready = Invoke-RestMethod -Method Get -Uri "http://127.0.0.1:$script:senseHTTPPort/readyz" -TimeoutSec 2 -NoProxy + if ($ready.status -eq 'ready') { + return + } + } + catch { + } + Start-Sleep -Milliseconds 500 + } + throw 'Sense readiness endpoint did not become ready' +} + +function Wait-SourcePublishers([int]$TimeoutSeconds = 90) { + $watch = [Diagnostics.Stopwatch]::StartNew() + while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + foreach ($publisher in $script:publishers.Values) { + Assert-Alive $publisher + } + try { + $inventory = Get-MediaInventory $script:sourceAPIPort + if ($inventory.Runtime -eq $sourceCount -and $inventory.Online -eq $sourceCount) { + return [Math]::Round($watch.Elapsed.TotalSeconds, 1) + } + } + catch { + } + Start-Sleep -Seconds 1 + } + throw 'independent source publishers did not all become online' +} + +function Wait-Convergence([ValidateSet('enabled', 'disabled')] [string]$DesiredState, [int]$TimeoutSeconds = 300) { + $watch = [Diagnostics.Stopwatch]::StartNew() + $last = 'no observation' + while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + try { + $page = Get-DevicePage + $devices = @($page.items) + $inventory = Get-MediaInventory $script:productionAPIPort + $totalMetric = Get-SenseMetric 'sense_reconcile_devices' '{state="total"}' + $unconvergedMetric = Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}' + if ($DesiredState -eq 'enabled') { + $stateOK = $devices.Count -eq $sourceCount -and + @($devices | Where-Object { + $_.desired_state -ne 'enabled' -or $_.actual_state -ne 'online' -or -not $_.converged + }).Count -eq 0 + $mediaOK = $inventory.Configured -eq $sourceCount -and + $inventory.Runtime -eq $sourceCount -and $inventory.Online -eq $sourceCount + $metricsOK = $totalMetric -eq $sourceCount -and $unconvergedMetric -eq 0 + } + else { + $stateOK = $devices.Count -eq $sourceCount -and + @($devices | Where-Object { + $_.desired_state -ne 'disabled' -or $_.actual_state -ne 'offline' -or -not $_.converged + }).Count -eq 0 + $mediaOK = $inventory.Configured -eq 0 -and $inventory.Runtime -eq 0 + $metricsOK = $totalMetric -eq 0 -and $unconvergedMetric -eq 0 + } + $last = "devices=$($devices.Count), configured=$($inventory.Configured), runtime=$($inventory.Runtime), online=$($inventory.Online), unconverged=$unconvergedMetric" + if ($stateOK -and $mediaOK -and $metricsOK) { + return [Math]::Round($watch.Elapsed.TotalSeconds, 1) + } + } + catch { + $last = $_.Exception.Message + } + Start-Sleep -Seconds 1 + } + throw "devices did not reach $DesiredState convergence: $last" +} + +function Wait-PartialFault([int]$TimeoutSeconds = 120) { + $watch = [Diagnostics.Stopwatch]::StartNew() + while ($watch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + try { + $page = Get-DevicePage + $devices = @($page.items) + $online = @($devices | Where-Object { $_.actual_state -eq 'online' }).Count + $notOnline = $devices.Count - $online + if ($devices.Count -eq $sourceCount -and $online -eq ($sourceCount - $faultIndexes.Count) -and + $notOnline -eq $faultIndexes.Count) { + return [ordered]@{ + detection_seconds = [Math]::Round($watch.Elapsed.TotalSeconds, 1) + affected = $notOnline + unaffected_online = $online + } + } + } + catch { + } + Start-Sleep -Seconds 1 + } + throw 'four-source fault was not isolated to exactly four devices' +} + +function Start-Publisher([int]$Index) { + $name = 'synthetic-{0:D2}' -f $Index + return Start-ManagedProcess "publisher-$Index" $script:ffmpeg @( + '-hide_banner', '-loglevel', 'warning', '-re', '-stream_loop', '-1', + '-i', $script:fixturePath, '-map', '0:v:0', '-c', 'copy', '-an', + '-f', 'rtsp', '-rtsp_transport', 'tcp', + "rtsp://127.0.0.1:$script:sourceRTSPPort/$name" + ) +} + +function Get-ProcessResourceSample($Managed, [double]$PreviousCPU, [double]$ElapsedSeconds) { + Assert-Alive $Managed + $Managed.Process.Refresh() + $totalCPU = $Managed.Process.TotalProcessorTime.TotalSeconds + $normalizedCPU = 0.0 + if ($ElapsedSeconds -gt 0) { + $normalizedCPU = (($totalCPU - $PreviousCPU) / $ElapsedSeconds / [Environment]::ProcessorCount) * 100 + } + return [pscustomobject]@{ + TotalCPU = $totalCPU + CPUPercent = [Math]::Max(0, $normalizedCPU) + WorkingSetMiB = $Managed.Process.WorkingSet64 / 1MB + PrivateMiB = $Managed.Process.PrivateMemorySize64 / 1MB + Handles = $Managed.Process.HandleCount + } +} + +function Get-Average([Collections.Generic.List[double]]$Values) { + if ($Values.Count -eq 0) { return 0.0 } + return ($Values | Measure-Object -Average).Average +} + +function Get-Maximum([Collections.Generic.List[double]]$Values) { + if ($Values.Count -eq 0) { return 0.0 } + return ($Values | Measure-Object -Maximum).Maximum +} + +function Get-PostgresScalar([string]$Query) { + $value = & $script:psql -X -A -t -v ON_ERROR_STOP=1 -d $script:adminDatabaseDSN -c $Query + if ($LASTEXITCODE -ne 0) { + throw 'PostgreSQL observation query failed' + } + return (($value | Out-String).Trim()) +} + +function Write-Result([string]$JSON) { + if (-not [string]::IsNullOrWhiteSpace($OutputPath)) { + $resolvedOutput = [IO.Path]::GetFullPath($OutputPath) + $repoPrefix = $script:repoRoot.TrimEnd('\') + '\' + if ($resolvedOutput.StartsWith($repoPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'capacity result output must stay outside the repository' + } + $parent = Split-Path -Parent $resolvedOutput + if (-not (Test-Path -LiteralPath $parent -PathType Container)) { + throw 'capacity result output directory does not exist' + } + [IO.File]::WriteAllText($resolvedOutput, $JSON + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) + } + Write-Output $JSON +} + +$script:repoRoot = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..\..')).Path +$pgRootResolved = (Resolve-Path -LiteralPath $PgRoot).Path +$pgBin = Join-Path $pgRootResolved 'bin' +$script:initdb = Join-Path $pgBin 'initdb.exe' +$script:pgCtl = Join-Path $pgBin 'pg_ctl.exe' +$script:psql = Join-Path $pgBin 'psql.exe' +$script:createdb = Join-Path $pgBin 'createdb.exe' +$postgres = Join-Path $pgBin 'postgres.exe' +foreach ($required in @($script:initdb, $script:pgCtl, $script:psql, $script:createdb, $postgres)) { + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) { + throw 'PostgreSQL executable is missing under the selected PgRoot' + } +} +$postgresVersion = (& $postgres --version 2>&1 | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $postgresVersion -notmatch 'PostgreSQL\) 17\.10$') { + throw 'T-014 requires the frozen PostgreSQL 17.10 binaries' +} +$script:ffmpeg = (Get-Command ffmpeg -ErrorAction Stop).Source +$ffmpegVersion = (& $script:ffmpeg -version 2>&1 | Select-Object -First 1) +if ($ffmpegVersion -notmatch '^ffmpeg version 8\.1\.2') { + throw 'T-014 requires the frozen FFmpeg 8.1.2 build' +} +$senseDirectory = Join-Path $script:repoRoot 'Sense' +$goVersion = (& go '-C' $senseDirectory 'version' 2>&1 | Out-String).Trim() +if ($LASTEXITCODE -ne 0 -or $goVersion -notmatch '^go version go1\.26\.5 ') { + throw 'T-014 requires the frozen Sense Go 1.26.5 toolchain' +} + +$systemTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') +$runtimeResolved = [IO.Path]::GetFullPath($RuntimeRoot).TrimEnd('\') +$runtimePrefix = $systemTemp + '\' +if (-not $runtimeResolved.StartsWith($runtimePrefix, [StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetFileName($runtimeResolved) -notlike 'yovision-t014*') { + throw 'RuntimeRoot must be a yovision-t014 directory under the system temporary directory' +} + +$mediaDirectory = Join-Path $runtimeResolved 'mediamtx-v1.19.3' +$mediaMTX = Join-Path $mediaDirectory 'mediamtx.exe' +if ($PreflightOnly) { + $memoryGiB = 0.0 + try { $memoryGiB = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB } catch {} + Write-Result (([ordered]@{ + success = $true + preflight_only = $true + source_count = $sourceCount + observation_minutes = $ObservationMinutes + formal_eligible = $ObservationMinutes -ge 30 + mediamtx_cached = Test-Path -LiteralPath $mediaMTX -PathType Leaf + postgres_version = $postgresVersion + ffmpeg_version = [string]$ffmpegVersion + go_version = $goVersion + logical_processors = [Environment]::ProcessorCount + total_memory_gib = [Math]::Round($memoryGiB, 2) + } | ConvertTo-Json -Depth 5)) + exit 0 +} + +New-Item -ItemType Directory -Path $runtimeResolved -Force | Out-Null +if (-not (Test-Path -LiteralPath $mediaMTX -PathType Leaf)) { + $zip = Join-Path $runtimeResolved 'mediamtx_v1.19.3_windows_amd64.zip' + Invoke-WebRequest 'https://github.com/bluenviron/mediamtx/releases/download/v1.19.3/mediamtx_v1.19.3_windows_amd64.zip' -OutFile $zip + $actualHash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualHash -ne $mediaMTXSHA256) { + throw 'MediaMTX checksum mismatch' + } + New-Item -ItemType Directory -Path $mediaDirectory -Force | Out-Null + Expand-Archive -LiteralPath $zip -DestinationPath $mediaDirectory -Force +} +if ((Get-FileHash -LiteralPath $mediaMTX -Algorithm SHA256).Hash.ToLowerInvariant() -ne $mediaMTXExecutableSHA256) { + throw 'MediaMTX executable checksum mismatch' +} + +$script:runNonce = [Guid]::NewGuid().ToString('N') +$script:session = Join-Path $runtimeResolved ("session-$script:runNonce") +New-Item -ItemType Directory -Path $script:session | Out-Null +$reservedPorts = [Collections.Generic.HashSet[int]]::new() +$script:sourceRTSPPort = Get-FreeTcpPort $reservedPorts +$script:sourceAPIPort = Get-FreeTcpPort $reservedPorts +$productionRTSPPort = Get-FreeTcpPort $reservedPorts +$script:productionAPIPort = Get-FreeTcpPort $reservedPorts +$productionMetricsPort = Get-FreeTcpPort $reservedPorts +$script:senseHTTPPort = Get-FreeTcpPort $reservedPorts +$postgresPort = Get-FreeTcpPort $reservedPorts + +$sourceConfig = Join-Path $script:session 'mediamtx-source.yml' +$productionConfig = Join-Path $script:session 'mediamtx-production.yml' +$sourceLines = [Collections.Generic.List[string]]::new() +@( + 'logLevel: warn', + "rtspAddress: 127.0.0.1:$script:sourceRTSPPort", + 'rtspTransports: [tcp]', + 'api: true', + "apiAddress: 127.0.0.1:$script:sourceAPIPort", + 'metrics: false', 'rtmp: false', 'hls: false', 'webrtc: false', 'srt: false', 'moq: false', + 'paths:' +) | ForEach-Object { $sourceLines.Add($_) } +foreach ($index in 1..$sourceCount) { + $sourceLines.Add((' synthetic-{0:D2}:' -f $index)) + $sourceLines.Add(' source: publisher') +} +[IO.File]::WriteAllLines($sourceConfig, $sourceLines, [Text.UTF8Encoding]::new($false)) +[IO.File]::WriteAllLines($productionConfig, @( + 'logLevel: warn', + "rtspAddress: 127.0.0.1:$productionRTSPPort", + 'rtspTransports: [tcp]', + 'api: true', + "apiAddress: 127.0.0.1:$script:productionAPIPort", + 'metrics: true', + "metricsAddress: 127.0.0.1:$productionMetricsPort", + 'rtmp: false', 'hls: false', 'webrtc: false', 'srt: false', 'moq: false', + 'paths: {}' +), [Text.UTF8Encoding]::new($false)) + +$script:fixturePath = Join-Path $script:session 'fixture.mp4' +$senseBinary = Join-Path $script:session 'sense-api.exe' +$authPath = Join-Path $script:session 'control-auth.json' +$cursorPath = Join-Path $script:session 'cursor.key' +$pgData = Join-Path $script:session 'pgdata' +$pgLog = Join-Path $script:session 'postgres.log' +$databaseName = 'yovision_t014' +$adminRootDSN = "postgres://postgres@127.0.0.1:$postgresPort/postgres?sslmode=disable" +$script:adminDatabaseDSN = "postgres://postgres@127.0.0.1:$postgresPort/${databaseName}?sslmode=disable" +$senseDSN = "postgres://yovision_t014_sense@127.0.0.1:$postgresPort/${databaseName}?sslmode=disable" +$existing5432 = @( + Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | Sort-Object +) + +$managed = [Collections.Generic.List[object]]::new() +$script:publishers = @{} +$postgresStarted = $false +$postgresStopped = $false +$success = $false +$stage = 'preparing fixtures' +$failure = $null +$resultJSON = $null +try { + Invoke-Checked $script:ffmpeg '-hide_banner' '-loglevel' 'error' '-f' 'lavfi' '-i' 'testsrc2=size=640x360:rate=10' '-t' '30' '-c:v' 'libx264' '-preset' 'ultrafast' '-tune' 'zerolatency' '-pix_fmt' 'yuv420p' '-g' '10' '-an' '-movflags' '+faststart' '-y' $script:fixturePath + Invoke-Checked 'go' '-C' $senseDirectory 'build' '-o' $senseBinary './cmd/sense-api' + + $stage = 'starting isolated PostgreSQL' + New-Item -ItemType Directory -Path $pgData | Out-Null + Invoke-Checked $script:initdb '-D' $pgData '-U' 'postgres' '-A' 'trust' '--encoding=UTF8' '--no-locale' '--no-sync' + $serverOptions = "-h 127.0.0.1 -p $postgresPort -c listen_addresses=127.0.0.1" + # Do not pipe pg_ctl start output: postgres can inherit the pipeline handle and + # keep PowerShell waiting until the server exits. + & $script:pgCtl '-D' $pgData '-l' $pgLog '-o' $serverOptions '-w' 'start' + if ($LASTEXITCODE -ne 0) { + throw "required command failed with exit code $LASTEXITCODE" + } + $postgresStarted = $true + Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-f' (Join-Path $script:repoRoot 'deploy\postgres\001_roles.sql') + Invoke-Checked $script:createdb '-h' '127.0.0.1' '-p' ([string]$postgresPort) '-U' 'postgres' $databaseName + foreach ($pass in 1..2) { + foreach ($name in $migrationNames) { + Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $script:adminDatabaseDSN '-f' (Join-Path $script:repoRoot "deploy\postgres\$name") + } + } + Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $adminRootDSN '-c' 'CREATE ROLE yovision_t014_sense LOGIN IN ROLE sense_app' + Invoke-Checked $script:psql '-X' '-v' 'ON_ERROR_STOP=1' '-d' $script:adminDatabaseDSN '-c' "INSERT INTO bell.sites(tenant_id,id,name,max_video_channels) VALUES ('tenant-t014','site-t014','T-014 Lab',16); INSERT INTO bell.areas(tenant_id,site_id,id,name,capture_policy) VALUES ('tenant-t014','site-t014','area-t014','T-014 Area','video_allowed');" + + $stage = 'starting independent publishers' + $sourceMedia = Start-ManagedProcess 'mediamtx-source' $mediaMTX @($sourceConfig) + $managed.Add($sourceMedia) + Wait-Port $script:sourceAPIPort + foreach ($index in 1..$sourceCount) { + $script:publishers[$index] = Start-Publisher $index + $managed.Add($script:publishers[$index]) + } + $publisherReadySeconds = Wait-SourcePublishers + $productionMedia = Start-ManagedProcess 'mediamtx-production' $mediaMTX @($productionConfig) + $managed.Add($productionMedia) + Wait-Port $script:productionAPIPort + + $stage = 'starting Sense Control API' + $script:controlToken = New-SecretToken + $tokenDigest = Get-SHA256Hex $script:controlToken + $cursorKey = ConvertTo-Base64Url (New-RandomBytes 32) + $localCredential = New-SecretToken + [IO.File]::WriteAllText($authPath, (([ordered]@{ + version = 1 + principals = @([ordered]@{ + token_sha256 = $tokenDigest + subject_id = 't014-runner' + actor_type = 'service' + tenant_id = 'tenant-t014' + site_ids = @('site-t014') + permissions = @('sense.devices.read', 'sense.devices.write') + }) + } | ConvertTo-Json -Depth 6) + [Environment]::NewLine), [Text.UTF8Encoding]::new($false)) + [IO.File]::WriteAllText($cursorPath, $cursorKey + [Environment]::NewLine, [Text.UTF8Encoding]::new($false)) + $senseEnvironment = @{ + SENSE_HTTP_ADDR = "127.0.0.1:$script:senseHTTPPort" + SENSE_DB_DRIVER = 'postgres' + SENSE_DB_DSN = $senseDSN + SENSE_MEDIAMTX_URL = "http://127.0.0.1:$script:productionAPIPort" + SENSE_RECONCILE_INTERVAL = '1s' + SENSE_RECONCILE_LEASE_DURATION = '30s' + SENSE_RECONCILE_OPERATION_TIMEOUT = '20s' + SENSE_PROBE_INTERVAL = '1s' + SENSE_INSTANCE_ID = 't014-lab' + SENSE_METRICS_ENABLED = 'true' + SENSE_ORPHAN_SCAN_ENABLED = 'false' + SENSE_ONVIF_MODE = 'disabled' + SENSE_CONTROL_API_ENABLED = 'true' + SENSE_CONTROL_AUTH_FILE = $authPath + SENSE_CONTROL_CURSOR_KEY_FILE = $cursorPath + SENSE_CREDENTIAL_SYNTHETIC_ONVIF_USERNAME = 't014-local' + SENSE_CREDENTIAL_SYNTHETIC_ONVIF_PASSWORD = $localCredential + SENSE_CREDENTIAL_SYNTHETIC_RTSP_USERNAME = 't014-local' + SENSE_CREDENTIAL_SYNTHETIC_RTSP_PASSWORD = $localCredential + } + $sense = Start-ManagedProcess 'sense-api' $senseBinary @() $senseEnvironment + $managed.Add($sense) + Wait-SenseHealth $sense + + $stage = 'creating 16 disabled devices' + $deviceIDs = [Collections.Generic.List[string]]::new() + foreach ($index in 1..$sourceCount) { + $name = 'synthetic-{0:D2}' -f $index + $response = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices' @(201) ([ordered]@{ + serial_number = "t014-$name" + name = "T-014 source $index" + modality = 'video' + capabilities = @('video_capture') + area_id = 'area-t014' + endpoint_ref = "rtsp://127.0.0.1:$script:sourceRTSPPort/$name" + credential_ref = 'env://synthetic' + desired_state = 'disabled' + }) @{ 'Idempotency-Key' = "t014-create-$index-$script:runNonce" } + $created = Convert-ResponseJSON $response + $deviceIDs.Add([string]$created.id) + } + $stage = 'batch enabling 16 devices' + $firstEnableEntries = Get-DeviceEntries @($deviceIDs) + $firstEnableBatch = Invoke-BatchDesiredState 'enabled' $firstEnableEntries 'enable-1' + $firstEnableSeconds = Wait-Convergence 'enabled' + $firstEnabledInventory = Get-MediaInventory $script:productionAPIPort + + $stage = 'rejecting seventeenth enabled device' + $overflowResponse = Invoke-ControlRequest 'POST' '/api/v1/sites/site-t014/devices' @(409) ([ordered]@{ + serial_number = 't014-overflow-17' + name = 'T-014 overflow source' + modality = 'video' + capabilities = @('video_capture') + area_id = 'area-t014' + endpoint_ref = "rtsp://127.0.0.1:$script:sourceRTSPPort/synthetic-01" + credential_ref = 'env://synthetic' + desired_state = 'enabled' + }) @{ 'Idempotency-Key' = "t014-overflow-17-$script:runNonce" } + $overflowProblem = Convert-ResponseJSON $overflowResponse + $overflowCodeProperty = $overflowProblem.PSObject.Properties['code'] + if ($null -eq $overflowCodeProperty) { + $propertyNames = @($overflowProblem.PSObject.Properties.Name) -join ',' + throw "quota response did not contain a stable code field; properties=$propertyNames" + } + $overflowCode = [string]$overflowCodeProperty.Value + $pageAfterOverflow = Get-DevicePage + if ($overflowCode -ne 'quota_exceeded' -or @($pageAfterOverflow.items).Count -ne $sourceCount) { + throw 'the seventeenth enabled device did not fail closed at the site quota' + } + + $stage = 'batch disabling 16 devices' + $disableEntries = Get-DeviceEntries @($deviceIDs) + $disableBatch = Invoke-BatchDesiredState 'disabled' $disableEntries 'disable' + $disableSeconds = Wait-Convergence 'disabled' + $disabledInventory = Get-MediaInventory $script:productionAPIPort + + $stage = 'batch re-enabling 16 devices' + $secondEnableEntries = Get-DeviceEntries @($deviceIDs) + $secondEnableBatch = Invoke-BatchDesiredState 'enabled' $secondEnableEntries 'enable-2' + $secondEnableSeconds = Wait-Convergence 'enabled' + $secondEnabledInventory = Get-MediaInventory $script:productionAPIPort + + $stage = 'injecting four-source fault' + foreach ($index in $faultIndexes) { + Stop-ManagedProcess $script:publishers[$index] + } + $fault = Wait-PartialFault + foreach ($index in $faultIndexes) { + $script:publishers[$index] = Start-Publisher $index + $managed.Add($script:publishers[$index]) + } + $faultRecoverySeconds = Wait-Convergence 'enabled' + $fault.recovery_seconds = $faultRecoverySeconds + $fault.final_unconverged = 0 + + $stage = 'observing stable 16-stream capacity' + $observation = [Diagnostics.Stopwatch]::StartNew() + $targetSeconds = $ObservationMinutes * 60 + $senseCPU = [Collections.Generic.List[double]]::new() + $senseWorking = [Collections.Generic.List[double]]::new() + $sensePrivate = [Collections.Generic.List[double]]::new() + $senseHandles = [Collections.Generic.List[double]]::new() + $mediaCPU = [Collections.Generic.List[double]]::new() + $mediaWorking = [Collections.Generic.List[double]]::new() + $mediaPrivate = [Collections.Generic.List[double]]::new() + $mediaHandles = [Collections.Generic.List[double]]::new() + $databaseConnections = [Collections.Generic.List[double]]::new() + $previousSenseCPU = $sense.Process.TotalProcessorTime.TotalSeconds + $previousMediaCPU = $productionMedia.Process.TotalProcessorTime.TotalSeconds + $observationSenseCPUStart = $previousSenseCPU + $observationMediaCPUStart = $previousMediaCPU + $previousSampleAt = 0.0 + $initialInventory = Get-MediaInventory $script:productionAPIPort + $samples = 0 + $requiredSamples = [int][Math]::Ceiling($targetSeconds / $sampleSeconds) + $maximumUnconverged = 0 + while ($samples -lt $requiredSamples) { + $nextSampleAt = ($samples + 1) * $sampleSeconds + $delayMilliseconds = [int][Math]::Ceiling(($nextSampleAt - $observation.Elapsed.TotalSeconds) * 1000) + if ($delayMilliseconds -gt 0) { + Start-Sleep -Milliseconds $delayMilliseconds + } + Assert-Alive $sourceMedia + Assert-Alive $productionMedia + Assert-Alive $sense + foreach ($publisher in $script:publishers.Values) { Assert-Alive $publisher } + $elapsed = $observation.Elapsed.TotalSeconds + $delta = $elapsed - $previousSampleAt + $senseSample = Get-ProcessResourceSample $sense $previousSenseCPU $delta + $mediaSample = Get-ProcessResourceSample $productionMedia $previousMediaCPU $delta + $previousSenseCPU = $senseSample.TotalCPU + $previousMediaCPU = $mediaSample.TotalCPU + $previousSampleAt = $elapsed + $senseCPU.Add($senseSample.CPUPercent) + $senseWorking.Add($senseSample.WorkingSetMiB) + $sensePrivate.Add($senseSample.PrivateMiB) + $senseHandles.Add($senseSample.Handles) + $mediaCPU.Add($mediaSample.CPUPercent) + $mediaWorking.Add($mediaSample.WorkingSetMiB) + $mediaPrivate.Add($mediaSample.PrivateMiB) + $mediaHandles.Add($mediaSample.Handles) + $databaseConnections.Add([double](Get-PostgresScalar "SELECT count(*) FROM pg_stat_activity WHERE datname = '$databaseName'")) + $page = Get-DevicePage + $inventory = Get-MediaInventory $script:productionAPIPort + $unconverged = [int](Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}') + $maximumUnconverged = [Math]::Max($maximumUnconverged, $unconverged) + if (@($page.items).Count -ne $sourceCount -or + @($page.items | Where-Object { $_.desired_state -ne 'enabled' -or $_.actual_state -ne 'online' -or -not $_.converged }).Count -ne 0 -or + $inventory.Configured -ne $sourceCount -or $inventory.Runtime -ne $sourceCount -or + $inventory.Online -ne $sourceCount -or $unconverged -ne 0) { + throw 'stability observation detected a non-converged stream' + } + $samples++ + } + $observationSeconds = [Math]::Round($observation.Elapsed.TotalSeconds, 1) + $finalInventory = Get-MediaInventory $script:productionAPIPort + $sense.Process.Refresh() + $productionMedia.Process.Refresh() + $senseCPUTotal = $sense.Process.TotalProcessorTime.TotalSeconds - $observationSenseCPUStart + $mediaCPUTotal = $productionMedia.Process.TotalProcessorTime.TotalSeconds - $observationMediaCPUStart + $inboundDelta = [UInt64]($finalInventory.InboundBytes - $initialInventory.InboundBytes) + $frameErrorDelta = [UInt64]($finalInventory.FrameErrors - $initialInventory.FrameErrors) + $inboundMbps = if ($observationSeconds -gt 0) { ($inboundDelta * 8.0) / $observationSeconds / 1000000.0 } else { 0.0 } + $databaseSizeBytes = [Int64](Get-PostgresScalar "SELECT pg_database_size('$databaseName')") + $finalUnconverged = [int](Get-SenseMetric 'sense_reconcile_devices' '{state="unconverged"}') + if ($observationSeconds -lt $targetSeconds -or $finalUnconverged -ne 0 -or + $maximumUnconverged -ne 0 -or $samples -lt $requiredSamples) { + throw 'formal observation did not satisfy sampling and convergence requirements' + } + + $memoryGiB = 0.0 + try { $memoryGiB = (Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory / 1GB } catch {} + $gitCommit = (& git -C $script:repoRoot rev-parse HEAD | Out-String).Trim() + $success = $true + $resultJSON = [ordered]@{ + success = $true + formal_eligible = $ObservationMinutes -ge 30 + scope = 'laboratory_software_baseline' + source_count = $sourceCount + independent_publishers = $sourceCount + fixture = [ordered]@{ width = 640; height = 360; fps = 10; codec = 'H.264'; audio = $false; publish_mode = 'preencoded_copy' } + versions = [ordered]@{ + repository_commit = $gitCommit + sense_sha256 = (Get-FileHash -LiteralPath $senseBinary -Algorithm SHA256).Hash.ToLowerInvariant() + mediamtx = $mediaMTXVersion + mediamtx_package_sha256 = $mediaMTXSHA256 + mediamtx_executable_sha256 = $mediaMTXExecutableSHA256 + postgres = $postgresVersion + ffmpeg = [string]$ffmpegVersion + go = $goVersion + } + host = [ordered]@{ logical_processors = [Environment]::ProcessorCount; total_memory_gib = [Math]::Round($memoryGiB, 2) } + quota = [ordered]@{ configured = 16; accepted_devices = 16; seventeenth_rejected = $true; error_code = 'quota_exceeded' } + publishers_ready_seconds = $publisherReadySeconds + batch = [ordered]@{ + enable_first = $firstEnableBatch + disable = $disableBatch + enable_second = $secondEnableBatch + } + convergence_seconds = [ordered]@{ + enable_first = $firstEnableSeconds + disable = $disableSeconds + enable_second = $secondEnableSeconds + } + configured_paths = [ordered]@{ + after_enable_first = $firstEnabledInventory.Configured + after_disable = $disabledInventory.Configured + after_enable_second = $secondEnabledInventory.Configured + } + fault_recovery = $fault + observation = [ordered]@{ + requested_minutes = $ObservationMinutes + seconds = $observationSeconds + sample_interval_seconds = $sampleSeconds + samples = $samples + maximum_unconverged = $maximumUnconverged + final_unconverged = $finalUnconverged + final_online_paths = $finalInventory.Online + aggregate_inbound_mbps = [Math]::Round($inboundMbps, 3) + inbound_frame_errors = $frameErrorDelta + } + resources = [ordered]@{ + sense = [ordered]@{ + cpu_average_percent = [Math]::Round((Get-Average $senseCPU), 3) + cpu_peak_percent = [Math]::Round((Get-Maximum $senseCPU), 3) + cpu_total_seconds = [Math]::Round($senseCPUTotal, 3) + working_set_peak_mib = [Math]::Round((Get-Maximum $senseWorking), 2) + private_peak_mib = [Math]::Round((Get-Maximum $sensePrivate), 2) + handles_peak = [int](Get-Maximum $senseHandles) + } + mediamtx = [ordered]@{ + cpu_average_percent = [Math]::Round((Get-Average $mediaCPU), 3) + cpu_peak_percent = [Math]::Round((Get-Maximum $mediaCPU), 3) + cpu_total_seconds = [Math]::Round($mediaCPUTotal, 3) + working_set_peak_mib = [Math]::Round((Get-Maximum $mediaWorking), 2) + private_peak_mib = [Math]::Round((Get-Maximum $mediaPrivate), 2) + handles_peak = [int](Get-Maximum $mediaHandles) + } + postgres_peak_connections = [int](Get-Maximum $databaseConnections) + postgres_database_mib = [Math]::Round($databaseSizeBytes / 1MB, 2) + } + limitations = @('synthetic_sources', 'no_customer_network', 'no_recording', 'no_downstream_readers', 'no_ai_or_gpu', 'not_a_production_sla') + } | ConvertTo-Json -Depth 12 +} +catch { + $failure = "T-014 stage '$stage' failed: $($_.Exception.Message)" +} +finally { + Stop-AllManagedProcesses @($managed) + if ($postgresStarted) { + & $script:pgCtl '-D' $pgData '-m' 'fast' '-w' 'stop' | Out-Null + $postgresStopped = $LASTEXITCODE -eq 0 + } + if (-not $postgresStarted -or $postgresStopped) { + $resolvedSession = [IO.Path]::GetFullPath($script:session) + $expectedPrefix = $runtimeResolved.TrimEnd('\') + '\' + if (-not $resolvedSession.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase) -or + [IO.Path]::GetFileName($resolvedSession) -notlike 'session-*') { + throw 'refusing to clean an unexpected T-014 session path' + } + if (Test-Path -LiteralPath $resolvedSession) { + Remove-SessionDirectory $resolvedSession + } + } + else { + Write-Warning 'Temporary PostgreSQL did not stop; the session directory was retained for manual recovery' + } + $after5432 = @( + Get-NetTCPConnection -State Listen -LocalPort 5432 -ErrorAction SilentlyContinue | + Select-Object -ExpandProperty OwningProcess -Unique | Sort-Object + ) + if (($existing5432 -join ',') -ne ($after5432 -join ',')) { + throw 'the existing PostgreSQL listener on port 5432 changed during T-014' + } +} +if (-not $success) { + throw $failure +} +Write-Result $resultJSON diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 0d43d96..da5a99f 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -40,13 +40,13 @@ MVP 以默认 16 路跑通一个场景的端到端闭环;架构、数据和 UI ## 当前阶段 -当前为 **M0 指定型号实机准入与 M1 Sense 五路混合源集成均已完成,M2 正在补齐生产接入边界**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。 +当前为 **M0 指定型号实机准入、M1 Sense 五路混合源集成和 M2 本地 16 路软件基线均已完成,正在转入 M3 的 Bell/Brain 最小闭环**。后续本地开发统一使用已准入的一台海康样机,多路软件闭环使用独立合成 RTSP 源补足;真实多设备证据延后到客户/借用/租赁条件具备时执行。客户网络尚未提供,T-013 WireGuard 继续后置,不阻塞 Bell 事件存储与审计 relay 边界设计。 优先路径: 1. M0:已用一台真实样机完成首期指定“型号 + 硬件版本 + 固件”ONVIF/RTSP 准入;结论不外推为多品牌兼容,真实断网恢复证据按负责人豁免留痕。 2. M1:只在 `Sense/` 建立 MediaMTX 生产接入骨架,以 1 路准入实机 + 至少 4 路独立合成源完成五路自动建 path、探活和断线重建。 -3. M2:Control API、多租户投影、调和 fencing、孤儿安全闸已完成;下一步是隧道和至少一个站点的 16 路全流程。 +3. M2:Control API、多租户投影、调和 fencing、孤儿安全闸和本地 16 路批量收敛/30 分钟稳定基线已完成;WireGuard 等客户网络条件具备后补验。 4. M3:Brain + Bell 起步,默认 16 路端到端事件、预警、ack 与误报反馈。 5. M4–M5:64/128 路分片、管理端和第二/第三场景包。 @@ -92,3 +92,5 @@ go -C Sense build ./... ``` 日常优先运行根目录 `./init.ps1` 或 `./init.sh`,它会执行上述治理、生成、测试、静态检查和构建门禁。Sense 本地启动为 `go -C Sense run ./cmd/sense-api`;默认只监听回环地址,具体配置、MediaMTX 版本与校验方法见 [`03-tech-stack.md`](03-tech-stack.md) 和 [`../Sense/README.md`](../Sense/README.md)。 + +本机 16 路软件容量基线使用 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17`;正式证据必须使用默认 30 分钟窗口,且只证明固定低码率合成负载。结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 807a976..22f6393 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -54,6 +54,8 @@ T-011 复用同一套冻结的 Go、PostgreSQL、pgx 与 `oapi-codegen`,未增 T-012 同样未增加第三方依赖:PostgreSQL v5 使用数据库时钟租约和 fencing token 协调多实例调和/孤儿扫描;MediaMTX Path 枚举继续使用同版生成客户端。`/metrics` 由 Go 标准库输出 Prometheus 文本格式,只包含固定结果枚举、构建/实例信息和汇总 gauge/counter,不使用 tenant、Site、device 或 Path 标签。孤儿处置是 PostgreSQL 专用本地命令,默认周期任务只报告、不自动删除。 +T-014 没有增加生产依赖。Windows 容量脚本冻结并核对 Sense 模块 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3 与 FFmpeg 8.1.2;MediaMTX Windows amd64 ZIP 使用上表官方 SHA-256,解压后 `mediamtx.exe` 另固定为 `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05`。FFmpeg 只生成无人物夹具并以 16 个独立 `-c copy` 进程发布,不进入生产镜像或 Go module。正式本机结果与限制见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。 + ## 2. 外部项目边界 - MiBeeNvr:只用于 M0 隔离实验室、ONVIF兼容性和交互参考,不作为生产依赖。 @@ -115,6 +117,6 @@ python scripts/validate_harness_governance.py | PostgreSQL schema/repository | `python -m unittest discover -s tests -p "test_postgres_contract.py"`;Windows 本机再运行 `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` | migration、权限、配额判定或 PostgreSQL driver 变化 | 不需要摄像头;必须核对临时集群未使用现有 data 目录,现有 5432 listener 前后不变 | | Brain Python | 单元测试、类型/格式检查(命令待项目脚手架冻结) | mapper、判定状态机、模型接口变化 | 命中模型任务时用冻结数据集和目标硬件 | | Bell Go/Web | 后端测试 + 前端 lint/test/build(命令待脚手架冻结) | schema、RBAC、预警状态机或公共 UI 变化 | P0 流程由产品/值班角色验收 | -| 容量/分片 | 任务内基准脚本 | 16/64/128 路里程碑 | 目标网络、媒体和 GPU 硬件必需 | +| 容量/分片 | 任务内基准脚本;本地 16 路入口为 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17` | 默认 16 路软件基线,以及后续 64/128 路分片里程碑 | 本地 16 路控制面可用独立合成源;真实多路、生产 SLA、64/128 路、AI/GPU、网络与存储必须使用目标环境分别验收 | 代码脚手架落地时必须把真实命令同步到本文、`init.ps1`/`init.sh`、`00-ai-start-here.md` 和 `current-state.md`。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index d5dd393..936be98 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -79,6 +79,8 @@ Sense ── 视频流/触发信号 ──> Brain - 单分片故障不能扩散到其他分片。 - 管理端默认查看 16 路,但按 128 路设计分页、虚拟列表、筛选和批量操作。 +T-014 已在单台 Windows 主机上用隔离 PostgreSQL、真实 Control API、单个生产 MediaMTX 和 16 个独立低码率合成 publisher 完成 `16 → 0 → 16` 批量收敛、四路发布故障隔离/恢复和 `1800.1 s / 180` 样本稳定观察,最大/最终 `unconverged=0`。这只证明默认 16 路的本地软件控制面与拉流基线,不改变上述分片架构:`media_shard.max_streams=32` 仍是待 64/128 路目标环境压测的初始建议,不能从 T-014 推导单机、真实摄像头、AI/GPU、存储或生产 SLA。完整证据见 [`research/sense-16-stream-capacity.md`](research/sense-16-stream-capacity.md)。 + ## 7. 一致性与失败处理 - PostgreSQL `sense` schema 是生产期望态真相源;SQLite 只保留为 M1 本地开发/回归路径。MediaMTX、推理 worker 和对象存储是可对账的实际态。 @@ -120,7 +122,7 @@ Sense 脚手架和 PostgreSQL `001`~`011` 已实现;Brain/Bell 应用目录 - M0 不写生产代码。 - M1 只动 Sense,以 1 路 T-001 准入实机 + 至少 4 路独立合成 RTSP 源完成五路接入骨架与 MediaMTX;设备模型从此时起保持模态/能力可扩展,但不提前实现非视频适配器。真实多设备现场门禁移到 T-007,阻塞生产试点但不阻塞本地开发。 -- M2 仍以 Sense 为主,完成 16 路开通/停用、对账、多租户投影与隧道。 +- M2 仍以 Sense 为主;Control API、对账、多租户投影和本地 16 路开通/停用基线已完成,隧道等待客户网络条件后补验。 - M3 Brain 与 Bell 同时起步,事件契约首次被真实使用。 - M4/M5 再做 64/128 路分片、完整管理端和多个场景包;M6 接入雷达、门磁、按钮和可穿戴等非视频适配器。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 6cbcf3f..8aa2c51 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -28,7 +28,7 @@ - T-011:按 T-008 契约实现认证 tenant 上下文、7 个设备/operation handler、PostgreSQL 幂等收据、ETag/HMAC cursor、最多 128 项批量操作和停用精确 path 收敛;公共控制 API 默认关闭且只在 PostgreSQL 路径启用。 - T-012:以 PostgreSQL 数据库时钟租约和 fencing token 防止多实例重复调和;增加可证明的 Path 历史归属、默认只读孤儿报告、15 分钟二次确认、10% 不可绕过安全闸和低基数 `/metrics`。 - T-013(后置,尚未创建):WireGuard 边缘隧道与断网恢复;等待客户网络拓扑、地址规划和部署权限,不阻塞本地软件开发。 -- T-014:使用隔离 PostgreSQL、真实 Control API 和 16 个独立本地合成 publisher,完成批量开通/停用、局部故障恢复和资源观测;只形成实验室软件基线,不替代 T-007/T-013 或生产 SLA。 +- T-014:使用隔离 PostgreSQL、真实 Control API 和 16 个独立本地合成 publisher,完成批量开通/停用、局部故障恢复和资源观测;正式基线为 `1800.1 s / 180` 样本、最大/最终 `unconverged=0`,仍只形成实验室软件证据,不替代 T-007/T-013 或生产 SLA。 ## M3:首个 16 路端到端 MVP diff --git a/docs/current-state.md b/docs/current-state.md index 45e31e9..6005f90 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -1,11 +1,11 @@ # 当前实现状态 -> 快照日期:2026-08-07。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。 +> 快照日期:2026-08-10。只记录仓库现实与 blocker;任务实时状态到 Gitea Issue 查看。 ## 当前阶段 -- 阶段:M0 指定摄像头型号准入已完成;M1 的“一实机 + 四合成源”实验室软件闭环已通过;M2 已完成 Control API、Site/Area 准入、本地审计事务、多实例调和 fencing 和孤儿受控处置基础,但 WireGuard、16 路容量出口、五条独立真实上游和生产 SLA 尚未验收。 -- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令和低基数指标;Bell 管理服务/JWT、Outbox relay、WireGuard 和完整生产部署仍未实现。 +- 阶段:M0 指定摄像头型号准入、M1“一实机 + 四合成源”软件闭环和 M2 本地 16 路批量收敛/稳定基线已通过;客户网络尚未提供,WireGuard T-013 后置,五条独立真实上游和生产 SLA 仍未验收。下一开发重点转向 M3 的 Bell 事件存储与审计 relay 边界。 +- 生产代码:Sense 已包含可构建进程、SQLite/PostgreSQL repository、Site/Area 准入、设备操作 Outbox、标准 ONVIF SOAP/WS-Security adapter、凭据引用、MediaMTX 生成客户端、Control API v1、对账/探活、数据库租约、孤儿只读扫描/受控命令、低基数指标和可重复 16 路容量脚本;Bell 管理服务/JWT、Outbox relay、WireGuard 和完整生产部署仍未实现。 - 默认容量:16 路;单站点本阶段上限 128 路,必须横向分片。 ## 仓库现实 @@ -18,6 +18,7 @@ - MediaMTX 固定为独立二进制 `v1.19.3`,官方 OpenAPI 已按 SHA-256 vendoring,并由固定 `oapi-codegen v2.8.0` 生成客户端;手写薄封装有 create/read/delete、幂等 ensure、探活和只返回名称的受限分页枚举测试。 - T-003 对账进度与指数退避持久化,覆盖取消和 SQLite 重启恢复;T-006 增加真实 ONVIF adapter、RTSP router、实验室播种/状态工具、故障代理和五路自动验收。T-012 的普通调和不枚举孤儿;独立 PostgreSQL 扫描默认只报告,未知归属永不删除。 - T-006 正式使用 1 台准入实机和 4 个独立合成 publisher 连续观察 `1806.6 s` / 180 次采样,四类恢复均通过,最大与最终 `unconverged` 均为 0;详细证据见 `docs/research/sense-5-stream-integration.md`。 +- T-014 正式使用隔离 PostgreSQL、真实 Control API、两套 MediaMTX 和 16 个独立低码率合成 publisher,完成 17 路配额拒绝、三轮 `16 → 0 → 16` 批量收敛和固定四路故障恢复;稳定观察 `1800.1 s` / 180 次采样,最大与最终 `unconverged` 均为 0、最终在线 Path 16、帧错误 0。证据见 `docs/research/sense-16-stream-capacity.md`;不外推到真实 16 机、网络、录像、AI/GPU、64/128 路或生产 SLA。 - `docs/raw/01`~`08` 已记录需求、分析、方案、客户场景、事件比对和三系统职责。 - `docs/raw/contracts/event-v0.1.schema.json` 已冻结,并有多份示例与语义说明。 - `docs/contracts/sense-control-v1.openapi.json` 的 7 个站点作用域/operation endpoint 已由 T-011 实现:外部静态 SHA-256 主体注册表、tenant/Site scope、HMAC cursor、ETag、PostgreSQL 24 小时幂等收据和最多 128 项 batch operation 均有代码与隔离集成测试。默认 SQLite 只暴露运维探针与低基数 `/metrics`,不注册业务路由;Bell 管理服务、JWT/OIDC 和 Outbox relay 尚未实现。 @@ -56,6 +57,15 @@ python scripts/validate_harness_governance.py ./scripts/test_postgres.ps1 -PgRoot D:\pgsql17 ``` +本机 16 路软件容量预检与正式验证: + +```powershell +./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly +./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal.json') +``` + +只有默认 30 分钟且输出 `formal_eligible=true` 的单次完整运行可作正式证据;`-ObservationMinutes 1` 只用于 smoke。 + Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针和低基数 `/metrics`;探针不代表摄像头或 M1 里程碑健康。MediaMTX 获取、校验和独立启动方法见 `Sense/README.md`。 ## 当前 blocker / 待确认 @@ -67,12 +77,12 @@ Sense 默认监听 `127.0.0.1:8080`,提供 `/healthz`、`/readyz` 运维探针 - 人脸方向已延后至 M5 的 S4 成人园区候选试点;必要性/PIP 影响评估、单独同意与替代方式、合法底库来源和删除流程未完成,阻塞人脸能力上线。 - 短信/语音具体供应商未选;生产前必须选定两条独立投递路径并验证故障切换。 - Python/Savant 的精确版本、目标硬件和 Bell 前端栈尚未冻结;Sense M1 的 Go、SQLite driver、MediaMTX、生成器及生成运行时版本已在 T-003 冻结,PostgreSQL/pgx 版本已在 T-009 冻结。 -- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-012 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色、外部 Control API 安全文件与备份方案。 +- 本机现有 PostgreSQL 5432 实例使用 SCRAM 且当前开发进程没有管理员密码;T-009~T-014 不绕过认证,自动验收使用隔离临时集群。向共享/生产实例安装 migration 前仍需管理员私下提供专用数据库、登录角色、外部 Control API 安全文件与备份方案。 - 代码知识图谱在无业务代码阶段可能为空;工具不可用时使用 `rg` 处理文档与配置。 ## 下一步 -下一项建议先冻结并实施 WireGuard 边缘隧道与断网恢复,再创建 16 路批量开通/停用和分维度容量基准任务;T-012 完成不等于 M2 容量出口。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-006 的合成结果不解除 T-007,也不形成容量或生产 SLA 承诺。 +客户网络仍未提供,T-013 WireGuard 继续后置。下一项建议拆分并冻结 M3 的 Bell 不可变事件接收/存储,以及 Sense 设备操作 Outbox 到 Bell 全局审计的幂等 relay 协议与实现;两者不要在一个任务中混写。客户授权、借用或租赁条件具备后再执行 T-007 五条独立真实上游现场门禁。T-014 只解除本地默认 16 路软件基线缺口,不解除 T-007/T-013,也不形成真实多路或生产 SLA 承诺。 ## 已知风险 diff --git a/docs/research/sense-16-stream-capacity.md b/docs/research/sense-16-stream-capacity.md new file mode 100644 index 0000000..6195309 --- /dev/null +++ b/docs/research/sense-16-stream-capacity.md @@ -0,0 +1,109 @@ +# Sense 本地 16 路容量与批量收敛基线 + +> T-014 正式证据,运行日期:2026-08-10。本文只证明固定主机、固定版本和低码率合成负载下的本地软件基线,不是生产 SLA。 + +## 1. 结论 + +在 24 个逻辑处理器、15.78 GiB 内存的 Windows 主机上,提交 `d029067aa750ad75a2d79ef8e6c51b6bad55096d` 使用隔离 PostgreSQL、真实 Sense Control API、两套独立 MediaMTX 和 16 个独立 FFmpeg publisher 完成正式验收: + +- 16 个 disabled 视频设备创建后,三轮 16 项批量操作全部成功,生产 MediaMTX 配置 Path 数按 `16 → 0 → 16` 收敛。 +- 16 路 enabled 后,第 17 路 enabled 创建稳定返回 `quota_exceeded`,设备台账仍为 16 项;disabled 项不冒充已占用视频通道。 +- 同时停止固定第 5~8 路后,精确 4 路受影响、其余 12 路保持在线;恢复后 5.1 秒回到 16 路在线和 `unconverged=0`。 +- 正式稳定窗口为 1800.1 秒,每 10 秒绝对节拍采样,共 180 个样本;最大与最终未收敛数均为 0,最终在线 Path 为 16,入站帧错误增量为 0。 + +因此,默认 16 路的 Sense/PostgreSQL/MediaMTX 控制面与拉流闭环具备可重复的本地软件基线。该结论不证明 16 台真实摄像头、客户网络、录像、下游观看、AI 解码/推理、GPU、64/128 路分片或生产 SLA,也不解除 T-007/T-013。 + +## 2. 固定环境与负载 + +| 项目 | 正式值 | +| --- | --- | +| 仓库提交 | `d029067aa750ad75a2d79ef8e6c51b6bad55096d` | +| Sense 二进制 SHA-256 | `e6fa01991152cf7e9f2a1e420422a12d474dfe7be10530ff2924671d46cc7871` | +| Go | `go1.26.5 windows/amd64`(在 `Sense/` 模块上下文读取) | +| PostgreSQL | `17.10`,单次运行隔离临时集群 | +| MediaMTX | `v1.19.3` | +| MediaMTX Windows amd64 ZIP SHA-256 | `5d82148d1032a6a190d9909a2997d9989457aaadf49af87dd02cd4512d31bebe` | +| MediaMTX EXE SHA-256 | `1cda85249312cb9463f9f94c5a712b9f160c9af3fd9490f0d4723911d7880e05` | +| FFmpeg | `8.1.2-full_build-www.gyan.dev` | +| 主机 | Windows,24 logical processors,15.78 GiB memory | +| 单路夹具 | 640×360、10 fps、H.264、无音频、无人物 | +| 发布方式 | 16 个独立 FFmpeg 进程,各自循环预编码夹具并以 `-c copy` 发布独立 RTSP Path | + +预编码 copy 发布是为了不把 16 路软件编码负载混入 Sense/MediaMTX 基线。它仍产生 16 个可独立停止和恢复的发布进程,但不能代表真实摄像头编码器、复杂 GOP、高码率、音频或公网抖动。 + +## 3. 运行方法与安全边界 + +从仓库根目录执行: + +```powershell +./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -PreflightOnly +./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal-final.json') +``` + +调试可显式传入 `-ObservationMinutes 1`,但输出固定标记 `formal_eligible=false`,不能作为正式验收。 + +脚本在系统临时目录生成媒体、Sense 二进制、凭据文件和 PGDATA,只绑定随机回环端口;migration 读取 `001`~`011` 并重复回放,不读取或修改 `D:\pgsql17\data`。结果 JSON 不包含 token、DSN、临时端口、设备 ID、Path/source URI 或凭据引用。正式退出后复查 session、FFmpeg、MediaMTX 和 Sense 数量均为 0,本机现有 5432 listener 前后未变化。 + +## 4. 功能与时序结果 + +| 门禁 | 正式结果 | +| --- | ---: | +| 16 个独立 publisher 就绪 | 7.6 s | +| 首轮 enable batch | `succeeded`,16 项,请求 0.041 s | +| 首轮 enable 收敛 | 3.1 s | +| 第 17 路 enabled | 拒绝,`quota_exceeded` | +| disable batch | `succeeded`,16 项,请求 0.015 s | +| disable 收敛 | 1.0 s | +| 第二轮 enable batch | `succeeded`,16 项,请求 0.046 s | +| 第二轮 enable 收敛 | 3.1 s | +| 配置 Path | `16 → 0 → 16` | +| 四路故障检测 | 1.0 s,影响 4 路,其余 12 路在线 | +| 四路恢复 | 5.1 s,最终未收敛 0 | + +批量启停使用真实 `/api/v1/sites/{site_id}/devices:batchDesiredState`,每轮提交前重新读取各设备最新 ETag;不能用创建响应中的旧 ETag 与后台调和竞速。每轮 operation 必须为 `succeeded` 且 16 个逐项结果全部成功。 + +## 5. 30 分钟稳定性与资源观测 + +| 指标 | 正式结果 | +| --- | ---: | +| 观察时长 | 1800.1 s | +| 采样节拍 / 样本 | 10 s / 180 | +| 最大未收敛数 | 0 | +| 最终未收敛数 | 0 | +| 最终在线 Path | 16 | +| 聚合入站码率 | 15.443 Mbps | +| 入站帧错误增量 | 0 | +| Sense CPU 总时间 | 5.594 s | +| Sense working set 峰值 | 69.99 MiB | +| Sense private bytes 峰值 | 57.58 MiB | +| Sense handle 峰值 | 228 | +| 生产 MediaMTX CPU 总时间 | 26.812 s | +| 生产 MediaMTX working set 峰值 | 45.27 MiB | +| 生产 MediaMTX private bytes 峰值 | 79.23 MiB | +| 生产 MediaMTX handle 峰值 | 295 | +| PostgreSQL 业务库连接峰值 | 3 | +| PostgreSQL 数据库大小 | 8.90 MiB | + +归一化 CPU 平均值与峰值在 24 逻辑处理器主机上四舍五入到三位小数后为 0.000%,因此报告保留进程 CPU 总秒数作为低负载证据;不能据此推导通用硬件下限。 + +## 6. 失败记录与工具修正 + +正式结论只取最后一次完整成功运行。研发过程中保留以下失败,不拼接为成功证据: + +1. MediaMTX 发布 ZIP 与解压后 EXE 的 SHA-256 不同,脚本分别冻结并校验两者。 +2. disabled 设备不消耗视频通道;第 17 路门禁改为在 16 路 enabled 后创建 enabled 设备,符合实际配额语义。 +3. PowerShell 读取 `application/problem+json` 时可能得到 byte array;脚本显式按 UTF-8 解码后校验稳定错误码。 +4. 把 `pg_ctl start` 接入 PowerShell 输出管道会让 PostgreSQL 继承管道句柄并阻塞;启动改为直接执行并检查退出码。 +5. Windows 退出时 `sense-api.exe` 曾短暂持有文件锁;清理改为共享截止时间内终止全部进程、完成异步输出读取、释放 `Process` 句柄并重试删除。 +6. “固定睡 10 秒再查询”会把查询耗时累计进采样间隔,30 分钟不足 180 个样本;改为按绝对时间点调度第 1~180 个样本。 +7. 根目录 Go launcher 为 1.23.0,但 `Sense/go.mod` 冻结并实际构建使用 1.26.5;版本证据改为在 `Sense/` 模块上下文读取。 +8. 首轮 batch 曾复用创建响应 ETag,与后台调和的资源版本更新竞速;现在三轮 batch 都在提交前读取最新 ETag,失败诊断只输出状态/错误码计数。 + +每次失败都没有生成正式成功 JSON,且相关临时 PostgreSQL、媒体和 Sense 进程已停止,单次 session 已清理。 + +## 7. 后续验收 + +- T-007 仍需至少 5 条独立真实摄像头上游与现场网络,才能形成真实多路故障隔离和生产试点证据。 +- T-013 等客户网络拓扑、地址规划和部署权限具备后,再验证 WireGuard 与断网补传。 +- 64/128 路必须在后续任务中分别验证媒体分片、带宽、解码、AI/GPU、证据存储和单分片故障域;本报告不外推这些结论。 +- 进入 M3 前应优先冻结并实现 Bell 事件不可变存储,以及 Sense Outbox 到 Bell 全局审计的 relay 边界。 diff --git a/docs/tasks/T-014.md b/docs/tasks/T-014.md index 4b90bb3..8411822 100644 --- a/docs/tasks/T-014.md +++ b/docs/tasks/T-014.md @@ -3,12 +3,12 @@ id: T-014 title: 建立 Sense 本地 16 路批量收敛与容量基线 phase: 2 deps: [T-012] -status: TODO +status: DONE created: 2026-08-08 issue: 47 -context_ref: null -claim_branch: null -work_branch: null +context_ref: 8ebb8ced492c614106e766192c59b7f13fb4ad0f +claim_branch: claims/T-014 +work_branch: agent/codex/T-014 write_paths: - docs/tasks/T-014.md - Sense/README.md @@ -38,7 +38,7 @@ T-006 已证明一台准入实机与四条独立合成源可以完成五路实 1. 新增 `Sense/scripts/t014-capacity.ps1` 一键基准:校验冻结的 PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 与 Go,使用随机回环端口和系统临时目录启动隔离 PostgreSQL、源 MediaMTX、生产 MediaMTX 与 Sense。migration 只读取并重放现有 `001`~`011`,不修改或连接本机已有 `D:\pgsql17\data`/5432。 2. 先用 FFmpeg 生成 640×360、10 fps、H.264、无音频、无人物的短循环媒体夹具,再启动 16 个独立 FFmpeg `-c copy` RTSP publisher,每路有独立进程与 Path,可单独停止/恢复。预编码发布避免把 16 路软件编码开销计入 Sense/MediaMTX 基线;不把同一 publisher fan-out 冒充独立源。 -3. 在临时 PostgreSQL 中建立固定实验室 Tenant/Site/Area,Site 配额精确为 16;运行时生成 Control API opaque token、SHA-256 注册表和 cursor key,文件只存在于临时目录。通过真实 HTTP API 创建 16 个 disabled 视频设备,并验证第 17 个同站点设备稳定返回 `quota_exceeded` 且不写入台账。 +3. 在临时 PostgreSQL 中建立固定实验室 Tenant/Site/Area,Site 配额精确为 16;运行时生成 Control API opaque token、SHA-256 注册表和 cursor key,文件只存在于临时目录。通过真实 HTTP API 创建 16 个 disabled 视频设备、批量启用并收敛后,验证第 17 个 enabled 同站点设备稳定返回 `quota_exceeded` 且不写入台账;disabled 台账项不冒充已占用通道。 4. 使用真实 `devices:batchDesiredState` 和每台设备最新 ETag 执行 16 路批量启用、批量停用、再次启用;每次要求 operation 的 16 个结果全部成功。分别验证生产 MediaMTX 配置/运行 Path 为 16、0、16,设备最终状态与 generation/observed generation 收敛,`unconverged=0`。 5. 在再次启用后同时停止固定四个 publisher,要求能观察到局部离线/未收敛且其余路径继续在线;恢复四个独立进程后自动回到 16 路在线和 `unconverged=0`。不得通过改数据库、人工编辑 MediaMTX 配置或重建进程集合掩盖失败。 6. 正式稳定观察默认 30 分钟、每 10 秒采样。记录 Sense 与生产 MediaMTX 的归一化 CPU、working set、private bytes、handle 峰值,PostgreSQL连接峰值,以及 MediaMTX aggregate inbound bytes/吞吐、在线 Path、Sense 低基数指标和未收敛数。功能正确性有硬门禁;机器相关资源值只记录,不设置伪通用阈值。 @@ -47,14 +47,14 @@ T-006 已证明一台准入实机与四条独立合成源可以完成五路实 ## 不可变约束 -- 阈值 / 数值边界:正式源数精确为 16;Site 默认配额 16、允许范围 1~128,17 路在该 Site 必须拒绝,129 仍非法;批量 API 上限保持 128。正式观察不少于 30 分钟、采样周期 10 秒;局部故障固定 4 路且不得影响其余 12 路。短窗口只能 smoke。 +- 阈值 / 数值边界:正式源数精确为 16;Site 默认配额 16、允许范围 1~128,已有 16 路 enabled 时第 17 路 enabled 必须拒绝,129 仍非法;批量 API 上限保持 128。正式观察不少于 30 分钟、采样周期 10 秒;局部故障固定 4 路且不得影响其余 12 路。短窗口只能 smoke。 - 判定式 / 状态转换:16 个设备先 disabled 创建,随后 `16 enabled → 16 disabled → 16 enabled`;每轮批量结果必须全部成功。启用完成必须配置 Path=16、运行在线 Path=16、`unconverged=0`;停用完成必须配置 Path=0、设备 offline 且各自 observed generation 追平。四路故障恢复后必须再次满足 16 路在线和 `unconverged=0`。 - 安全边界:只绑定随机回环端口;运行时 token/cursor key/DSN/临时路径不进入 Git、普通日志或结果摘要。脚本不得读取 `ip_camera.env`,不得访问客户网络或现有 PostgreSQL data/service;递归清理前必须验证目标位于系统临时目录且名称含 T-014 随机前缀。 - 既有契约:不修改 Control OpenAPI v1、审计 v1/v2、MediaMTX 生成代码或数据库 migration;不把 16 写入业务数组/协议硬上限。T-001/T-006 的实机结论、T-007 真实五上游门禁与未来 T-013 WireGuard 均保持独立。 ## 验收要点 -- 任务相关验证:先以短窗口运行脚本 smoke,再执行默认不少于 30 分钟的正式 16 路基准;验证 16 个独立 publisher、17 路配额拒绝、三轮批量状态、16/0/16 Path、四路故障隔离/恢复、最终 `unconverged=0` 和资源摘要。`python -m unittest tests.test_sense_capacity_contract` 锁定脚本安全/语义。 +- 任务相关验证:先以短窗口运行脚本 smoke,再执行默认不少于 30 分钟的正式 16 路基准;验证 16 个独立 publisher、第 17 路 enabled 配额拒绝、三轮批量状态、16/0/16 Path、四路故障隔离/恢复、最终 `unconverged=0` 和资源摘要。`python -m unittest discover -s tests -p "test_sense_capacity_contract.py"` 锁定脚本安全/语义。 - 完整门禁:`./init.ps1`、三条 Python 治理命令、`go -C Sense test ./...`、`go -C Sense vet ./...`、`go -C Sense build ./...`、`./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 和 `git diff --check` 全部通过;本任务不修改 Go 业务代码,但仍回归全部 Sense 包和 PostgreSQL v5。 - 人工 / 设备验收:不需要摄像头、GPU、客户网络或客户数据。任务所有者核对 30 分钟原始 JSON 摘要、进程独立性和报告;真实摄像头/网络验收仍由 T-007/T-013 完成。 - 构建产物:`Sense/scripts/t014-capacity.ps1`、`docs/research/sense-16-stream-capacity.md`、静态契约测试与同步文档;临时二进制、媒体、数据库、凭据和日志运行后删除,不提交。 @@ -74,6 +74,37 @@ T-006 已证明一台准入实机与四条独立合成源可以完成五路实 ## 执行记录 +### 2026-08-10 正式 16 路基线 + +- 在提交 `d029067aa750ad75a2d79ef8e6c51b6bad55096d` 执行 `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -OutputPath (Join-Path $env:TEMP 'yovision-t014-formal-final.json')`,成功输出 `formal_eligible=true`。固定环境为 Go 1.26.5、PostgreSQL 17.10、MediaMTX v1.19.3、FFmpeg 8.1.2;主机为 24 logical processors / 15.78 GiB。 +- 16 个独立 publisher 在 `7.6 s` 就绪;三轮 16 项 batch 均 `succeeded`,请求分别为 `0.041 s / 0.015 s / 0.046 s`,收敛分别为 `3.1 s / 1.0 s / 3.1 s`,配置 Path 为 `16 → 0 → 16`。16 路 enabled 后第 17 路 enabled 返回 `quota_exceeded`。 +- 固定停止第 5~8 路后 `1.0 s` 检测到精确 4 路受影响,其余 12 路在线;恢复 `5.1 s` 后回到 16 路在线。稳定窗口 `1800.1 s`、10 秒绝对节拍、180 个样本,最大/最终 `unconverged=0`,聚合入站 `15.443 Mbps`,帧错误 0。 +- 资源记录:Sense CPU 总计 `5.594 s`、working set 峰值 `69.99 MiB`、private 峰值 `57.58 MiB`、handle 峰值 228;生产 MediaMTX 分别为 `26.812 s / 45.27 MiB / 79.23 MiB / 295`;PostgreSQL 连接峰值 3,数据库 `8.90 MiB`。详细边界与失败记录见 `docs/research/sense-16-stream-capacity.md`。 +- 正式退出后复查 T-014 session、FFmpeg、MediaMTX 和 Sense 数量均为 0;结果不含 token、DSN、端口、设备 ID、Path/source URI 或客户信息。本机现有 5432 listener 未被停止或修改。 + +### 2026-08-10 完整门禁 + +- PowerShell parser 与 `python -m unittest discover -s tests -p "test_sense_capacity_contract.py" -v` 通过,T-014 静态契约 6/6 通过。 +- `./init.ps1` 通过:58 个 Python 测试成功,MediaMTX/Control API 生成漂移检查、全部 Sense Go 包测试、`go vet` 与 `go build` 均成功。 +- `./scripts/test_postgres.ps1 -PgRoot D:\pgsql17` 通过:PostgreSQL 17.10 隔离集群重放 `001`~`011`,权限断言与 `yovision/sense/internal/store` 真实 PostgreSQL 测试成功,临时端口和 PGDATA 已清理。 +- `python scripts/validate_agent_context.py`、`python -m unittest discover -s tests -p "test_*.py"`(58 项)、`python scripts/validate_harness_governance.py` 与 `git diff --check` 均通过。生成文件只有现有 LF→CRLF 提示,没有内容漂移或任务范围外修改。 + +### 2026-08-08~2026-08-10 基准工具失败与修正 + +- 正式结论没有拼接失败样本。依次修复并回归:MediaMTX ZIP/EXE 双指纹、disabled 不占配额、problem+json byte array 解码、`pg_ctl start` 管道句柄继承、Windows EXE 文件锁与进程句柄释放、固定 sleep 导致 30 分钟不足 180 样本、根目录 Go launcher 与 Sense 1.26.5 工具链口径不一致,以及首轮 batch 复用创建 ETag 的调和竞速。 +- 中断或失败运行均未输出正式成功 JSON;已核验并停止临时 PostgreSQL,清除精确的单次 session。最终脚本按绝对 10 秒节拍取得 180 个样本,三轮 batch 提交前都读取最新 ETag,清理使用共享截止时间并释放异步输出/进程句柄。 + +### 2026-08-08 一分钟端到端 smoke + +- `./Sense/scripts/t014-capacity.ps1 -PgRoot D:\pgsql17 -ObservationMinutes 1` 通过并输出 `formal_eligible=false`:16 个独立 publisher 全部在线,三轮 16 项 batch 均 `succeeded`,配置 Path 为 `16 → 0 → 16`,第 17 路 enabled 返回 `quota_exceeded` 且台账仍为 16 项。 +- 首次启用、停用、再次启用分别在 `3.1 s`、`1.0 s`、`3.1 s` 收敛;同时停止四路后 `1.0 s` 观察到精确四路受影响、其余 12 路在线,恢复 `5.2 s`。稳定观察 `60.8 s`/6 次采样,最大与最终 `unconverged` 均为 0,最终在线 Path 16,aggregate inbound `15.432 Mbps`,帧错误 0。 +- 保留三类脚本修正记录:发布 ZIP 与 EXE 需要不同 SHA-256;disabled 台账不消耗视频运行配额,故第 17 路门禁必须在 16 路 enabled 后验证;PowerShell 对 `application/problem+json` 返回 byte array,需显式 UTF-8 解码后校验稳定 `code`。每次失败均停止并清理临时 PostgreSQL/进程,未拼接成功片段。 + +### 2026-08-08 领取任务 + +- dispatcher `ila` 将 Issue #47 分配给 `codex`;`context_ref` 为 `8ebb8ced492c614106e766192c59b7f13fb4ad0f`,claim 为 `claims/T-014`,工作分支为 `agent/codex/T-014`。 +- 已读回 Issue `status/doing`、assignee、dispatcher 发布的完整 CLAIM 与两个分支 SHA;接受 frontmatter 全部写路径。T-007 继续 waiting,本任务期间不恢复,当前无活跃写路径冲突。 + ### 2026-08-08 Gitea 映射 - 任务规格通过 PR #46 合入默认分支后创建唯一主 Issue #47;本次只回填双向映射,映射合入并读回前不领取任务。 diff --git a/tests/test_sense_capacity_contract.py b/tests/test_sense_capacity_contract.py new file mode 100644 index 0000000..8082e9e --- /dev/null +++ b/tests/test_sense_capacity_contract.py @@ -0,0 +1,76 @@ +import re +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "Sense" / "scripts" / "t014-capacity.ps1" +TASK = ROOT / "docs" / "tasks" / "T-014.md" + + +class SenseCapacityContractTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.script = SCRIPT.read_text(encoding="utf-8") + cls.task = TASK.read_text(encoding="utf-8") + + def test_uses_sixteen_independent_copy_publishers(self): + self.assertIn("$sourceCount = 16", self.script) + self.assertIn("foreach ($index in 1..$sourceCount)", self.script) + self.assertIn("'-c', 'copy'", self.script) + self.assertIn("$faultIndexes = @(5, 6, 7, 8)", self.script) + self.assertNotIn("ip_camera.env", self.script) + + def test_exercises_real_control_api_and_quota_boundary(self): + self.assertIn("/api/v1/sites/site-t014/devices:batchDesiredState", self.script) + self.assertIn("desired_state = 'disabled'", self.script) + self.assertIn("$firstEnableEntries = Get-DeviceEntries @($deviceIDs)", self.script) + self.assertIn("t014-overflow-17", self.script) + self.assertIn("quota_exceeded", self.script) + self.assertIn("'enabled'", self.script) + self.assertIn("'disabled'", self.script) + + def test_formal_window_and_functional_gates_are_fixed(self): + self.assertRegex(self.script, r"\[int\]\$ObservationMinutes = 30") + self.assertIn("$sampleSeconds = 10", self.script) + self.assertIn("$requiredSamples = [int][Math]::Ceiling($targetSeconds / $sampleSeconds)", self.script) + self.assertIn("$nextSampleAt = ($samples + 1) * $sampleSeconds", self.script) + self.assertIn("formal_eligible = $ObservationMinutes -ge 30", self.script) + self.assertIn("Wait-Convergence 'enabled'", self.script) + self.assertIn("Wait-Convergence 'disabled'", self.script) + self.assertIn("maximum_unconverged", self.script) + self.assertIn("go1\\.26\\.5", self.script) + self.assertIn("& go '-C' $senseDirectory 'version'", self.script) + + def test_isolated_postgres_and_cleanup_are_guarded(self): + for migration in range(2, 12): + self.assertRegex(self.script, rf"'{migration:03d}_[^']+\.sql'") + self.assertIn("PostgreSQL\\) 17\\.10$", self.script) + self.assertIn("yovision-t014", self.script) + self.assertIn("[IO.Path]::GetFileName($resolvedSession) -notlike 'session-*'", self.script) + self.assertIn("the existing PostgreSQL listener on port 5432 changed", self.script) + self.assertNotRegex(self.script, r"Invoke-Checked \$script:pgCtl .* 'start'") + self.assertIn("$cleanupWatch.Elapsed.TotalSeconds -lt 10", self.script) + self.assertIn("$item.Process.Dispose()", self.script) + self.assertIn("$deleteWatch.Elapsed.TotalSeconds -ge 10", self.script) + + def test_runtime_secrets_and_sensitive_inventory_are_not_reported(self): + self.assertIn("New-SecretToken", self.script) + self.assertIn("token_sha256", self.script) + self.assertIn("capacity result output must stay outside the repository", self.script) + result_block = self.script.split("$resultJSON = [ordered]@{", 1)[1] + for forbidden in ("controlToken", "senseDSN", "adminDatabaseDSN", "sourceRTSPPort", "endpoint_ref", "credential_ref"): + self.assertNotIn(forbidden, result_block) + self.assertNotRegex(self.script, r"(?i)rtsp://[^\s\"']+:[^\s\"']+@") + + def test_task_preserves_product_boundaries(self): + self.assertIn("deps: [T-012]", self.task) + self.assertIn("默认配额 16、允许范围 1~128", self.task) + self.assertIn("129 仍非法", self.task) + self.assertIn("不代表 16 台真实摄像头", self.task) + self.assertIn("不解除 T-007", self.task) + self.assertIn("WireGuard T-013 暂缓", self.task) + + +if __name__ == "__main__": + unittest.main()