Files
ziti/controller/sync_strats/rtx.go
Paul Lorenz ae8972bc24 Deliver the router data model to transit routers. For #3743
- Creates a router sender on demand when an untracked router subscribes to the
  data model, so non-edge (transit) routers receive their controller-managed
  configuration without going through the edge connect/sync flow
- Marks on-demand senders as supporting the router data model so they receive
  live model updates, not just the initial sync
- Restricts the legacy api-session/session broadcasts to edge routers, since
  routers register those handlers universally and would otherwise load session
  state they never use. A router counts as edge when an edge router record
  exists for its id, which is how both the connect path and the subscribe path
  classify it; senders carry the result as an atomic edge flag and the fanout
  walks them via RangeEdge. Classification is fixed for the life of a
  connection, so a record added or removed under a live connection takes effect
  on reconnect. Transit routers also never receive the initial session state,
  which only follows a hello
- Adds a race-safe routerTxMap.GetOrCreate keyed on the control channel; the
  subscribe path always routes through it, replacing a sender bound to a stale
  channel instead of enqueueing on a dead one, and resolves edge-router
  membership up front so a replaced edge sender is not demoted to transit
- Adopts an existing sender for the connecting channel in RouterConnected instead
  of rejecting it as a duplicate connect; a subscribe that creates the sender first
  would otherwise leave the router with no server hello, so it never sends a client
  hello and never synchronizes while still being reported online
- Removes routerTxMap.Add, leaving GetOrCreate as the only way a sender is installed
- Classifies a connecting router as edge by id rather than by fingerprint, matching
  the disconnect and subscribe paths, and logs an unexpected store error instead of
  discarding it
- Removes the unused RouterSender.EdgeRouter field and drops the edge router
  argument from the RouterConnected handler
- Generalizes router connect/disconnect logging that previously assumed edge routers
- Updates the strategy flow comment and RouterSender godoc, which described senders
  as edge-only and referenced the removed EdgeRouter field
- Documents that api-session-added events and the legacy session model (API and
  service sessions) are deprecated for removal in OpenZiti 3.0
- Adds unit tests for GetOrCreate (create/reuse/stale-replace/concurrent), the
  edge-only fanout filter, and RouterConnected adopting a subscribe-created sender
2026-08-19 00:51:39 -04:00

460 lines
14 KiB
Go

