ipn/ipnlocal, control/controlclient: process node adds/removes in constant time

For large tailnets (~50k+ nodes) with frequent peer churn (ephemeral
GitHub Actions workers etc.), tailscaled used to rebuild the full
netmap and fan it out on the IPN bus on every MapResponse that
added or removed a peer. There were two O(N) costs per delta: the
full netmap rebuild + every Notify.NetMap encode to every bus watcher.

This change tackles both:

  1. Plumb O(1) peer add/remove through the delta path. PeersChanged
     and PeersRemoved no longer prevent the delta happy path; instead,
     they mutate the per-node-backend peer map in place.

  2. Restrict ipn.Notify.NetMap emission to the platforms whose host
     GUIs still depend on it (Windows, macOS, iOS) and migrate
     in-tree consumers off it everywhere else:

     - Migrate reactive consumers (containerboot, kube agents,
       sniproxy, tsconsensus, etc.) off Notify.NetMap to the
       previously-added Notify.SelfChange signal so they no longer
       have to subscribe to the full netmap.
     - Add ipn.NotifyNoNetMap so GUI clients on "legacy-emit" platforms
       that have already migrated can opt out of the per-watcher
       NetMap encode.
     - Gate Notify.NetMap emission on the producer side by a compile-
       time GOOS check, so the supporting code is dead-code-eliminated
       on Linux and other geese where no GUI consumer needs it.

Re-running BenchmarkGiantTailnet from tstest/largetailnet, which was
added along with baseline numbers on unmodified main in ad5436af0d,
the per-delta cost (one peer add+remove pair) is now ~O(1) regardless
of tailnet size N:

    N         no-watcher (ms/op)            bus-watcher (ms/op)
              before    now     factor      before    now     factor
     10000        32   0.11       300x         166   0.13      1300x
     50000       222   0.11      2000x         865   0.13      6700x
    100000       504   0.12      4100x        1765   0.13     13400x
    250000      1551   0.12     12500x        4696   0.15     32400x

Updates #12542

