snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 1s
Server CI Nightly Race / Build Environment (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package scheduled_recap
import (
"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/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// SchedulerPollingInterval defines how often the scheduler polls for due scheduled recaps.
const SchedulerPollingInterval = 1 * time.Minute
// Scheduler polls for due scheduled recaps and creates jobs for them.
type Scheduler struct {
*jobs.PeriodicScheduler
store store.Store
jobServer *jobs.JobServer
}
// MakeScheduler creates a new scheduler for scheduled recaps.
func MakeScheduler(jobServer *jobs.JobServer, storeInstance store.Store) *Scheduler {
isEnabled := func(cfg *model.Config) bool {
return cfg.AIRecapsEnabled()
}
return &Scheduler{
PeriodicScheduler: jobs.NewPeriodicScheduler(
jobServer,
model.JobTypeScheduledRecap,
SchedulerPollingInterval,
isEnabled,
),
store: storeInstance,
jobServer: jobServer,
}
}
// NextScheduleTime overrides to use tight polling interval.
func (s *Scheduler) NextScheduleTime(cfg *model.Config, now time.Time, pendingJobs bool, lastJob *model.Job) *time.Time {
next := now.Add(SchedulerPollingInterval)
return &next
}
// ScheduleJob polls for due scheduled recaps and creates jobs for each.
func (s *Scheduler) ScheduleJob(rctx request.CTX, cfg *model.Config, pendingJobs bool, lastJob *model.Job) (*model.Job, *model.AppError) {
now := model.GetMillis()
dueRecaps, err := s.store.ScheduledRecap().GetDueBefore(now, 100)
if err != nil {
mlog.Error("Failed to get due scheduled recaps", mlog.Err(err))
return nil, nil
}
for _, sr := range dueRecaps {
// The worker re-fetches the full row by ID, so the job only needs the ID.
jobData := model.StringMap{
"scheduled_recap_id": sr.Id,
}
job, jobErr := s.jobServer.CreateJobOnceByTypeAndData(
rctx,
model.JobTypeScheduledRecap,
jobData,
map[string]string{"scheduled_recap_id": sr.Id},
)
if jobErr != nil {
mlog.Warn("Scheduled recap job creation failed",
mlog.String("scheduled_recap_id", sr.Id),
mlog.Err(jobErr))
continue
}
if job == nil {
mlog.Debug("Scheduled recap job already queued",
mlog.String("scheduled_recap_id", sr.Id))
}
}
return nil, nil
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package scheduled_recap
import (
"testing"
"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/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
"github.com/mattermost/mattermost/server/v8/channels/utils/testutils"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestScheduleJobEnqueuesEachDueRecapAndSkipsDuplicateAtomically(t *testing.T) {
cfg := &model.Config{}
cfg.SetDefaults()
cfg.FeatureFlags.EnableAIRecaps = true
mockStore := &storetest.Store{}
t.Cleanup(func() {
mockStore.AssertExpectations(t)
})
jobServer := jobs.NewJobServer(&testutils.StaticConfigService{Cfg: cfg}, mockStore, nil, mlog.CreateConsoleTestLogger(t), nil)
jobServer.RegisterJobType(model.JobTypeScheduledRecap, jobs.NewSimpleWorker(
model.JobTypeScheduledRecap,
jobServer,
func(logger mlog.LoggerIFace, job *model.Job) error { return nil },
func(cfg *model.Config) bool { return true },
), nil)
dueRecap1 := testScheduledRecap(true)
dueRecap2 := testScheduledRecap(true)
duplicateRecap := *dueRecap1
dueRecaps := []*model.ScheduledRecap{dueRecap1, dueRecap2, &duplicateRecap}
mockStore.ScheduledRecapStore.
On("GetDueBefore", mock.AnythingOfType("int64"), 100).
Return(dueRecaps, nil)
for _, sr := range []*model.ScheduledRecap{dueRecap1, dueRecap2} {
mockStore.JobStore.
On("SaveOnceByTypeAndData", mock.MatchedBy(func(job *model.Job) bool {
return job.Type == model.JobTypeScheduledRecap &&
len(job.Data) == 1 &&
job.Data["scheduled_recap_id"] == sr.Id
}), map[string]string{"scheduled_recap_id": sr.Id}).
Return(func(job *model.Job, data map[string]string) *model.Job { return job }, nil).
Once()
}
mockStore.JobStore.
On("SaveOnceByTypeAndData", mock.MatchedBy(func(job *model.Job) bool {
return job.Type == model.JobTypeScheduledRecap &&
job.Data["scheduled_recap_id"] == dueRecap1.Id
}), map[string]string{"scheduled_recap_id": dueRecap1.Id}).
Return(nil, nil).
Once()
scheduler := MakeScheduler(jobServer, mockStore)
job, appErr := scheduler.ScheduleJob(request.EmptyContext(mlog.CreateConsoleTestLogger(t)), cfg, false, nil)
require.Nil(t, appErr)
require.Nil(t, job)
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package scheduled_recap
import (
"fmt"
"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/jobs"
"github.com/mattermost/mattermost/server/v8/channels/store"
)
// AppIface defines the app methods required by the scheduled recap worker.
// This interface will be implemented by the App layer in 03-02.
type AppIface interface {
CreateRecapFromSchedule(rctx request.CTX, scheduledRecap *model.ScheduledRecap) (*model.Recap, *model.AppError)
}
// MakeWorker creates a new worker for processing scheduled recap jobs.
func MakeWorker(jobServer *jobs.JobServer, storeInstance store.Store, app AppIface) *jobs.SimpleWorker {
const workerName = "ScheduledRecap"
isEnabled := func(cfg *model.Config) bool {
return cfg.AIRecapsEnabled()
}
execute := func(logger mlog.LoggerIFace, job *model.Job) error {
defer jobServer.HandleJobPanic(logger, job)
return processScheduledRecapJob(logger, job, storeInstance, app)
}
return jobs.NewSimpleWorker(workerName, jobServer, execute, isEnabled)
}
func processScheduledRecapJob(logger mlog.LoggerIFace, job *model.Job, storeInstance store.Store, app AppIface) error {
scheduledRecapID := job.Data["scheduled_recap_id"]
// Get the scheduled recap
sr, err := storeInstance.ScheduledRecap().Get(scheduledRecapID)
if err != nil {
return fmt.Errorf("scheduled recap not found: %w", err)
}
// Verify still enabled
if !sr.Enabled {
logger.Info("Scheduled recap is disabled, skipping",
mlog.String("scheduled_recap_id", scheduledRecapID))
return nil
}
// CreateRecapFromSchedule performs the atomic daily-limit check; an over-limit
// run surfaces as the max_recaps_reached error, which we treat as a skip.
rctx := request.EmptyContext(logger)
_, appErr := app.CreateRecapFromSchedule(rctx, sr)
if appErr != nil {
if appErr.Id == "app.recap.max_recaps_reached.app_error" {
if saveErr := saveSkippedRecap(storeInstance, sr); saveErr != nil {
logger.Error("Failed to save skipped recap", mlog.Err(saveErr))
return fmt.Errorf("failed to save skipped recap: %w", saveErr)
}
logger.Info("Scheduled recap skipped due to daily limit",
mlog.String("scheduled_recap_id", scheduledRecapID),
mlog.String("user_id", sr.UserId))
return finalizeSchedule(logger, storeInstance, sr)
}
return fmt.Errorf("failed to create recap from schedule: %w", appErr)
}
logger.Info("Scheduled recap executed successfully",
mlog.String("scheduled_recap_id", scheduledRecapID))
return finalizeSchedule(logger, storeInstance, sr)
}
// finalizeSchedule advances the schedule to its next run and, for non-recurring
// recaps, disables it last so a one-shot recap is never left enabled.
func finalizeSchedule(logger mlog.LoggerIFace, storeInstance store.Store, sr *model.ScheduledRecap) error {
if err := advanceSchedule(logger, storeInstance, sr); err != nil {
return err
}
if !sr.IsRecurring {
logger.Info("Disabling non-recurring scheduled recap",
mlog.String("scheduled_recap_id", sr.Id))
if setErr := storeInstance.ScheduledRecap().SetEnabled(sr.Id, false); setErr != nil {
return fmt.Errorf("failed to disable non-recurring scheduled recap: %w", setErr)
}
}
return nil
}
// advanceSchedule computes the next run time and marks the scheduled recap as executed.
// If the next run time can't be computed, the recap is disabled.
func advanceSchedule(logger mlog.LoggerIFace, storeInstance store.Store, sr *model.ScheduledRecap) error {
nextRunAt, computeErr := sr.ComputeNextRunAt(time.Now())
if computeErr != nil {
logger.Error("Failed to compute next run time",
mlog.String("scheduled_recap_id", sr.Id),
mlog.Err(computeErr))
if setErr := storeInstance.ScheduledRecap().SetEnabled(sr.Id, false); setErr != nil {
return fmt.Errorf("failed to disable scheduled recap after next-run computation failure: %w", setErr)
}
return fmt.Errorf("failed to compute next run time: %w", computeErr)
}
if markErr := storeInstance.ScheduledRecap().MarkExecuted(sr.Id, model.GetMillis(), nextRunAt); markErr != nil {
logger.Error("Failed to mark as executed",
mlog.String("scheduled_recap_id", sr.Id),
mlog.Err(markErr))
return fmt.Errorf("failed to mark scheduled recap as executed: %w", markErr)
}
return nil
}
func saveSkippedRecap(storeInstance store.Store, sr *model.ScheduledRecap) error {
skippedRecap := &model.Recap{
Id: model.NewId(),
UserId: sr.UserId,
Title: sr.Title,
Status: model.RecapStatusSkipped,
SkipReason: model.SkipReasonDailyLimit,
ScheduledRecapId: sr.Id,
CreateAt: model.GetMillis(),
UpdateAt: model.GetMillis(),
}
if _, saveErr := storeInstance.Recap().SaveRecap(skippedRecap); saveErr != nil {
return saveErr
}
return nil
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package scheduled_recap
import (
"errors"
"testing"
"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/storetest/mocks"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
type mockScheduledRecapApp struct {
mock.Mock
}
func (m *mockScheduledRecapApp) CreateRecapFromSchedule(rctx request.CTX, scheduledRecap *model.ScheduledRecap) (*model.Recap, *model.AppError) {
args := m.Called(rctx, scheduledRecap)
if args.Get(0) == nil {
return nil, args.Get(1).(*model.AppError)
}
return args.Get(0).(*model.Recap), nil
}
func TestProcessScheduledRecapJobReturnsPersistenceErrors(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
t.Run("mark executed failure", func(t *testing.T) {
scheduledRecap := testScheduledRecap(true)
job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}}
mockStore := &mocks.Store{}
mockScheduledStore := &mocks.ScheduledRecapStore{}
mockStore.On("ScheduledRecap").Return(mockScheduledStore)
mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil)
mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(errors.New("mark failed"))
mockApp := &mockScheduledRecapApp{}
mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(&model.Recap{Id: model.NewId()}, nil)
err := processScheduledRecapJob(logger, job, mockStore, mockApp)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to mark scheduled recap as executed")
mockScheduledStore.AssertExpectations(t)
mockApp.AssertExpectations(t)
})
t.Run("non recurring disable failure", func(t *testing.T) {
scheduledRecap := testScheduledRecap(false)
job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}}
mockStore := &mocks.Store{}
mockScheduledStore := &mocks.ScheduledRecapStore{}
mockStore.On("ScheduledRecap").Return(mockScheduledStore)
mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil)
mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil)
mockScheduledStore.On("SetEnabled", scheduledRecap.Id, false).Return(errors.New("disable failed"))
mockApp := &mockScheduledRecapApp{}
mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(&model.Recap{Id: model.NewId()}, nil)
err := processScheduledRecapJob(logger, job, mockStore, mockApp)
require.Error(t, err)
require.Contains(t, err.Error(), "failed to disable non-recurring scheduled recap")
mockScheduledStore.AssertExpectations(t)
mockApp.AssertExpectations(t)
})
}
func TestProcessScheduledRecapJobDailyLimitSkip(t *testing.T) {
logger := mlog.CreateConsoleTestLogger(t)
limitErr := model.NewAppError("CreateRecapFromSchedule", "app.recap.max_recaps_reached.app_error", nil, "", 429)
t.Run("recurring schedule advances but stays enabled", func(t *testing.T) {
scheduledRecap := testScheduledRecap(true)
job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}}
mockStore := &mocks.Store{}
mockScheduledStore := &mocks.ScheduledRecapStore{}
mockRecapStore := &mocks.RecapStore{}
mockStore.On("ScheduledRecap").Return(mockScheduledStore)
mockStore.On("Recap").Return(mockRecapStore)
mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil)
mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil)
mockRecapStore.On("SaveRecap", mock.MatchedBy(func(r *model.Recap) bool {
return r.Status == model.RecapStatusSkipped && r.ScheduledRecapId == scheduledRecap.Id
})).Return(&model.Recap{}, nil)
mockApp := &mockScheduledRecapApp{}
mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(nil, limitErr)
err := processScheduledRecapJob(logger, job, mockStore, mockApp)
require.NoError(t, err)
mockScheduledStore.AssertExpectations(t)
mockRecapStore.AssertExpectations(t)
mockApp.AssertExpectations(t)
// A recurring schedule must not be disabled on the skip path.
mockScheduledStore.AssertNotCalled(t, "SetEnabled", mock.Anything, mock.Anything)
})
t.Run("non recurring schedule is disabled on skip", func(t *testing.T) {
scheduledRecap := testScheduledRecap(false)
job := &model.Job{Data: map[string]string{"scheduled_recap_id": scheduledRecap.Id}}
mockStore := &mocks.Store{}
mockScheduledStore := &mocks.ScheduledRecapStore{}
mockRecapStore := &mocks.RecapStore{}
mockStore.On("ScheduledRecap").Return(mockScheduledStore)
mockStore.On("Recap").Return(mockRecapStore)
mockScheduledStore.On("Get", scheduledRecap.Id).Return(scheduledRecap, nil)
mockScheduledStore.On("MarkExecuted", scheduledRecap.Id, mock.AnythingOfType("int64"), mock.AnythingOfType("int64")).Return(nil)
mockScheduledStore.On("SetEnabled", scheduledRecap.Id, false).Return(nil)
mockRecapStore.On("SaveRecap", mock.MatchedBy(func(r *model.Recap) bool {
return r.Status == model.RecapStatusSkipped && r.ScheduledRecapId == scheduledRecap.Id
})).Return(&model.Recap{}, nil)
mockApp := &mockScheduledRecapApp{}
mockApp.On("CreateRecapFromSchedule", mock.Anything, scheduledRecap).Return(nil, limitErr)
err := processScheduledRecapJob(logger, job, mockStore, mockApp)
require.NoError(t, err)
mockScheduledStore.AssertExpectations(t)
mockRecapStore.AssertExpectations(t)
mockApp.AssertExpectations(t)
})
}
func testScheduledRecap(isRecurring bool) *model.ScheduledRecap {
return &model.ScheduledRecap{
Id: model.NewId(),
UserId: model.NewId(),
Title: "Scheduled Recap",
DaysOfWeek: model.EveryDay,
TimeOfDay: "09:00",
Timezone: "America/New_York",
TimePeriod: model.TimePeriodLast24h,
ChannelMode: model.ChannelModeSpecific,
ChannelIds: []string{model.NewId()},
AgentId: "test-agent",
IsRecurring: isRecurring,
Enabled: true,
}
}