/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package sync_strats
import (
"fmt"
"reflect"
"sync"
"sync/atomic"
"time"
"github.com/michaelquigley/pfxlog"
"github.com/openziti/channel/v5"
"github.com/openziti/channel/v5/protobufs"
"github.com/openziti/foundation/v2/stringz"
"github.com/openziti/ziti/v2/common"
"github.com/openziti/ziti/v2/common/ctrlchan"
"github.com/openziti/ziti/v2/common/eid"
"github.com/openziti/ziti/v2/common/pb/edge_ctrl_pb"
"github.com/openziti/ziti/v2/controller/env"
"github.com/openziti/ziti/v2/controller/model"
cmap "github.com/orcaman/concurrent-map/v2"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"google.golang.org/protobuf/proto"
)
// RouterSender represents a connection from a router to the controller and asynchronously buffers
// and sends messages to it via Send(). It starts running when created and is stopped with Stop().
// Senders exist for both edge and non-edge (transit) routers; see the edge field for what that
// distinction gates.
type RouterSender struct {
env.RouterState
Id string
Router *model.Router
ae *env.AppEnv
send chan *channel.Message
closeNotify chan struct{}
routerDataModel *common.RouterDataModelSender
requestModelSync chan *edge_ctrl_pb.SubscribeToDataModelRequest
modelChange chan struct{}
currentIndex uint64
syncRdmUntil time.Time
lastIndexSent uint64
running atomic.Bool
timelineId string
subscriptionId string
SupportsRouterModel bool
// edge is true for edge-router senders and false for non-edge (transit) senders, which are
// created on demand when a transit router subscribes to the data model. It gates the legacy
// api-session/session broadcasts, which only edge routers consume. Atomic because the connect
// path can set it on a sender that a subscribe already created and published to the map, where
// the broadcast fanout is already able to see it. Use setEdge and isEdge.
edge atomic.Bool
sync.Mutex
}
func newRouterSender(ae *env.AppEnv, router *model.Router, sendBufferSize int, routerDataModel *common.RouterDataModelSender) *RouterSender {
rtx := &RouterSender{
Id: eid.New(),
Router: router,
ae: ae,
send: make(chan *channel.Message, sendBufferSize),
requestModelSync: make(chan *edge_ctrl_pb.SubscribeToDataModelRequest, 1),
modelChange: make(chan struct{}, 1),
closeNotify: make(chan struct{}),
RouterState: env.NewLockingRouterStatus(),
routerDataModel: routerDataModel,
}
rtx.running.Store(true)
go rtx.run()
return rtx
}
// setEdge marks whether this sender is for an edge router.
func (rtx *RouterSender) setEdge(edge bool) {
rtx.edge.Store(edge)
}
// isEdge reports whether this sender is for an edge router.
func (rtx *RouterSender) isEdge() bool {
return rtx.edge.Load()
}
func (rtx *RouterSender) GetState() env.RouterStateValues {
if rtx == nil {
return env.NewRouterStatusValues()
}
return rtx.Values()
}
func (rtx *RouterSender) Stop() {
if rtx.running.CompareAndSwap(true, false) {
close(rtx.closeNotify)
}
}
func (rtx *RouterSender) run() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-rtx.closeNotify:
return
case msg := <-rtx.send:
_ = rtx.Router.Control.GetDefaultSender().Send(msg)
case req := <-rtx.requestModelSync:
rtx.handleSyncRequest(req)
case <-rtx.modelChange:
rtx.handleModelChange()
case <-ticker.C:
rtx.sendRouterDataModelIndex()
}
}
}
func (rtx *RouterSender) notifyOfModelChange() {
select {
case rtx.modelChange <- struct{}{}:
default:
}
}
func (rtx *RouterSender) sendRouterDataModelIndex() {
if rtx.routerDataModel == nil {
return
}
idx := rtx.routerDataModel.CurrentIndex()
if idx <= rtx.lastIndexSent {
return
}
msg := channel.NewMessage(int32(edge_ctrl_pb.ContentType_CurrentIndexMessageType), nil)
msg.PutUint64Header(int32(edge_ctrl_pb.Header_RouterDataModelIndex), idx)
if err := rtx.Router.Control.GetDefaultSender().Send(msg); err != nil {
logger := pfxlog.Logger().WithField("routerId", rtx.Router.Id)
logger.WithError(err).Error("could not send current router data model index")
} else {
rtx.lastIndexSent = idx
}
}
func (rtx *RouterSender) subscribe(request *edge_ctrl_pb.SubscribeToDataModelRequest) {
logger := pfxlog.Logger().WithField("routerId", rtx.Router.Id)
select {
case rtx.requestModelSync <- request:
return
default:
// already a request queued, let's try to clear it
logger.Debug("data model subscription event received, but one already queued, attempting to clear")
}
select {
case <-rtx.requestModelSync:
default:
}
select {
case rtx.requestModelSync <- request:
return
default:
logger.Debug("data model subscription event received, still can't queue, exiting")
}
}
func (rtx *RouterSender) handleSyncRequest(req *edge_ctrl_pb.SubscribeToDataModelRequest) {
if !req.Renew || req.SubscriptionId != rtx.subscriptionId {
rtx.currentIndex = req.CurrentIndex
}
if req.SubscriptionDurationSeconds < 10 {
req.SubscriptionDurationSeconds = 10
}
if req.SubscriptionDurationSeconds > 360 {
req.SubscriptionDurationSeconds = 360
}
timelineChange := rtx.timelineId != req.TimelineId
rtx.timelineId = req.TimelineId
rtx.subscriptionId = req.SubscriptionId
rtx.syncRdmUntil = time.Now().Add(time.Duration(req.SubscriptionDurationSeconds) * time.Second)
pfxlog.Logger().WithField("routerId", rtx.Router.Id).
WithField("routerName", rtx.Router.Name).
WithField("requestedIndex", req.CurrentIndex).
WithField("currentIndex", rtx.currentIndex).
WithField("renew", req.Renew).
WithField("subscriptionDuration", rtx.syncRdmUntil.String()).
WithField("timelineId", rtx.timelineId).
WithField("subscriptionId", rtx.subscriptionId).
Info("data model subscription started")
if rtx.routerDataModel.CurrentIndex() > rtx.currentIndex || timelineChange {
rtx.handleModelChange()
}
}
func (rtx *RouterSender) handleModelChange() {
if rtx.syncRdmUntil.Before(time.Now()) {
return
}
logger := pfxlog.Logger().WithField("routerId", rtx.Router.Id).
WithField("currentIndex", rtx.currentIndex).
WithField("routerTimelineId", rtx.timelineId).
WithField("ctrlTimelineId", rtx.routerDataModel.GetTimelineId()).
WithField("subscriptionId", rtx.subscriptionId)
var events []*edge_ctrl_pb.DataState_ChangeSet
replayResult := common.ReplayResultFullSyncRequired
if rtx.currentIndex > 0 && rtx.timelineId == rtx.routerDataModel.GetTimelineId() {
events, replayResult = rtx.routerDataModel.ReplayFrom(rtx.currentIndex)
}
var err error
logger.Debugf("event retrieval ok? %s, event count: %d for replay to router", replayResult.String(), len(events))
if replayResult == common.ReplayResultSuccess {
// we replay from the current index, that way if we're already caught up due to timing issues with notify events
// we won't get a 'request from future' result.
if len(events) > 0 {
events = events[1:]
}
for _, curEvent := range events {
// Always forward the change set, even when the filter removes every
// entry. Sending an empty change set advances the receiver's index
// without altering any entities, which keeps the PreviousIndex chain
// intact without per-router bookkeeping. The alternative (skipping
// filter-empty change sets and rewriting PreviousIndex on the next
// non-empty one) is racy under subscription churn and produced gap
// detection storms.
toSend := curEvent
filtered, changed := rtx.filterEventsForRouter(curEvent.Changes, true)
if changed {
toSend = &edge_ctrl_pb.DataState_ChangeSet{
Index: curEvent.Index,
PreviousIndex: curEvent.PreviousIndex,
IsSynthetic: curEvent.IsSynthetic,
TimestampId: curEvent.TimestampId,
Changes: filtered,
}
}
var msg *channel.Message
if msg, err = rtx.marshal(toSend); err == nil {
err = rtx.Router.Control.GetDefaultSender().Send(msg)
}
if err != nil {
logger.WithError(err).
WithField("eventIndex", curEvent.Index).
WithField("eventType", reflect.TypeOf(curEvent).String()).
WithField("synthetic", curEvent.IsSynthetic).
Error("could not send data state event")
break
}
logger.
WithField("eventIndex", curEvent.Index).
WithField("eventType", reflect.TypeOf(curEvent).String()).
WithField("synthetic", curEvent.IsSynthetic).
Debug("data state event sent to router")
rtx.currentIndex = curEvent.Index
}
}
var fullSync bool
if err != nil {
fullSync = true
logger.Info("could not send events for router sync, attempting full state sync")
} else if replayResult == common.ReplayResultFullSyncRequired {
fullSync = true
logger.Info("required sync events not found in event cache, attempting full state sync")
} else if rtx.ae.GetCommandDispatcher().IsLeader() && replayResult == common.ReplayResultRequestFromFuture {
fullSync = true
logger.Info("index is in future, and this node is the leader, attempting full state sync")
}
if fullSync {
//full sync — already filtered per-router (router-target configs only)
dataState := rtx.routerDataModel.GetDataStateForRouter(rtx.Router.Id)
if dataState == nil {
return
}
var msg *channel.Message
if msg, err = rtx.marshal(dataState); err == nil {
err = rtx.Router.Control.GetDefaultSender().Send(msg)
}
if err != nil {
logger.WithError(err).Error("failure sending full data state")
} else {
rtx.currentIndex = dataState.EndIndex
rtx.timelineId = dataState.TimelineId
logger.Infof("router synced data model to index %d with on timeline: %s", rtx.currentIndex, rtx.timelineId)
}
}
}
// filterEventsForRouter delegates to the shared RouterDataModelSender filter.
// See RouterDataModelSender.FilterEventsForRouter for behavior.
func (rtx *RouterSender) filterEventsForRouter(events []*edge_ctrl_pb.DataState_Event, synthesizeMissing bool) ([]*edge_ctrl_pb.DataState_Event, bool) {
return rtx.routerDataModel.FilterEventsForRouter(rtx.Router.Id, events, synthesizeMissing)
}
func (rtx *RouterSender) marshal(message protobufs.TypedMessage) (*channel.Message, error) {
b, err := proto.Marshal(message)
if err != nil {
return nil, fmt.Errorf("failed to marshal %v (%w)", reflect.TypeOf(message), err)
}
result := channel.NewMessage(message.GetContentType(), b)
if rtx.subscriptionId != "" {
result.PutStringHeader(int32(edge_ctrl_pb.Header_RouterDataModelSubscriptionId), rtx.subscriptionId)
}
return result, nil
}
func (rtx *RouterSender) logger() *logrus.Entry {
return pfxlog.Logger().
WithField("routerTxId", rtx.Id).
WithField("routerId", rtx.Router.Id).
WithField("routerName", rtx.Router.Name).
WithField("routerFingerprint", stringz.OrEmpty(rtx.Router.Fingerprint)).
WithField("routerChannelIsOpen", !rtx.Router.Control.IsClosed())
}
func (rtx *RouterSender) Send(msg *channel.Message) error {
if !rtx.running.Load() {
rtx.logger().Errorf("cannot send to router [%s], rtx'er is stopped", rtx.Router.Id)
return errors.Errorf("cannot send to router [%s], rtx'er is stopped", rtx.Router.Id)
}
if rtx.Router.Control.IsClosed() {
rtx.logger().Errorf("cannot send to router [%s], rtx's channel is closed", rtx.Router.Id)
rtx.Stop()
return errors.Errorf("cannot send to router [%s], rtx's channel is closed", rtx.Router.Id)
}
select {
case rtx.send <- msg:
case <-rtx.closeNotify:
}
return nil
}
// Map used make working with internal RouterSender easier as sync.Map accepts and returns interface{}
type routerTxMap struct {
internalMap cmap.ConcurrentMap[string, *RouterSender] //id -> RouterSender
}
// GetOrCreate atomically returns the RouterSender for the given id whose Router.Control
// matches control, creating one via factory if none exists or if the existing sender is
// bound to a stale control channel. It returns the sender and whether it was newly created.
//
// A replaced stale sender is stopped here rather than left to RouterDisconnected, because the
// broker dispatches the old connection's RouterDisconnected asynchronously: on a reconnect the new
// connection's RouterConnected can install its sender before that cleanup runs, which would
// otherwise orphan the old sender's goroutine.
func (m *routerTxMap) GetOrCreate(id string, control ctrlchan.CtrlChannel, factory func() *RouterSender) (rtx *RouterSender, created bool) {
m.internalMap.Upsert(id, nil, func(exists bool, current *RouterSender, _ *RouterSender) *RouterSender {
if exists && current != nil && current.Router.Control == control {
rtx = current
return current
}
if exists && current != nil {
current.Stop()
}
rtx = factory()
created = true
return rtx
})
return rtx, created
}
func (m *routerTxMap) Get(id string) *RouterSender {
val, found := m.internalMap.Get(id)
if !found {
return nil
}
return val
}
func (m *routerTxMap) GetState(id string) env.RouterStateValues {
rtx := m.Get(id)
return rtx.GetState()
}
func (m *routerTxMap) Remove(r *model.Router) {
var rtx *RouterSender
m.internalMap.RemoveCb(r.Id, func(key string, v *RouterSender, exists bool) bool {
if !exists {
return false
}
if v.Router == r {
rtx = v
return true
}
return false
})
if rtx != nil {
rtx.Stop()
}
}
// Range creates a snapshot of the rtx's to loop over and will execute callback
// with each rtx.
func (m *routerTxMap) Range(callback func(entries *RouterSender)) {
var routerSenders []*RouterSender
m.internalMap.IterCb(func(_ string, v *RouterSender) {
routerSenders = append(routerSenders, v)
})
for _, routerSender := range routerSenders {
callback(routerSender)
}
}
// RangeEdge is like Range but only visits senders for edge routers, skipping non-edge
// (transit) senders. Used for the legacy api-session/session broadcasts, which only edge
// routers consume.
func (m *routerTxMap) RangeEdge(callback func(entries *RouterSender)) {
m.Range(func(rtx *RouterSender) {
if rtx.isEdge() {
callback(rtx)
}
})
}