mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* MM-69792: Recover shared channel sync after a brief remote outage When a remote went offline for less than the 5-minute IsOnline() threshold, sync recovery never ran. IsOnline() never flipped, so no connection-state-change event fired and ForceSyncForRemote was never triggered. Posts created during the outage stayed unsynced until the next organic change in the channel. The root cause was that send failures are reported asynchronously through a callback that sendSyncMsgToRemote discarded. On a failed send syncForRemote still returned nil, so processTask saw success: the retry loop and the failure-notification hook were effectively dead code for delivery failures. Changes: - sendSyncMsgToRemote now propagates the delivery error reported via the callback instead of swallowing it. A failed send is retried rather than silently dropped. This applies to every sync type (posts, users, memberships, reactions, statuses); the success path is unchanged. - remotecluster tracks a per-remote "sync failed since last ping" flag (NotifySyncFailed). On the next successful ping, PingNow fires a connection-state-change event even when IsOnline() never flipped, driving a single ForceSyncForRemote on recovery. - Sync retries are now spaced by SyncRetryDelay (15s) instead of retrying immediately, giving a short outage time to recover and guaranteeing a failed ping before retries exhaust. Also adds a post/offline-recovery case to the sharedchannel-test integration tool (stop and restart Server B mid-run) and disables metrics on the two test servers so they do not collide on the metrics port when the loaded config has metrics enabled. * fix linter error * Fix Rabbit nit * fix CI * Delete pr37499-fix-findings-1-and-4.md * address review comments * MM-69792: Keep a per-remote retry task when a shared channel sync fans out A sync task with no specific remote (remoteID == "") fans out to every remote sharing the channel. On failure each remote's retry was a copy of the original task that still carried the original, remote-less task id. Because addTask merges on task id, all of those per-remote retries collapsed into a single entry, so only one failed remote was ever retried and the rest were silently dropped. Delivery failures were partly masked by the ping-driven ForceSyncForRemote recovery, but non-delivery errors for the other remotes had no path back. Give each per-remote retry a remote-specific id (task.id + remoteId) when the originating task had no remote, so every failed remote keeps its own retry task. Single-remote tasks already have a remote-specific id and are left untouched, since recomputing would grow the id on each retry. Also harden the tests: assert the retry is scheduled the full SyncRetryDelay out (no tolerance), assert the result callback is not invoked on delivery failures or unconfirmed responses (so the cursor stays un-advanced), and add a regression test covering fan-out to multiple failing remotes. * fix one more pre-existing edge case. * fix linter
307 lines
9.4 KiB
Go
307 lines
9.4 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package remotecluster
|
|
|
|
import (
|
|
"context"
|
|
"net"
|
|
"net/http"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store"
|
|
"github.com/mattermost/mattermost/server/v8/einterfaces"
|
|
)
|
|
|
|
const (
|
|
SendChanBuffer = 50
|
|
RecvChanBuffer = 50
|
|
ResultsChanBuffer = 50
|
|
ResultQueueDrainTimeoutMillis = 10000
|
|
MaxConcurrentSends = 10
|
|
SendMsgURL = "api/v4/remotecluster/msg"
|
|
SendTimeout = time.Minute
|
|
SendFileTimeout = time.Minute * 5
|
|
PingURL = "api/v4/remotecluster/ping"
|
|
PingFreq = time.Minute
|
|
PingTimeout = time.Second * 15
|
|
ConfirmInviteURL = "api/v4/remotecluster/confirm_invite"
|
|
InvitationTopic = "invitation"
|
|
PingTopic = "ping"
|
|
ResponseStatusOK = model.StatusOk
|
|
ResponseStatusFail = model.StatusFail
|
|
InviteExpiresAfter = time.Hour * 48
|
|
)
|
|
|
|
type ServerIface interface {
|
|
Config() *model.Config
|
|
IsLeader() bool
|
|
AddClusterLeaderChangedListener(listener func()) string
|
|
RemoveClusterLeaderChangedListener(id string)
|
|
GetStore() store.Store
|
|
Log() *mlog.Logger
|
|
GetMetrics() einterfaces.MetricsInterface
|
|
}
|
|
|
|
type AppIface interface {
|
|
OnSharedChannelsPing(rc *model.RemoteCluster) bool
|
|
}
|
|
|
|
// RemoteClusterServiceIFace is used to allow mocking where a remote cluster service is used (for testing).
|
|
// Unfortunately it lives here because the shared channel service, app layer, and server interface all need it.
|
|
// Putting it in app layer means shared channel service must import app package.
|
|
type RemoteClusterServiceIFace interface {
|
|
Shutdown() error
|
|
Start() error
|
|
Active() bool
|
|
AddTopicListener(topic string, listener TopicListener) string
|
|
RemoveTopicListener(listenerId string)
|
|
AddConnectionStateListener(listener ConnectionStateListener) string
|
|
RemoveConnectionStateListener(listenerId string)
|
|
SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error
|
|
SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error
|
|
SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error
|
|
AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string, defaultTeamId string) (*model.RemoteCluster, error)
|
|
ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response
|
|
ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error)
|
|
PingNow(rc *model.RemoteCluster)
|
|
NotifySyncFailed(remoteId string)
|
|
}
|
|
|
|
// TopicListener is a callback signature used to listen for incoming messages for
|
|
// a specific topic.
|
|
type TopicListener func(msg model.RemoteClusterMsg, rc *model.RemoteCluster, resp *Response) error
|
|
|
|
// ConnectionStateListener is used to listen to remote cluster connection state changes.
|
|
type ConnectionStateListener func(rc *model.RemoteCluster, online bool)
|
|
|
|
// Service provides inter-cluster communication via topic based messages. In product these are called "Secured Connections".
|
|
type Service struct {
|
|
server ServerIface
|
|
app AppIface
|
|
httpClient *http.Client
|
|
send []chan any
|
|
done chan struct{} // signals sendLoop workers to stop; lifecycle: Start -> Shutdown
|
|
active atomic.Bool // true after Start(), false after Shutdown()
|
|
disablePing bool // when true, pingStart skips launching the ping loop; for testing
|
|
|
|
syncFailedSinceLastPing sync.Map // set of remoteIds (string) with a pending sync failure; presence is the marker
|
|
|
|
// everything below guarded by `mux`
|
|
mux sync.RWMutex
|
|
leaderListenerId string
|
|
topicListeners map[string]map[string]TopicListener // maps topic id to a map of listenerid->listener
|
|
connectionStateListeners map[string]ConnectionStateListener // maps listener id to listener
|
|
pingDone chan struct{} // signals ping workers to stop; lifecycle: pingStart -> pingStop
|
|
pingFreq time.Duration
|
|
}
|
|
|
|
// NotifySyncFailed records that a sync has failed for the given remote. On the next
|
|
// successful ping, PingNow will fire a connection-state-change event so the shared
|
|
// channel service re-syncs any affected channels.
|
|
func (rcs *Service) NotifySyncFailed(remoteId string) {
|
|
rcs.syncFailedSinceLastPing.Store(remoteId, struct{}{})
|
|
}
|
|
|
|
// NewRemoteClusterService creates a RemoteClusterService instance. In product this is called a "Secured Connection".
|
|
func NewRemoteClusterService(server ServerIface, app AppIface) (*Service, error) {
|
|
transport := &http.Transport{
|
|
Proxy: http.ProxyFromEnvironment,
|
|
DialContext: (&net.Dialer{
|
|
Timeout: 30 * time.Second,
|
|
KeepAlive: 30 * time.Second,
|
|
DualStack: true,
|
|
}).DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 200,
|
|
MaxIdleConnsPerHost: 2,
|
|
IdleConnTimeout: 90 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: 1 * time.Second,
|
|
DisableCompression: false,
|
|
}
|
|
|
|
client := &http.Client{
|
|
Transport: transport,
|
|
Timeout: SendTimeout,
|
|
}
|
|
|
|
service := &Service{
|
|
server: server,
|
|
app: app,
|
|
httpClient: client,
|
|
topicListeners: make(map[string]map[string]TopicListener),
|
|
connectionStateListeners: make(map[string]ConnectionStateListener),
|
|
}
|
|
|
|
service.send = make([]chan any, MaxConcurrentSends)
|
|
for i := range service.send {
|
|
service.send[i] = make(chan any, SendChanBuffer)
|
|
}
|
|
service.pingFreq = PingFreq
|
|
|
|
return service, nil
|
|
}
|
|
|
|
// Start is called by the server on server start-up.
|
|
func (rcs *Service) Start() error {
|
|
if !rcs.active.CompareAndSwap(false, true) {
|
|
return nil
|
|
}
|
|
rcs.done = make(chan struct{})
|
|
for i := range rcs.send {
|
|
go rcs.sendLoop(i, rcs.done)
|
|
}
|
|
|
|
rcs.mux.Lock()
|
|
rcs.leaderListenerId = rcs.server.AddClusterLeaderChangedListener(rcs.onClusterLeaderChange)
|
|
rcs.mux.Unlock()
|
|
|
|
rcs.onClusterLeaderChange()
|
|
|
|
return nil
|
|
}
|
|
|
|
// Shutdown is called by the server on server shutdown.
|
|
func (rcs *Service) Shutdown() error {
|
|
if !rcs.active.CompareAndSwap(true, false) {
|
|
return nil
|
|
}
|
|
rcs.mux.Lock()
|
|
id := rcs.leaderListenerId
|
|
rcs.mux.Unlock()
|
|
|
|
rcs.server.RemoveClusterLeaderChangedListener(id)
|
|
rcs.pingStop()
|
|
close(rcs.done)
|
|
return nil
|
|
}
|
|
|
|
// Active returns true if this instance of the remote cluster service has been started.
|
|
func (rcs *Service) Active() bool {
|
|
return rcs.active.Load()
|
|
}
|
|
|
|
// GetPingFreq gets the frequency of pings to each remote.
|
|
func (rcs *Service) GetPingFreq() time.Duration {
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
return rcs.pingFreq
|
|
}
|
|
|
|
// SetPingFreq sets the frequency of pings to each remote. Defaults to `PingFreq`.
|
|
// This is typically used to set a higher frequency for testing.
|
|
func (rcs *Service) SetPingFreq(freq time.Duration) {
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
rcs.pingFreq = freq
|
|
}
|
|
|
|
// AddTopicListener registers a callback
|
|
func (rcs *Service) AddTopicListener(topic string, listener TopicListener) string {
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
|
|
id := model.NewId()
|
|
|
|
listeners, ok := rcs.topicListeners[topic]
|
|
if !ok || listeners == nil {
|
|
rcs.topicListeners[topic] = make(map[string]TopicListener)
|
|
}
|
|
rcs.topicListeners[topic][id] = listener
|
|
return id
|
|
}
|
|
|
|
func (rcs *Service) RemoveTopicListener(listenerId string) {
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
|
|
for topic, listeners := range rcs.topicListeners {
|
|
if _, ok := listeners[listenerId]; ok {
|
|
delete(listeners, listenerId)
|
|
if len(listeners) == 0 {
|
|
delete(rcs.topicListeners, topic)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func (rcs *Service) getTopicListeners(topic string) []TopicListener {
|
|
rcs.mux.RLock()
|
|
defer rcs.mux.RUnlock()
|
|
|
|
listeners, ok := rcs.topicListeners[topic]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
listenersCopy := make([]TopicListener, 0, len(listeners))
|
|
for _, l := range listeners {
|
|
listenersCopy = append(listenersCopy, l)
|
|
}
|
|
return listenersCopy
|
|
}
|
|
|
|
func (rcs *Service) AddConnectionStateListener(listener ConnectionStateListener) string {
|
|
id := model.NewId()
|
|
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
|
|
rcs.connectionStateListeners[id] = listener
|
|
return id
|
|
}
|
|
|
|
func (rcs *Service) RemoveConnectionStateListener(listenerId string) {
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
delete(rcs.connectionStateListeners, listenerId)
|
|
}
|
|
|
|
// onClusterLeaderChange is called whenever the cluster leader may have changed.
|
|
func (rcs *Service) onClusterLeaderChange() {
|
|
if rcs.server.IsLeader() {
|
|
rcs.pingStart()
|
|
} else {
|
|
rcs.pingStop()
|
|
}
|
|
}
|
|
|
|
func (rcs *Service) pingStart() {
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
|
|
if rcs.pingDone != nil {
|
|
return
|
|
}
|
|
rcs.pingDone = make(chan struct{})
|
|
|
|
if !rcs.disablePing {
|
|
// first ping all the plugin remotes immediately, synchronously.
|
|
rcs.pingAllNow(model.RemoteClusterQueryFilter{OnlyPlugins: true})
|
|
|
|
// start the async ping loop
|
|
rcs.pingLoop(rcs.pingDone)
|
|
}
|
|
|
|
rcs.server.Log().Debug("Remote Cluster Service ping active")
|
|
}
|
|
|
|
func (rcs *Service) pingStop() {
|
|
rcs.mux.Lock()
|
|
defer rcs.mux.Unlock()
|
|
|
|
if rcs.pingDone == nil {
|
|
return
|
|
}
|
|
close(rcs.pingDone)
|
|
rcs.pingDone = nil
|
|
|
|
rcs.server.Log().Debug("Remote Cluster Service ping inactive")
|
|
}
|