Merge pull request '[T-014] 建立 Sense 本地 16 路批量收敛与容量基线' (#49)
Harness governance / validate (push) Has been cancelled
Harness governance / validate (push) Has been cancelled
Closes #47
This commit was merged in pull request #49.
This commit is contained in:
@@ -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。
|
||||
|
||||
@@ -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
|
||||
@@ -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)。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 接入雷达、门磁、按钮和可穿戴等非视频适配器。
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+15
-5
@@ -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 承诺。
|
||||
|
||||
## 已知风险
|
||||
|
||||
|
||||
@@ -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 边界。
|
||||
+38
-7
@@ -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;本次只回填双向映射,映射合入并读回前不领取任务。
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user