From 024fd30881475ea97b74b4fc71a194a41709dbef Mon Sep 17 00:00:00 2001 From: "Patrick W. Healy" Date: Thu, 17 Sep 2026 01:24:49 +0000 Subject: [PATCH] fix(net): preserve receipt times in incremental summary notifications Live dashboard verification found that full rebuilds supplied lastPushTime, but subsequent summary patches lost it. Stamp notification copies with the cache entry receipt time for summary, legacy full/delta and transport-source changes, without altering wire metadata or renewing freshness on reconnect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d2243398-6c36-4c3d-969e-7ed7bfb5b459 --- cmd/unbounded-net-controller/node_overview.go | 15 ++- cmd/unbounded-net-controller/node_status.go | 6 +- .../summary_receipt_time_test.go | 102 ++++++++++++++++++ frontend/README.md | 2 + 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 cmd/unbounded-net-controller/summary_receipt_time_test.go diff --git a/cmd/unbounded-net-controller/node_overview.go b/cmd/unbounded-net-controller/node_overview.go index 2af369dfe..793616821 100644 --- a/cmd/unbounded-net-controller/node_overview.go +++ b/cmd/unbounded-net-controller/node_overview.go @@ -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() diff --git a/cmd/unbounded-net-controller/node_status.go b/cmd/unbounded-net-controller/node_status.go index e0ed77023..a026769c3 100644 --- a/cmd/unbounded-net-controller/node_status.go +++ b/cmd/unbounded-net-controller/node_status.go @@ -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) @@ -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) @@ -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) diff --git a/cmd/unbounded-net-controller/summary_receipt_time_test.go b/cmd/unbounded-net-controller/summary_receipt_time_test.go new file mode 100644 index 000000000..57c81e430 --- /dev/null +++ b/cmd/unbounded-net-controller/summary_receipt_time_test.go @@ -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") + } + }) + }) + } +} diff --git a/frontend/README.md b/frontend/README.md index ff799c2f1..cfa424d4c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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.