Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions cmd/unbounded-net-controller/node_overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,31 @@ func (c *NodeStatusCache) StoreOverview(nodeName string, overview statusv1alpha1
revision = previous.Revision + 1
}

c.entries[nodeName] = &CachedNodeStatus{
entry := &CachedNodeStatus{
Status: &metadata, Overview: &overview, Source: source,
Revision: revision, ReceivedAt: time.Now(),
}
c.entries[nodeName] = entry
fn := c.onOverviewChange
c.mu.Unlock()

if fn != nil {
fn(nodeName, overview)
fn(nodeName, entry.overviewForNotification())
}

return revision, nil
}

// Notifications use controller receipt time, matching full cluster rebuilds,
// without changing the node's wire metadata or renewing it on source changes.
func (entry *CachedNodeStatus) overviewForNotification() statusv1alpha1.NodeStatusOverview {
overview := *entry.Overview
received := entry.ReceivedAt
overview.LastPushTime = &received

return overview
}

// SetOnOverviewChange registers the summary-only cache mutation callback.
func (c *NodeStatusCache) SetOnOverviewChange(fn func(string, statusv1alpha1.NodeStatusOverview)) {
c.mu.Lock()
Expand Down
6 changes: 3 additions & 3 deletions cmd/unbounded-net-controller/node_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ func (c *NodeStatusCache) StoreFullChecked(nodeName string, status NodeStatusRes

if entry.Overview != nil {
if overviewFn != nil {
overviewFn(nodeName, *entry.Overview)
overviewFn(nodeName, entry.overviewForNotification())
}
} else if fn != nil {
fn(nodeName, entry.Status)
Expand Down Expand Up @@ -446,7 +446,7 @@ func (c *NodeStatusCache) commitParsedDeltaBase(nodeName string, previous *Cache

if next.Overview != nil {
if overviewFn != nil {
overviewFn(nodeName, *next.Overview)
overviewFn(nodeName, next.overviewForNotification())
}
} else if fn != nil {
fn(nodeName, next.Status)
Expand Down Expand Up @@ -558,7 +558,7 @@ func (c *NodeStatusCache) UpdateSourceIf(nodeName, expectedSource, source string

if updated.Overview != nil {
if overviewFn != nil {
overviewFn(nodeName, *updated.Overview)
overviewFn(nodeName, updated.overviewForNotification())
}
} else if fn != nil {
fn(nodeName, statusCopy)
Expand Down
102 changes: 102 additions & 0 deletions cmd/unbounded-net-controller/summary_receipt_time_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Copyright (c) Microsoft Corporation.
// SPDX-License-Identifier: Apache-2.0

package main

import (
"testing"
"testing/synctest"
"time"

statusv1alpha1 "github.com/Azure/unbounded/internal/net/status/v1alpha1"
)

func TestSummaryNotificationsPreserveReceiptTime(t *testing.T) {
for _, mode := range []string{"summary", "legacy-full", "legacy-delta"} {
t.Run(mode, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewNodeStatusCache()
cache.BindDetails(testDetailRequests(t, nodeDetailRequestHooks{}))

cluster := NewClusterStatusCache(&healthState{})
cluster.status = &ClusterStatusResponse{
Nodes: []*NodeStatusResponse{{NodeInfo: NodeInfo{Name: "node"}}},
}
cluster.nodeIndex["node"] = 0
cache.SetOnOverviewChange(cluster.PatchOverview)

claimedTime := time.Unix(1, 0)

var revision uint64

publish := func() {
t.Helper()

var err error

switch {
case mode == "summary":
revision, err = cache.StoreOverview("node", statusv1alpha1.NodeStatusOverview{
NodeInfo: NodeInfo{Name: "node"}, LastPushTime: &claimedTime,
}, "ws")
case revision == 0 || mode == "legacy-full":
status := retentionFixture(1)
status.LastPushTime = &claimedTime
revision, err = cache.StoreFullChecked("node", status, "ws")
default:
now := time.Now()

var resync bool

revision, resync, err = cache.ApplyParsedDelta("node", revision, parsedDelta{timestamp: &now}, "ws")
if resync {
t.Fatal("unexpected resync")
}
}

if err != nil {
t.Fatal(err)
}
}
assertReceipt := func(want time.Time) *ClusterSummary {
t.Helper()

summary := buildClusterSummary(cluster.Get())

got := summary.NodeSummaries[0].LastPushTime
if got == nil || !got.Equal(want) {
t.Fatalf("summary receipt time = %v, want %v", got, want)
}

return summary
}

publish()

entry, _ := cache.Get("node")

first := assertReceipt(entry.ReceivedAt)
if !entry.Overview.LastPushTime.Equal(claimedTime) {
t.Fatal("notification changed the stored wire metadata")
}

time.Sleep(time.Second)
cache.UpdateSource("node", "apiserver-ws")
assertReceipt(entry.ReceivedAt)
time.Sleep(time.Second)
publish()

nextEntry, _ := cache.Get("node")

next := assertReceipt(nextEntry.ReceivedAt)
if !first.NodeSummaries[0].LastPushTime.Equal(entry.ReceivedAt) {
t.Fatal("new notification mutated an earlier snapshot")
}

if delta := computeClusterSummaryDelta(first, next); delta == nil || len(delta.NodeSummaries) != 1 {
t.Fatal("receipt-only update did not reach the summary delta")
}
})
})
}
}
2 changes: 2 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ summaries and remain visible before loading and after details expire. Current
summary metadata takes precedence over an older diagnostic snapshot. Unloaded
tabs remain selectable but show no tables, pagination or diagnostic validation
results. The node table shows the last received status age alongside its source.
This is controller receipt time, not the node's clock. Switching transports
without receiving a new status does not reset that age.

The node dialog distinguishes not-loaded, loading, loaded, expired and error.
Peer/route/BPF tables and full node JSON are available only with valid details.
Expand Down