Change-Id: I94e34b37331d1a8ec74c299deffadf4d061fda9e
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-05-21 09:26:19 -07:00
committed by Brad Fitzpatrick
parent 2703f91174
commit aa5da2e5f2
23 changed files with 1521 additions and 211 deletions
+44 -19
View File
@@ -68,6 +68,25 @@ func (m NodeMutationLastSeen) Apply(n *tailcfg.Node) {
n.LastSeen = new(m.LastSeen)
}
// NodeMutationAdd is a NodeMutation that says a new peer has been added.
// Apply is a no-op: consumers of NodeMutationAdd must type-switch to handle
// adds by inserting Node into their peer map.
type NodeMutationAdd struct {
Node tailcfg.NodeView
}
func (m NodeMutationAdd) NodeIDBeingMutated() tailcfg.NodeID { return m.Node.ID() }
func (m NodeMutationAdd) Apply(*tailcfg.Node) {}
// NodeMutationRemove is a NodeMutation that says a peer has been removed.
// Apply is a no-op: consumers of NodeMutationRemove must type-switch to handle
// removes by deleting the node from their peer map.
type NodeMutationRemove struct {
mutatingNodeID
}
func (m NodeMutationRemove) Apply(*tailcfg.Node) {}
var peerChangeFields = sync.OnceValue(func() []reflect.StructField {
var fields []reflect.StructField
rt := reflect.TypeFor[tailcfg.PeerChange]()
@@ -110,8 +129,12 @@ func NodeMutationsFromPatch(p *tailcfg.PeerChange) (_ []NodeMutation, ok bool) {
}
// MutationsFromMapResponse returns all the discrete node mutations described
// by res. It returns ok=false if res contains any non-patch field as defined
// by res. It returns ok=false if res contains any non-delta field as defined
// by mapResponseContainsNonPatchFields.
//
// Adds and removes (from res.PeersChanged / res.PeersRemoved) are emitted as
// NodeMutationAdd / NodeMutationRemove entries. Callers must type-switch to
// handle those alongside field mutations.
func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []NodeMutation, ok bool) {
if now.IsZero() {
now = time.Now()
@@ -119,8 +142,15 @@ func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []No
if mapResponseContainsNonPatchFields(res) {
return nil, false
}
// All that remains is PeersChangedPatch, OnlineChange, and LastSeenChange.
for _, id := range res.PeersRemoved {
ret = append(ret, NodeMutationRemove{mutatingNodeID(id)})
}
for _, n := range res.PeersChanged {
// Any n still in PeersChanged after patchifyPeersChanged is a
// truly-new (or replaced) peer.
ret = append(ret, NodeMutationAdd{Node: n.View()})
}
for _, p := range res.PeersChangedPatch {
deltas, ok := NodeMutationsFromPatch(p)
if !ok {
@@ -142,25 +172,26 @@ func MutationsFromMapResponse(res *tailcfg.MapResponse, now time.Time) (ret []No
return ret, true
}
// mapResponseContainsNonPatchFields reports whether res contains only "patch"
// fields set (PeersChangedPatch primarily, but also including the legacy
// PeerSeenChange and OnlineChange fields).
// mapResponseContainsNonPatchFields reports whether res contains any field
// that can't be expressed as a per-peer NodeMutation (including the new
// NodeMutationAdd / NodeMutationRemove variants) or via the sibling narrow
// setter methods on the map-session backend (e.g. UpdatePacketFilter).
//
// It ignores any of the meta fields that are handled by PollNetMap before the
// peer change handling gets involved.
// When this returns true, the caller must fall back to rebuilding and
// dispatching a full NetworkMap. When it returns false, the response can be
// handled incrementally.
//
// The purpose of this function is to ask whether this is a tricky enough
// MapResponse to warrant a full netmap update. When this returns false, it
// means the response can be handled incrementally, patching up the local state.
// PeersChanged, PeersRemoved, and PacketFilter(s) are intentionally not in
// this list: new/removed peers ride NodeMutationAdd/Remove, packet
// filter updates are delivered via the backend's UpdatePacketFilter
// method, and UserProfile updates ride the backend's UpdateUserProfiles
// method.
func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
return res.Node != nil ||
res.DERPMap != nil ||
res.DNSConfig != nil ||
res.Domain != "" ||
res.CollectServices != "" ||
res.PacketFilter != nil ||
res.PacketFilters != nil ||
res.UserProfiles != nil ||
res.Health != nil ||
res.DisplayMessages != nil ||
res.SSHPolicy != nil ||
@@ -170,11 +201,5 @@ func mapResponseContainsNonPatchFields(res *tailcfg.MapResponse) bool {
res.ControlDialPlan != nil ||
res.ClientVersion != nil ||
res.Peers != nil ||
res.PeersRemoved != nil ||
// PeersChanged is too coarse to be considered a patch. Also, we convert
// PeersChanged to PeersChangedPatch in patchifyPeersChanged before this
// function is called, so it should never be set anyway. But for
// completedness, and for tests, check it too:
res.PeersChanged != nil ||
res.DeprecatedDefaultAutoUpdate != ""
}
+42 -1
View File
@@ -52,7 +52,16 @@ func TestMapResponseContainsNonPatchFields(t *testing.T) {
// They should be ignored.
want = false
case "PeersChangedPatch", "PeerSeenChange", "OnlineChange":
// The actual three delta fields we care about handling.
// The three legacy delta fields handled via NodeMutation patches.
want = false
case "PeersChanged", "PeersRemoved":
// Now carried as NodeMutationAdd / NodeMutationRemove entries.
want = false
case "PacketFilter", "PacketFilters":
// Now delivered separately via PacketFilterUpdater.
want = false
case "UserProfiles":
// Now delivered separately via UserProfileUpdater.
want = false
default:
// Everything else should be conseratively handled as a
@@ -175,6 +184,36 @@ func TestMutationsFromMapResponse(t *testing.T) {
},
want: nil,
},
{
name: "peer-removed",
mr: &tailcfg.MapResponse{
PeersRemoved: []tailcfg.NodeID{5},
},
want: muts(NodeMutationRemove{5}),
},
{
name: "peer-added",
mr: &tailcfg.MapResponse{
PeersChanged: []*tailcfg.Node{{ID: 7}},
},
want: muts(NodeMutationAdd{Node: (&tailcfg.Node{ID: 7}).View()}),
},
{
name: "add-and-remove-mixed-with-patch",
mr: &tailcfg.MapResponse{
PeersRemoved: []tailcfg.NodeID{3},
PeersChanged: []*tailcfg.Node{{ID: 7}},
PeersChangedPatch: []*tailcfg.PeerChange{{
NodeID: 5,
DERPRegion: 2,
}},
},
want: muts(
NodeMutationRemove{3},
NodeMutationDERPHome{5, 2},
NodeMutationAdd{Node: (&tailcfg.Node{ID: 7}).View()},
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -188,11 +227,13 @@ func TestMutationsFromMapResponse(t *testing.T) {
if diff := cmp.Diff(tt.want, got,
cmp.Comparer(func(a, b netip.Addr) bool { return a == b }),
cmp.Comparer(func(a, b netip.AddrPort) bool { return a == b }),
cmp.Comparer(func(a, b tailcfg.NodeView) bool { return a.ID() == b.ID() }),
cmp.AllowUnexported(
NodeMutationEndpoints{},
NodeMutationDERPHome{},
NodeMutationOnline{},
NodeMutationLastSeen{},
NodeMutationRemove{},
)); diff != "" {
t.Errorf("wrong result (-want +got):\n%s", diff)
}