mirror of
https://github.com/hanzoai/team.git
synced 2026-09-30 01:57:35 +00:00
* allow workflow_dispatch trigger for Server CI (for plugins CI) * [MM-68402] MBE Phase 2: declare four generic plugin hooks (#36291) * new hooks-only phase 2 * remove ChannelWillBeMoved * remove RecapWillBeProcessed and MessageWillBeRewrittenByAI Drop the AI/recap hooks from the new-hook surface; AI-LLM paths remain uncovered in tech preview and are documented as residuals. * [MM-68403] MBE Phase 3: ChannelGuards primitive (storage + cache + plugin API) (#36365) * phase 3 * phase 3: register ChannelGuard mock in test setup helper NewChannels' startup-time call to reloadGuardCache invokes s.ChannelGuard().GetAll(); without an expectation on the mock store, every test that sets up the server with GetMockStoreForSetupFunctions panics during init. * phase 3: register ChannelGuard mock in retrylayer test retrylayer.New walks every store getter to wrap it; without the mock expectation on ChannelGuard, TestRetry panics during layer construction. * use rctx properly in the store methods * phase 3: match rctx arg in testlib ChannelGuard mock GetAll now takes request.CTX, so the testify expectation must include mock.Anything; otherwise the call panics under the mocked store. * phase 3: set api.ctx in TestChannelGuardLowercaseNormalization The test constructs PluginAPI directly without a ctx, which used to work when App.RegisterChannelGuard built its own EmptyContext. Now that the App methods take rctx from the caller, the nil ctx panics inside RequestContextWithMaster. * [MM-68404] MBE Phase 4: App-layer plugin hook wiring (#36407) * phase 4 * Fix nil rctx in TestChannelGuardLowercaseNormalization The PluginAPI struct literal was missing ctx: rctx after a refactor moved the rctx declaration below the struct construction, leaving api.ctx as nil. This caused a nil pointer dereference in reloadGuardCache when RegisterChannelGuard called store.RequestContextWithMaster(nil). * Remove ChannelWillBeMoved hook call from MoveChannel (phase 4) The hook and its ID were removed from mbe-phase-2 but the call site in MoveChannel and its i18n string were not cleaned up during the rebase. * remove channel will be moved test * Remove RecapWillBeProcessed and MessageWillBeRewrittenByAI hook calls (phase 4) The hooks and their IDs were removed from mbe-phase-2 but the call sites in ProcessRecapChannel and RewriteMessage, their i18n strings, and their tests were not cleaned up during the rebase. * Revert channel_id plumbing on rewrite endpoint (phase 4) The channel_id field on RewriteRequest was added in phase 4 to feed the synthetic post passed to MessageWillBeRewrittenByAI. With that hook removed from mbe-phase-2, channel_id has no consumer; revert the field, the api4 validation, the app.RewriteMessage parameter, and the corresponding webapp client + hook plumbing. * [MM-68555] MBE Phase 5: Channel-guard enforcement + two-phase dispatch (#36473) * phase 5 * Bake plugin counter-file paths into source instead of env vars t.Setenv panics when an ancestor test calls t.Parallel, so the two channel-guard tests broke under ENABLE_FULLY_PARALLEL_TESTS in CI. Build each plugin source per-subtest with its temp file path embedded as a Go literal — same pattern as TestPluginUploadsAPI. * Remove guarded helpers and tests for dropped hooks (phase 5) The runGuardedRecapWillBeProcessed and runGuardedMessageWillBeRewrittenByAI helpers were never wired (their app-layer call sites were already removed in the phase-4 cleanup), and the corresponding sub-tests across panic / allow / reject / partial plugins reference hooks that no longer exist. * [MM-68405] MBE Phase 6: fire MessagesWillBeConsumed on the edit path (#36475) --------- --------- --------- --------- * rebase onto master --------- Co-authored-by: Hanzo Dev <[email protected]>
217 lines
7.3 KiB
Go
217 lines
7.3 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
"github.com/mattermost/mattermost/server/v8/channels/store"
|
|
)
|
|
|
|
// Backoff bounds for the guard-cache reload retry. Package vars (not consts) so tests can shrink
|
|
// them via t.Cleanup-restored override.
|
|
var (
|
|
guardCacheRetryInitialDelay = 1 * time.Second
|
|
guardCacheRetryMaxDelay = 5 * time.Minute
|
|
)
|
|
|
|
const clusterEventInvalidateChannelGuardCache = model.ClusterEvent("inv_channel_guards")
|
|
|
|
// reloadGuardCache scans the ChannelGuards table and atomically replaces the in-memory cache with
|
|
// the result. Used both at startup (from NewChannels) and from the cluster invalidation handler.
|
|
// Forces a master read because all callers (post-write reload, cluster invalidation) can race with
|
|
// replica lag.
|
|
func (ch *Channels) reloadGuardCache(rctx request.CTX, s store.Store) error {
|
|
guards, err := s.ChannelGuard().GetAll(store.RequestContextWithMaster(rctx))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fresh := &sync.Map{}
|
|
grouped := map[string][]*store.ChannelGuard{}
|
|
for _, g := range guards {
|
|
grouped[g.ChannelId] = append(grouped[g.ChannelId], g)
|
|
}
|
|
for channelID, slice := range grouped {
|
|
fresh.Store(channelID, slice)
|
|
}
|
|
|
|
ch.guardCache.Store(fresh)
|
|
return nil
|
|
}
|
|
|
|
// getGuardsForChannel returns the cached guard slice for a channel, or nil if none.
|
|
func (ch *Channels) getGuardsForChannel(channelID string) []*store.ChannelGuard {
|
|
m := ch.guardCache.Load()
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
v, ok := m.Load(channelID)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
guards, _ := v.([]*store.ChannelGuard)
|
|
return guards
|
|
}
|
|
|
|
// clusterInvalidateGuardCacheHandler is registered as the receive-side handler for
|
|
// clusterEventInvalidateChannelGuardCache. The handler refetches the entire table.
|
|
func (ch *Channels) clusterInvalidateGuardCacheHandler(msg *model.ClusterMessage) {
|
|
rctx := request.EmptyContext(ch.srv.Log())
|
|
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
|
|
ch.srv.Log().Warn(
|
|
"Failed to reload channel guard cache after cluster invalidation; retry scheduled",
|
|
mlog.String("event", string(msg.Event)),
|
|
mlog.Err(err),
|
|
)
|
|
ch.scheduleGuardCacheReloadRetry()
|
|
}
|
|
}
|
|
|
|
// broadcastChannelGuardInvalidation tells the rest of the cluster to refetch their guard caches.
|
|
// The payload is intentionally empty.
|
|
func (ch *Channels) broadcastChannelGuardInvalidation() {
|
|
cluster := ch.srv.platform.Cluster()
|
|
if cluster == nil {
|
|
return
|
|
}
|
|
|
|
msg := &model.ClusterMessage{
|
|
Event: clusterEventInvalidateChannelGuardCache,
|
|
SendType: model.ClusterSendReliable,
|
|
WaitForAllToSend: true,
|
|
}
|
|
cluster.SendClusterMessage(msg)
|
|
}
|
|
|
|
// RegisterChannelGuard records that pluginID claims channelID. The caller's pluginID is expected to
|
|
// be lowercased.
|
|
func (a *App) RegisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
|
|
if channelID == "" {
|
|
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.empty_channel.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
if !model.IsValidId(channelID) {
|
|
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
guard := &store.ChannelGuard{
|
|
ChannelId: channelID,
|
|
PluginId: pluginID,
|
|
CreatedAt: model.GetMillis(),
|
|
}
|
|
if err := a.Srv().Store().ChannelGuard().Save(rctx, guard); err != nil {
|
|
return model.NewAppError("RegisterChannelGuard", "app.channel_guard.register.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
ch := a.Channels()
|
|
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
|
|
a.Srv().Log().Warn(
|
|
"Failed to reload channel guard cache after Register; retry scheduled",
|
|
mlog.String("channel_id", channelID),
|
|
mlog.String("plugin_id", pluginID),
|
|
mlog.Err(err),
|
|
)
|
|
ch.scheduleGuardCacheReloadRetry()
|
|
}
|
|
ch.broadcastChannelGuardInvalidation()
|
|
return nil
|
|
}
|
|
|
|
// UnregisterChannelGuard removes pluginID's claim on channelID. If pluginID has no claim on the
|
|
// channel, this is a no-op (returns nil). The store-level DELETE matches by both ChannelId and
|
|
// PluginId, so other plugins' claims on the same channel are left untouched.
|
|
func (a *App) UnregisterChannelGuard(rctx request.CTX, channelID, pluginID string) *model.AppError {
|
|
if channelID == "" {
|
|
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.empty_channel.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
if !model.IsValidId(channelID) {
|
|
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.invalid_channel.app_error", nil, "", http.StatusBadRequest)
|
|
}
|
|
|
|
rowsAffected, err := a.Srv().Store().ChannelGuard().Delete(rctx, channelID, pluginID)
|
|
if err != nil {
|
|
return model.NewAppError("UnregisterChannelGuard", "app.channel_guard.unregister.app_error", nil, err.Error(), http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
if rowsAffected == 0 {
|
|
a.Srv().Log().Warn(
|
|
"UnregisterChannelGuard removed no rows; pluginID does not match any guard for this channel",
|
|
mlog.String("error_id", "unregister_no_matching_guard"),
|
|
mlog.String("channel_id", channelID),
|
|
mlog.String("plugin_id", pluginID),
|
|
)
|
|
}
|
|
|
|
ch := a.Channels()
|
|
if err := ch.reloadGuardCache(rctx, a.Srv().Store()); err != nil {
|
|
a.Srv().Log().Warn(
|
|
"Failed to reload channel guard cache after Unregister; retry scheduled",
|
|
mlog.String("channel_id", channelID),
|
|
mlog.String("plugin_id", pluginID),
|
|
mlog.Err(err),
|
|
)
|
|
ch.scheduleGuardCacheReloadRetry()
|
|
}
|
|
ch.broadcastChannelGuardInvalidation()
|
|
return nil
|
|
}
|
|
|
|
// scheduleGuardCacheReloadRetry kicks off a single in-flight retry goroutine that calls
|
|
// reloadGuardCache with exponential backoff until success or until the server is shutting down.
|
|
// Multiple concurrent calls collapse to a single retry — useful when Register, Unregister, the
|
|
// cluster handler, and the startup loader can all see the same DB outage simultaneously.
|
|
//
|
|
// Returns true if a new retry goroutine was scheduled, false if one was already in flight. Call
|
|
// sites can ignore the return value; tests use it to assert single-flight semantics.
|
|
func (ch *Channels) scheduleGuardCacheReloadRetry() bool {
|
|
if !ch.guardCacheRetryInFlight.CompareAndSwap(false, true) {
|
|
return false
|
|
}
|
|
go ch.runGuardCacheReloadRetry()
|
|
return true
|
|
}
|
|
|
|
func (ch *Channels) runGuardCacheReloadRetry() {
|
|
defer ch.guardCacheRetryInFlight.Store(false)
|
|
rctx := request.EmptyContext(ch.srv.Log())
|
|
|
|
delay := guardCacheRetryInitialDelay
|
|
for attempt := 1; ; attempt++ {
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-ch.interruptQuitChan:
|
|
timer.Stop()
|
|
ch.srv.Log().Info(
|
|
"Channel guard cache reload retry cancelled by shutdown",
|
|
mlog.Int("attempt", attempt),
|
|
)
|
|
return
|
|
case <-timer.C:
|
|
}
|
|
|
|
if err := ch.reloadGuardCache(rctx, ch.srv.Store()); err != nil {
|
|
ch.srv.Log().Info(
|
|
"Channel guard cache reload retry attempt failed; will retry",
|
|
mlog.Int("attempt", attempt),
|
|
mlog.Err(err),
|
|
)
|
|
delay *= 2
|
|
if delay > guardCacheRetryMaxDelay {
|
|
delay = guardCacheRetryMaxDelay
|
|
}
|
|
continue
|
|
}
|
|
|
|
ch.srv.Log().Info(
|
|
"Channel guard cache reload retry succeeded",
|
|
mlog.Int("attempt", attempt),
|
|
)
|
|
return
|
|
}
|
|
}
|