- Refactor edge_registry.go and edge_server.go - Update edge_server_test.go with new test cases - Update main.go and main_test.go for edge integration - Move completed tasks to archive
416 lines
12 KiB
Go
416 lines
12 KiB
Go
package wire
|
|
|
|
import (
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
|
|
proto_socket "git.toki-labs.com/toki/proto-socket/go"
|
|
"google.golang.org/protobuf/proto"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
const recentNodeEventCapacity = 128
|
|
const recentCommandCapacity = 128
|
|
const commandEventCapacity = 64
|
|
|
|
// EdgeConnectionState captures the Control Plane's internal view of a single
|
|
// Edge wire connection.
|
|
type EdgeConnectionState struct {
|
|
EdgeID string
|
|
EdgeName string
|
|
Version string
|
|
Capabilities []string
|
|
Protocol string
|
|
Connected bool
|
|
LastSeen time.Time
|
|
DisconnectInfo proto_socket.DisconnectInfo
|
|
|
|
// token identifies the specific connection that produced this state. It is an
|
|
// internal registry detail (not an external API contract) used to ignore a
|
|
// stale connection's late disconnect after the same edge_id reconnected.
|
|
token uint64
|
|
}
|
|
|
|
// EdgeNodeEventRecord is the Control Plane's copied recent view of one node
|
|
// lifecycle event as received from a specific Edge connection.
|
|
type EdgeNodeEventRecord struct {
|
|
EdgeID string
|
|
Event *iop.EdgeNodeEvent
|
|
ReceivedAt time.Time
|
|
}
|
|
|
|
// EdgeCommandRecord is the Control Plane's bounded audit view of one command
|
|
// dispatched to an Edge: the request summary, the latest result, and a bounded
|
|
// list of relayed lifecycle events. It is a control/audit view; durable command
|
|
// history is intentionally outside this in-memory baseline.
|
|
type EdgeCommandRecord struct {
|
|
EdgeID string
|
|
CommandID string
|
|
Operation string
|
|
TargetSelector string
|
|
Status string
|
|
Summary string
|
|
Error string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Events []EdgeCommandEventRecord
|
|
}
|
|
|
|
// EdgeCommandEventRecord is the Control Plane's copied view of one command
|
|
// lifecycle event relayed from an Edge during command execution.
|
|
type EdgeCommandEventRecord struct {
|
|
Phase string
|
|
Summary string
|
|
OccurredAt time.Time
|
|
ReceivedAt time.Time
|
|
}
|
|
|
|
// EdgeRegistry stores Edge connection state keyed by edge_id behind a mutex.
|
|
type EdgeRegistry struct {
|
|
mu sync.Mutex
|
|
edges map[string]*EdgeConnectionState
|
|
nextToken uint64
|
|
recentNodeEvents []EdgeNodeEventRecord
|
|
commands []*EdgeCommandRecord
|
|
}
|
|
|
|
// NewEdgeRegistry returns an empty Edge connection registry.
|
|
func NewEdgeRegistry() *EdgeRegistry {
|
|
return &EdgeRegistry{edges: make(map[string]*EdgeConnectionState)}
|
|
}
|
|
|
|
// MarkConnected records or refreshes an Edge as connected from its hello and
|
|
// returns the connection token to hand back on disconnect. A reconnection
|
|
// supersedes any earlier connection for the same edge_id.
|
|
func (r *EdgeRegistry) MarkConnected(req *iop.EdgeHelloRequest, at time.Time) uint64 {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.nextToken++
|
|
token := r.nextToken
|
|
r.edges[req.GetEdgeId()] = &EdgeConnectionState{
|
|
EdgeID: req.GetEdgeId(),
|
|
EdgeName: req.GetEdgeName(),
|
|
Version: req.GetVersion(),
|
|
Capabilities: append([]string(nil), req.GetCapabilities()...),
|
|
Protocol: Protocol,
|
|
Connected: true,
|
|
LastSeen: at,
|
|
token: token,
|
|
}
|
|
return token
|
|
}
|
|
|
|
// MarkDisconnected flips a tracked Edge to disconnected and records why, but
|
|
// only when token still matches the current connection. A stale token from a
|
|
// superseded connection is ignored so a reconnection is not cleared. It returns
|
|
// true only when the current connection was flipped, so callers (e.g. the
|
|
// EdgeServer active client map) can apply the same stale-safe cleanup.
|
|
func (r *EdgeRegistry) MarkDisconnected(edgeID string, token uint64, at time.Time, info proto_socket.DisconnectInfo) bool {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
state, ok := r.edges[edgeID]
|
|
if !ok || state.token != token {
|
|
return false
|
|
}
|
|
state.Connected = false
|
|
state.LastSeen = at
|
|
state.DisconnectInfo = info
|
|
return true
|
|
}
|
|
|
|
// Snapshot returns a copy of the tracked state for an Edge.
|
|
func (r *EdgeRegistry) Snapshot(edgeID string) (EdgeConnectionState, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
state, ok := r.edges[edgeID]
|
|
if !ok {
|
|
return EdgeConnectionState{}, false
|
|
}
|
|
snapshot := *state
|
|
snapshot.Capabilities = append([]string(nil), state.Capabilities...)
|
|
return snapshot, true
|
|
}
|
|
|
|
// Snapshots returns a stable, copied view of all tracked Edge states.
|
|
func (r *EdgeRegistry) Snapshots() []EdgeConnectionState {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
ids := make([]string, 0, len(r.edges))
|
|
for id := range r.edges {
|
|
ids = append(ids, id)
|
|
}
|
|
sort.Strings(ids)
|
|
snapshots := make([]EdgeConnectionState, 0, len(ids))
|
|
for _, id := range ids {
|
|
state := r.edges[id]
|
|
snapshot := *state
|
|
snapshot.Capabilities = append([]string(nil), state.Capabilities...)
|
|
snapshots = append(snapshots, snapshot)
|
|
}
|
|
return snapshots
|
|
}
|
|
|
|
// RecordNodeEvent stores a copied EdgeNodeEvent under the identified Edge. It
|
|
// is intended for tests and non-connection seed paths that do not have an
|
|
// active connection token.
|
|
func (r *EdgeRegistry) RecordNodeEvent(edgeID string, event *iop.EdgeNodeEvent, receivedAt time.Time) {
|
|
r.recordNodeEvent(edgeID, 0, false, event, receivedAt)
|
|
}
|
|
|
|
// RecordNodeEventFromConnection stores a copied EdgeNodeEvent only when the
|
|
// supplied token still identifies the current connection for edgeID. It returns
|
|
// false for stale superseded connections.
|
|
func (r *EdgeRegistry) RecordNodeEventFromConnection(edgeID string, token uint64, event *iop.EdgeNodeEvent, receivedAt time.Time) bool {
|
|
return r.recordNodeEvent(edgeID, token, true, event, receivedAt)
|
|
}
|
|
|
|
// recordNodeEvent appends to the bounded recent buffer. The recent buffer is
|
|
// global to the in-memory registry; durable event history is intentionally
|
|
// outside this baseline.
|
|
func (r *EdgeRegistry) recordNodeEvent(edgeID string, token uint64, requireToken bool, event *iop.EdgeNodeEvent, receivedAt time.Time) bool {
|
|
if edgeID == "" || event == nil {
|
|
return false
|
|
}
|
|
if receivedAt.IsZero() {
|
|
receivedAt = time.Now()
|
|
}
|
|
copied := cloneEdgeNodeEvent(event)
|
|
if copied.GetTimestamp() == 0 {
|
|
copied.Timestamp = receivedAt.UnixNano()
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if requireToken {
|
|
state, ok := r.edges[edgeID]
|
|
if !ok || state.token != token {
|
|
return false
|
|
}
|
|
}
|
|
if len(r.recentNodeEvents) >= recentNodeEventCapacity {
|
|
r.recentNodeEvents = append(r.recentNodeEvents[:0], r.recentNodeEvents[1:]...)
|
|
}
|
|
r.recentNodeEvents = append(r.recentNodeEvents, EdgeNodeEventRecord{
|
|
EdgeID: edgeID,
|
|
Event: copied,
|
|
ReceivedAt: receivedAt,
|
|
})
|
|
return true
|
|
}
|
|
|
|
// NodeEvents returns copied recent node lifecycle events for an Edge. When
|
|
// nodeID is non-empty, only events for that node are returned.
|
|
func (r *EdgeRegistry) NodeEvents(edgeID, nodeID string) []EdgeNodeEventRecord {
|
|
if edgeID == "" {
|
|
return nil
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]EdgeNodeEventRecord, 0, len(r.recentNodeEvents))
|
|
for _, record := range r.recentNodeEvents {
|
|
if record.EdgeID != edgeID {
|
|
continue
|
|
}
|
|
if nodeID != "" && record.Event.GetNodeId() != nodeID {
|
|
continue
|
|
}
|
|
out = append(out, cloneEdgeNodeEventRecord(record))
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// RecordCommandRequest stores or refreshes a pending command audit record for a
|
|
// command the Control Plane is dispatching to an Edge. It is bounded; the oldest
|
|
// record is evicted past capacity.
|
|
func (r *EdgeRegistry) RecordCommandRequest(edgeID string, req *iop.EdgeCommandRequest, at time.Time) {
|
|
if edgeID == "" || req == nil || req.GetCommandId() == "" {
|
|
return
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if rec := r.findCommandLocked(edgeID, req.GetCommandId()); rec != nil {
|
|
rec.Operation = req.GetOperation()
|
|
rec.TargetSelector = req.GetTargetSelector()
|
|
rec.UpdatedAt = at
|
|
return
|
|
}
|
|
rec := r.appendCommandLocked(edgeID, req.GetCommandId(), at)
|
|
rec.Operation = req.GetOperation()
|
|
rec.TargetSelector = req.GetTargetSelector()
|
|
rec.Status = "pending"
|
|
}
|
|
|
|
// RecordCommandResult updates the audit record for a command with the Edge's
|
|
// response status/summary. A missing record is created so a result is never
|
|
// silently dropped.
|
|
func (r *EdgeRegistry) RecordCommandResult(edgeID string, resp *iop.EdgeCommandResponse, at time.Time) {
|
|
if edgeID == "" || resp == nil || resp.GetCommandId() == "" {
|
|
return
|
|
}
|
|
if at.IsZero() {
|
|
at = time.Now()
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
rec := r.findCommandLocked(edgeID, resp.GetCommandId())
|
|
if rec == nil {
|
|
rec = r.appendCommandLocked(edgeID, resp.GetCommandId(), at)
|
|
}
|
|
status := resp.GetStatus()
|
|
if status == "" {
|
|
status = "ok"
|
|
}
|
|
rec.Status = status
|
|
rec.Summary = resp.GetSummary()
|
|
rec.Error = resp.GetError()
|
|
rec.UpdatedAt = at
|
|
}
|
|
|
|
// RecordCommandEventFromConnection appends a bounded command lifecycle event to
|
|
// the matching command audit record, but only when token still identifies the
|
|
// current connection for edgeID. It returns false for stale superseded
|
|
// connections.
|
|
func (r *EdgeRegistry) RecordCommandEventFromConnection(edgeID string, token uint64, event *iop.EdgeCommandEvent, receivedAt time.Time) bool {
|
|
return r.recordCommandEvent(edgeID, token, true, event, receivedAt)
|
|
}
|
|
|
|
// RecordCommandEvent stores a command lifecycle event without a connection
|
|
// token. It is intended for tests and non-connection seed paths.
|
|
func (r *EdgeRegistry) RecordCommandEvent(edgeID string, event *iop.EdgeCommandEvent, receivedAt time.Time) {
|
|
r.recordCommandEvent(edgeID, 0, false, event, receivedAt)
|
|
}
|
|
|
|
func (r *EdgeRegistry) recordCommandEvent(edgeID string, token uint64, requireToken bool, event *iop.EdgeCommandEvent, receivedAt time.Time) bool {
|
|
if edgeID == "" || event == nil || event.GetCommandId() == "" {
|
|
return false
|
|
}
|
|
if receivedAt.IsZero() {
|
|
receivedAt = time.Now()
|
|
}
|
|
occurredAt := receivedAt
|
|
if event.GetOccurredAt() != 0 {
|
|
occurredAt = time.Unix(0, event.GetOccurredAt())
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if requireToken {
|
|
state, ok := r.edges[edgeID]
|
|
if !ok || state.token != token {
|
|
return false
|
|
}
|
|
}
|
|
rec := r.findCommandLocked(edgeID, event.GetCommandId())
|
|
if rec == nil {
|
|
rec = r.appendCommandLocked(edgeID, event.GetCommandId(), receivedAt)
|
|
rec.Status = "running"
|
|
}
|
|
if len(rec.Events) >= commandEventCapacity {
|
|
rec.Events = append(rec.Events[:0], rec.Events[1:]...)
|
|
}
|
|
rec.Events = append(rec.Events, EdgeCommandEventRecord{
|
|
Phase: event.GetPhase(),
|
|
Summary: event.GetSummary(),
|
|
OccurredAt: occurredAt,
|
|
ReceivedAt: receivedAt,
|
|
})
|
|
rec.UpdatedAt = receivedAt
|
|
return true
|
|
}
|
|
|
|
// Commands returns copied command audit records for an Edge, oldest first.
|
|
func (r *EdgeRegistry) Commands(edgeID string) []EdgeCommandRecord {
|
|
if edgeID == "" {
|
|
return nil
|
|
}
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]EdgeCommandRecord, 0, len(r.commands))
|
|
for _, rec := range r.commands {
|
|
if rec.EdgeID != edgeID {
|
|
continue
|
|
}
|
|
out = append(out, cloneEdgeCommandRecord(rec))
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Command returns a single copied command audit record.
|
|
func (r *EdgeRegistry) Command(edgeID, commandID string) (EdgeCommandRecord, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
rec := r.findCommandLocked(edgeID, commandID)
|
|
if rec == nil {
|
|
return EdgeCommandRecord{}, false
|
|
}
|
|
return cloneEdgeCommandRecord(rec), true
|
|
}
|
|
|
|
// findCommandLocked returns the stored command record for an edge_id/command_id
|
|
// pair. Callers must hold r.mu.
|
|
func (r *EdgeRegistry) findCommandLocked(edgeID, commandID string) *EdgeCommandRecord {
|
|
for _, rec := range r.commands {
|
|
if rec.EdgeID == edgeID && rec.CommandID == commandID {
|
|
return rec
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// appendCommandLocked appends a new bounded command record. Callers must hold
|
|
// r.mu.
|
|
func (r *EdgeRegistry) appendCommandLocked(edgeID, commandID string, at time.Time) *EdgeCommandRecord {
|
|
if len(r.commands) >= recentCommandCapacity {
|
|
r.commands = append(r.commands[:0], r.commands[1:]...)
|
|
}
|
|
rec := &EdgeCommandRecord{
|
|
EdgeID: edgeID,
|
|
CommandID: commandID,
|
|
CreatedAt: at,
|
|
UpdatedAt: at,
|
|
}
|
|
r.commands = append(r.commands, rec)
|
|
return rec
|
|
}
|
|
|
|
func cloneEdgeCommandRecord(rec *EdgeCommandRecord) EdgeCommandRecord {
|
|
clone := *rec
|
|
clone.Events = append([]EdgeCommandEventRecord(nil), rec.Events...)
|
|
return clone
|
|
}
|
|
|
|
// Len returns the number of tracked Edge connections.
|
|
func (r *EdgeRegistry) Len() int {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
return len(r.edges)
|
|
}
|
|
|
|
func cloneEdgeNodeEventRecord(record EdgeNodeEventRecord) EdgeNodeEventRecord {
|
|
return EdgeNodeEventRecord{
|
|
EdgeID: record.EdgeID,
|
|
Event: cloneEdgeNodeEvent(record.Event),
|
|
ReceivedAt: record.ReceivedAt,
|
|
}
|
|
}
|
|
|
|
func cloneEdgeNodeEvent(event *iop.EdgeNodeEvent) *iop.EdgeNodeEvent {
|
|
if event == nil {
|
|
return nil
|
|
}
|
|
copied, ok := proto.Clone(event).(*iop.EdgeNodeEvent)
|
|
if !ok {
|
|
return &iop.EdgeNodeEvent{}
|
|
}
|
|
return copied
|
|
}
|