feat(sense): establish M1 offline intake skeleton
Harness governance / validate (push) Has been cancelled
Harness governance / validate (pull_request) Has been cancelled

This commit is contained in:
QiuSW
2026-08-04 15:45:50 +08:00
parent bafd8d983e
commit 5208891e02
36 changed files with 17728 additions and 22 deletions
+134
View File
@@ -0,0 +1,134 @@
// Package reconcile converges MediaMTX paths from the database desired state.
package reconcile
import (
"context"
"errors"
"fmt"
"net/url"
"time"
"yovision/sense/internal/onvif"
"yovision/sense/internal/store"
)
const defaultBatchSize = 128
type Repository interface {
ListDueReconcile(ctx context.Context, now time.Time, limit int) ([]store.ReconcileCandidate, error)
MarkReconciled(ctx context.Context, id string, generation int64, now time.Time) error
MarkReconcileFailure(ctx context.Context, id string, failureCount int, nextAttempt time.Time, errorCode string, now time.Time) error
}
type MediaPaths interface {
EnsurePath(ctx context.Context, name, source string) (bool, error)
}
type Reconciler struct {
repository Repository
discovery onvif.Adapter
media MediaPaths
now func() time.Time
baseBackoff time.Duration
maxBackoff time.Duration
batchSize int
}
func New(repository Repository, discovery onvif.Adapter, media MediaPaths) *Reconciler {
return &Reconciler{
repository: repository, discovery: discovery, media: media,
now: time.Now, baseBackoff: time.Second, maxBackoff: time.Minute, batchSize: defaultBatchSize,
}
}
func (r *Reconciler) RunOnce(ctx context.Context) error {
now := r.now().UTC()
candidates, err := r.repository.ListDueReconcile(ctx, now, r.batchSize)
if err != nil {
return fmt.Errorf("list reconciliation candidates: %w", err)
}
var runErrors []error
for _, candidate := range candidates {
if err := ctx.Err(); err != nil {
return err
}
if err := r.reconcileOne(ctx, candidate, now); err != nil {
runErrors = append(runErrors, fmt.Errorf("reconcile device %s: %w", candidate.Device.ID, err))
}
}
return errors.Join(runErrors...)
}
func (r *Reconciler) reconcileOne(ctx context.Context, candidate store.ReconcileCandidate, now time.Time) error {
result, err := r.discovery.Probe(ctx, onvif.Target{
EndpointRef: candidate.Device.EndpointRef, CredentialRef: candidate.Device.CredentialRef,
})
if err == nil {
err = validateStreamURI(result.StreamURI)
}
if err == nil {
_, err = r.media.EnsurePath(ctx, candidate.Device.PathName, result.StreamURI)
}
if err == nil {
return r.repository.MarkReconciled(ctx, candidate.Device.ID, candidate.Device.Generation, now)
}
if ctx.Err() != nil {
return ctx.Err()
}
failureCount := candidate.FailureCount + 1
nextAttempt := now.Add(r.backoff(failureCount))
errorCode := string(onvif.CodeOf(err))
var onvifError *onvif.Error
if !errors.As(err, &onvifError) {
errorCode = "media_error"
}
if markErr := r.repository.MarkReconcileFailure(
ctx, candidate.Device.ID, failureCount, nextAttempt, errorCode, now,
); markErr != nil {
return errors.Join(err, fmt.Errorf("persist reconcile failure: %w", markErr))
}
return err
}
func validateStreamURI(value string) error {
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "rtsp" && parsed.Scheme != "rtsps") {
return &onvif.Error{Code: onvif.ErrorInvalidReply, Err: fmt.Errorf("stream URI has invalid scheme or host")}
}
return nil
}
func (r *Reconciler) backoff(failureCount int) time.Duration {
if failureCount <= 1 {
return r.baseBackoff
}
value := r.baseBackoff
for step := 1; step < failureCount; step++ {
if value >= r.maxBackoff/2 {
return r.maxBackoff
}
value *= 2
}
if value > r.maxBackoff {
return r.maxBackoff
}
return value
}
func (r *Reconciler) Run(ctx context.Context, interval time.Duration, report func(error)) {
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := r.RunOnce(ctx); err != nil && ctx.Err() == nil && report != nil {
report(err)
}
}
}
}
+159
View File
@@ -0,0 +1,159 @@
package reconcile
import (
"context"
"errors"
"path/filepath"
"testing"
"time"
"yovision/sense/internal/device"
"yovision/sense/internal/onvif"
"yovision/sense/internal/store"
)
type recordingMedia struct {
calls int
changed int
paths map[string]string
}
func (m *recordingMedia) EnsurePath(_ context.Context, name, source string) (bool, error) {
m.calls++
if m.paths == nil {
m.paths = make(map[string]string)
}
if m.paths[name] == source {
return false, nil
}
m.paths[name] = source
m.changed++
return true, nil
}
func TestReconcileConvergesOnceAndPersistsGeneration(t *testing.T) {
t.Parallel()
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
createReconcileDevice(t, repository)
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {Result: onvif.ProbeResult{
Profiles: []onvif.Profile{{Token: "main", Name: "Main", VideoEncoder: true}},
StreamURI: "rtsp://media.invalid/camera-1",
}},
})
media := &recordingMedia{}
reconciler := New(repository, discovery, media)
reconciler.now = func() time.Time { return time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC) }
if err := reconciler.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if err := reconciler.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.calls != 1 || media.changed != 1 {
t.Fatalf("converged generation should not repeat: calls=%d changed=%d", media.calls, media.changed)
}
}
func TestBackoffSurvivesStoreRestart(t *testing.T) {
t.Parallel()
databasePath := filepath.Join(t.TempDir(), "sense.db")
repository := openRepository(t, databasePath)
createReconcileDevice(t, repository)
failingDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {ProbeError: onvif.ErrorAuthentication},
})
media := &recordingMedia{}
initialTime := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
first := New(repository, failingDiscovery, media)
first.now = func() time.Time { return initialTime }
err := first.RunOnce(context.Background())
if err == nil || onvif.CodeOf(err) != onvif.ErrorAuthentication {
t.Fatalf("expected authentication failure, got %v", err)
}
if err := repository.Close(); err != nil {
t.Fatal(err)
}
reopened, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(databasePath))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = reopened.Close() })
successDiscovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {Result: onvif.ProbeResult{
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
StreamURI: "rtsp://media.invalid/camera-1",
}},
})
afterRestart := New(reopened, successDiscovery, media)
afterRestart.now = func() time.Time { return initialTime.Add(500 * time.Millisecond) }
if err := afterRestart.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.calls != 0 {
t.Fatal("backoff window must survive restart")
}
afterRestart.now = func() time.Time { return initialTime.Add(time.Second) }
if err := afterRestart.RunOnce(context.Background()); err != nil {
t.Fatal(err)
}
if media.calls != 1 {
t.Fatal("device must retry when persisted backoff expires")
}
}
func TestCancellationDoesNotPersistFailure(t *testing.T) {
t.Parallel()
repository := openRepository(t, filepath.Join(t.TempDir(), "sense.db"))
createReconcileDevice(t, repository)
discovery := onvif.NewFake(map[string]onvif.FakeScenario{
"onvif://camera-1": {
DelayMillis: 100,
Result: onvif.ProbeResult{
Profiles: []onvif.Profile{{Token: "main", VideoEncoder: true}},
StreamURI: "rtsp://media.invalid/camera-1",
},
},
})
reconciler := New(repository, discovery, &recordingMedia{})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
defer cancel()
err := reconciler.RunOnce(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected cancellation, got %v", err)
}
candidates, err := repository.ListDueReconcile(context.Background(), time.Now().Add(time.Hour), 10)
if err != nil {
t.Fatal(err)
}
if len(candidates) != 1 || candidates[0].FailureCount != 0 {
t.Fatalf("cancellation must not consume retry budget: %+v", candidates)
}
}
func openRepository(t *testing.T, path string) *store.SQLite {
t.Helper()
repository, err := store.OpenSQLite(context.Background(), "file:"+filepath.ToSlash(path))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = repository.Close() })
return repository
}
func createReconcileDevice(t *testing.T, repository *store.SQLite) {
t.Helper()
ctx := context.Background()
if err := repository.EnsureSite(ctx, device.Site{TenantID: "tenant", ID: "site", Name: "Site"}); err != nil {
t.Fatal(err)
}
if err := repository.CreateDevice(ctx, device.Device{
ID: "camera-1", TenantID: "tenant", SiteID: "site", SerialNumber: "camera-1", Name: "Camera 1",
Modality: device.ModalityVideo, Capabilities: []device.Capability{device.CapabilityVideoCapture},
DesiredState: device.DesiredEnabled, ActualState: device.ActualPending,
EndpointRef: "onvif://camera-1", CredentialRef: "secret://camera-1", PathName: "camera-1",
}); err != nil {
t.Fatal(err)
}
}