feat(sense): complete T-006 five-stream integration
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-07 16:33:35 +08:00
parent 08692e33e9
commit 9567838045
19 changed files with 1658 additions and 14 deletions
+89
View File
@@ -25,6 +25,25 @@ type ReconcileCandidate struct {
NextAttempt *time.Time
}
type DeviceConvergence struct {
ID string `json:"id"`
PathName string `json:"path_name"`
DesiredState device.DesiredState `json:"desired_state"`
ActualState device.ActualState `json:"actual_state"`
Generation int64 `json:"generation"`
ObservedGeneration int64 `json:"observed_generation"`
FailureCount int `json:"failure_count"`
NextAttemptAt *time.Time `json:"next_attempt_at,omitempty"`
LastErrorCode string `json:"last_error_code,omitempty"`
Converged bool `json:"converged"`
}
type ConvergenceSnapshot struct {
Total int `json:"total"`
Unconverged int `json:"unconverged"`
Devices []DeviceConvergence `json:"devices"`
}
type SQLite struct {
db *sql.DB
}
@@ -521,6 +540,76 @@ func (s *SQLite) UpdateActualState(ctx context.Context, id string, state device.
return nil
}
// RequestReconcile invalidates the observed generation without changing the
// desired state or retry backoff. Runtime probes use it when MediaMTX loses a
// configured path, including after a MediaMTX process restart.
func (s *SQLite) RequestReconcile(ctx context.Context, id string, now time.Time) error {
result, err := s.db.ExecContext(ctx, `
UPDATE sense_reconcile_state
SET observed_generation = 0, updated_at = ?
WHERE device_id = ?`, formatTime(now), id)
if err != nil {
return fmt.Errorf("request device reconciliation: %w", err)
}
if affected, _ := result.RowsAffected(); affected != 1 {
return ErrNotFound
}
return nil
}
// ConvergenceSnapshot returns only identifiers, state and counters. Endpoint
// and credential references are deliberately excluded from diagnostics.
func (s *SQLite) ConvergenceSnapshot(ctx context.Context) (ConvergenceSnapshot, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT d.id, d.path_name, d.desired_state, d.actual_state, d.generation,
r.observed_generation, r.failure_count, r.next_attempt_at, r.last_error_code
FROM sense_devices d
JOIN sense_reconcile_state r ON r.device_id = d.id
WHERE d.desired_state = 'enabled'
AND EXISTS (
SELECT 1 FROM sense_device_capabilities c
WHERE c.device_id = d.id AND c.capability = 'video_capture'
)
ORDER BY d.id`)
if err != nil {
return ConvergenceSnapshot{}, fmt.Errorf("query convergence snapshot: %w", err)
}
defer rows.Close()
snapshot := ConvergenceSnapshot{Devices: make([]DeviceConvergence, 0)}
for rows.Next() {
var value DeviceConvergence
var nextAttempt, lastError sql.NullString
if err := rows.Scan(
&value.ID, &value.PathName, &value.DesiredState, &value.ActualState,
&value.Generation, &value.ObservedGeneration, &value.FailureCount,
&nextAttempt, &lastError,
); err != nil {
return ConvergenceSnapshot{}, fmt.Errorf("scan convergence snapshot: %w", err)
}
if nextAttempt.Valid {
parsed, parseErr := parseTime(nextAttempt.String)
if parseErr != nil {
return ConvergenceSnapshot{}, parseErr
}
value.NextAttemptAt = &parsed
}
if lastError.Valid {
value.LastErrorCode = lastError.String
}
value.Converged = value.ObservedGeneration == value.Generation &&
value.FailureCount == 0 && value.ActualState == device.ActualOnline
if !value.Converged {
snapshot.Unconverged++
}
snapshot.Devices = append(snapshot.Devices, value)
}
if err := rows.Err(); err != nil {
return ConvergenceSnapshot{}, fmt.Errorf("iterate convergence snapshot: %w", err)
}
snapshot.Total = len(snapshot.Devices)
return snapshot, nil
}
const deviceColumns = `d.id, d.tenant_id, d.site_id, d.serial_number, d.name, d.modality,
d.desired_state, d.actual_state, d.endpoint_ref, d.credential_ref,
d.path_name, d.generation, d.created_at, d.updated_at`
+37
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"path/filepath"
"testing"
"time"
"yovision/sense/internal/device"
)
@@ -144,6 +145,42 @@ func TestLowerQuotaDoesNotDisableExistingStreams(t *testing.T) {
}
}
func TestConvergenceSnapshotAndRuntimeReconcileRequest(t *testing.T) {
t.Parallel()
store := openTestStore(t)
ctx := context.Background()
if err := store.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
t.Fatal(err)
}
if err := store.CreateDevice(ctx, videoDevice(1, "tenant", "site")); err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
if err := store.MarkReconciled(ctx, "camera-001", 1, now); err != nil {
t.Fatal(err)
}
if err := store.UpdateActualState(ctx, "camera-001", device.ActualOnline, now); err != nil {
t.Fatal(err)
}
snapshot, err := store.ConvergenceSnapshot(ctx)
if err != nil {
t.Fatal(err)
}
if snapshot.Total != 1 || snapshot.Unconverged != 0 {
t.Fatalf("expected converged snapshot, got %+v", snapshot)
}
if err := store.RequestReconcile(ctx, "camera-001", now.Add(time.Second)); err != nil {
t.Fatal(err)
}
snapshot, err = store.ConvergenceSnapshot(ctx)
if err != nil {
t.Fatal(err)
}
if snapshot.Unconverged != 1 || snapshot.Devices[0].ObservedGeneration != 0 {
t.Fatalf("runtime loss must invalidate convergence: %+v", snapshot)
}
}
func openTestStore(t *testing.T) *SQLite {
t.Helper()
dsn := "file:" + filepath.ToSlash(filepath.Join(t.TempDir(), "sense.db"))