Files
go-sip/internal/store/local_v04_discovery.go
T

178 lines
6.0 KiB
Go

package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
// CloseLocalTaskDiscoveryAdmissionV04 fails closed without persisting a live cursor.
func (s *Store) CloseLocalTaskDiscoveryAdmissionV04(dispatcherID string) error {
s.mu.Lock()
defer s.mu.Unlock()
_, err := s.db.Exec(`INSERT INTO local_v04_task_discovery_state(dispatcher_id,ready,updated_at) VALUES(?,0,?)
ON CONFLICT(dispatcher_id) DO UPDATE SET ready=0,updated_at=excluded.updated_at`, dispatcherID, time.Now().UTC().Format(time.RFC3339Nano))
return err
}
// LocalTaskDiscoveryReadyV04 reports whether a complete snapshot and control
// backlog have been applied. An absent row is not ready on a fresh host.
func (s *Store) LocalTaskDiscoveryReadyV04(dispatcherID string) (bool, error) {
if dispatcherID == "" {
return false, ErrLocalDiscoveryUnavailable
}
s.mu.Lock()
defer s.mu.Unlock()
var ready int
if err := s.db.QueryRow(`SELECT ready FROM local_v04_task_discovery_state WHERE dispatcher_id=?`, dispatcherID).Scan(&ready); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return ready == 1, nil
}
// MarkLocalTaskDiscoveryReadyV04 opens admission after the live change stream is caught up.
func (s *Store) MarkLocalTaskDiscoveryReadyV04(dispatcherID string, observedAt time.Time) error {
if dispatcherID == "" || observedAt.IsZero() {
return ErrLocalDiscoveryUnavailable
}
s.mu.Lock()
defer s.mu.Unlock()
result, err := s.db.Exec(`UPDATE local_v04_task_discovery_state SET ready=1,updated_at=? WHERE dispatcher_id=?`, observedAt.UTC().Format(time.RFC3339Nano), dispatcherID)
if err != nil {
return err
}
if n, err := result.RowsAffected(); err != nil || n != 1 {
return ErrLocalDiscoveryUnavailable
}
return nil
}
// ApplyLocalTaskSnapshot replaces membership only after every page of one snapshot
// has been verified by the caller. Control barriers and execution records survive.
func (s *Store) ApplyLocalTaskSnapshot(dispatcherID string, tasks []LocalDiscoveredTask, observedAt time.Time) error {
if dispatcherID == "" || observedAt.IsZero() || len(tasks) > 256 {
return ErrLocalDiscoveryUnavailable
}
s.mu.Lock()
defer s.mu.Unlock()
if err := validateV04AssignmentSet(dispatcherID, tasks); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
now := observedAt.UTC().Format(time.RFC3339Nano)
seen := make(map[string]bool, len(tasks))
for _, task := range tasks {
if task.Status == "removed" {
return ErrLocalDiscoveryUnavailable
}
seen[task.TaskID] = true
if err := applyLocalDiscoveredTask(tx, dispatcherID, task, now); err != nil {
return err
}
}
rows, err := tx.Query(`SELECT task_id FROM local_v01_task_assignments WHERE dispatcher_id=? AND removed=0`, dispatcherID)
if err != nil {
return err
}
var absent []string
for rows.Next() {
var taskID string
if err := rows.Scan(&taskID); err != nil {
rows.Close()
return err
}
if !seen[taskID] {
absent = append(absent, taskID)
}
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
for _, taskID := range absent {
if _, err := tx.Exec(`UPDATE local_v01_task_assignments SET saas_status='removed',admission_state='removed',removed=1,updated_at=? WHERE dispatcher_id=? AND task_id=?`, now, dispatcherID, taskID); err != nil {
return err
}
}
if _, err := tx.Exec(`INSERT INTO local_v04_task_discovery_state(dispatcher_id,ready,updated_at) VALUES(?,0,?)
ON CONFLICT(dispatcher_id) DO UPDATE SET ready=0,updated_at=excluded.updated_at`, dispatcherID, now); err != nil {
return err
}
return tx.Commit()
}
// ApplyLocalTaskChanges updates task membership. An existing task's status is
// controlled by MQ; contradictory HTTP status fails admission instead of racing it.
func (s *Store) ApplyLocalTaskChanges(dispatcherID string, tasks []LocalDiscoveredTask, observedAt time.Time) error {
if dispatcherID == "" || observedAt.IsZero() || len(tasks) > 256 {
return ErrLocalDiscoveryUnavailable
}
s.mu.Lock()
defer s.mu.Unlock()
if err := validateV04AssignmentSet(dispatcherID, tasks); err != nil {
return err
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, task := range tasks {
var existingStatus string
err := tx.QueryRow(`SELECT saas_status FROM local_v01_task_assignments WHERE dispatcher_id=? AND task_id=?`, dispatcherID, task.TaskID).Scan(&existingStatus)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if err == nil && task.Status != "removed" && task.Status != existingStatus {
if err := tx.Rollback(); err != nil {
return err
}
if _, err := s.db.Exec(`UPDATE local_v04_task_discovery_state SET ready=0 WHERE dispatcher_id=?`, dispatcherID); err != nil {
return err
}
return fmt.Errorf("task %s: HTTP status %s conflicts with MQ-applied %s: %w", task.TaskID, task.Status, existingStatus, ErrLocalDiscoveryUnavailable)
}
}
now := observedAt.UTC().Format(time.RFC3339Nano)
for _, task := range tasks {
if err := applyLocalDiscoveredTask(tx, dispatcherID, task, now); err != nil {
return err
}
}
if _, err := tx.Exec(`UPDATE local_v04_task_discovery_state SET updated_at=? WHERE dispatcher_id=?`, now, dispatcherID); err != nil {
return err
}
return tx.Commit()
}
func validateV04AssignmentSet(dispatcherID string, tasks []LocalDiscoveredTask) error {
seen := make(map[string]bool, len(tasks))
ids, keys := make(map[string]string), make(map[string]string)
for _, task := range tasks {
if err := validateLocalDiscoveredTask(dispatcherID, task); err != nil {
return err
}
if seen[task.TaskID] {
return fmt.Errorf("duplicate discovered task %s: %w", task.TaskID, ErrLocalDiscoveryUnavailable)
}
seen[task.TaskID] = true
if key, ok := ids[task.TenantID]; ok && key != task.TenantKey {
return ErrTenantBindingConflict
}
if id, ok := keys[task.TenantKey]; ok && id != task.TenantID {
return ErrTenantBindingConflict
}
ids[task.TenantID], keys[task.TenantKey] = task.TenantKey, task.TenantID
}
return nil
}