Files
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

230 lines
6.0 KiB
Go

package cluster
import (
"encoding/json"
"sync"
"time"
"github.com/pkg/errors"
"github.com/hanzoteam/server/server/public/model"
)
const (
// cronPrefix is used to namespace key values created for a job from other key values
// created by a plugin.
cronPrefix = "cron_"
)
// JobPluginAPI is the plugin API interface required to schedule jobs.
type JobPluginAPI interface {
MutexPluginAPI
KVGet(key string) ([]byte, *model.AppError)
KVDelete(key string) *model.AppError
KVList(page, count int) ([]string, *model.AppError)
}
// JobConfig defines the configuration of a scheduled job.
type JobConfig struct {
// Interval is the period of execution for the job.
Interval time.Duration
}
// NextWaitInterval is a callback computing the next wait interval for a job.
type NextWaitInterval func(now time.Time, metadata JobMetadata) time.Duration
// MakeWaitForInterval creates a function to scheduling a job to run on the given interval relative
// to the last finished timestamp.
//
// For example, if the job first starts at 12:01 PM, and is configured with interval 5 minutes,
// it will next run at:
//
// 12:06, 12:11, 12:16, ...
//
// If the job has not previously started, it will run immediately.
func MakeWaitForInterval(interval time.Duration) NextWaitInterval {
if interval == 0 {
panic("must specify non-zero ready interval")
}
return func(now time.Time, metadata JobMetadata) time.Duration {
sinceLastFinished := now.Sub(metadata.LastFinished)
if sinceLastFinished < interval {
return interval - sinceLastFinished
}
return 0
}
}
// MakeWaitForRoundedInterval creates a function, scheduling a job to run on the nearest rounded
// interval relative to the last finished timestamp.
//
// For example, if the job first starts at 12:04 PM, and is configured with interval 5 minutes,
// and is configured to round to 5 minute intervals, it will next run at:
//
// 12:05 PM, 12:10 PM, 12:15 PM, ...
//
// If the job has not previously started, it will run immediately. Note that this wait interval
// strategy does not guarantee a minimum interval between runs, only that subsequent runs will be
// scheduled on the rounded interval.
func MakeWaitForRoundedInterval(interval time.Duration) NextWaitInterval {
if interval == 0 {
panic("must specify non-zero ready interval")
}
return func(now time.Time, metadata JobMetadata) time.Duration {
if metadata.LastFinished.IsZero() {
return 0
}
target := metadata.LastFinished.Add(interval).Truncate(interval)
untilTarget := target.Sub(now)
if untilTarget > 0 {
return untilTarget
}
return 0
}
}
// Job is a scheduled job whose callback function is executed on a configured interval by at most
// one plugin instance at a time.
//
// Use scheduled jobs to perform background activity on a regular interval without having to
// explicitly coordinate with other instances of the same plugin that might repeat that effort.
type Job struct {
pluginAPI JobPluginAPI
key string
mutex *Mutex
nextWaitInterval NextWaitInterval
callback func()
stopOnce sync.Once
stop chan bool
done chan bool
}
// JobMetadata persists metadata about job execution.
type JobMetadata struct {
// LastFinished is the last time the job finished anywhere in the cluster.
LastFinished time.Time
}
// Schedule creates a scheduled job.
func Schedule(pluginAPI JobPluginAPI, key string, nextWaitInterval NextWaitInterval, callback func()) (*Job, error) {
key = cronPrefix + key
mutex, err := NewMutex(pluginAPI, key)
if err != nil {
return nil, errors.Wrap(err, "failed to create job mutex")
}
job := &Job{
pluginAPI: pluginAPI,
key: key,
mutex: mutex,
nextWaitInterval: nextWaitInterval,
callback: callback,
stop: make(chan bool),
done: make(chan bool),
}
go job.run()
return job, nil
}
// readMetadata reads the job execution metadata from the kv store.
func (j *Job) readMetadata() (JobMetadata, error) {
data, appErr := j.pluginAPI.KVGet(j.key)
if appErr != nil {
return JobMetadata{}, errors.Wrap(appErr, "failed to read data")
}
if data == nil {
return JobMetadata{}, nil
}
var metadata JobMetadata
err := json.Unmarshal(data, &metadata)
if err != nil {
return JobMetadata{}, errors.Wrap(err, "failed to decode data")
}
return metadata, nil
}
// saveMetadata writes updated job execution metadata from the kv store.
//
// It is assumed that the job mutex is held, negating the need to require an atomic write.
func (j *Job) saveMetadata(metadata JobMetadata) error {
data, err := json.Marshal(metadata)
if err != nil {
return errors.Wrap(err, "failed to marshal data")
}
ok, appErr := j.pluginAPI.KVSetWithOptions(j.key, data, model.PluginKVSetOptions{})
if appErr != nil || !ok {
return errors.Wrap(appErr, "failed to set data")
}
return nil
}
// run attempts to run the scheduled job, guaranteeing only one instance is executing concurrently.
func (j *Job) run() {
defer close(j.done)
var waitInterval time.Duration
for {
select {
case <-j.stop:
return
case <-time.After(waitInterval):
}
func() {
// Acquire the corresponding job lock and hold it throughout execution.
j.mutex.Lock()
defer j.mutex.Unlock()
metadata, err := j.readMetadata()
if err != nil {
j.pluginAPI.LogError("failed to read job metadata", "err", err, "key", j.key)
waitInterval = nextWaitInterval(waitInterval, err)
return
}
// Is it time to run the job?
waitInterval = j.nextWaitInterval(time.Now(), metadata)
if waitInterval > 0 {
return
}
// Run the job
j.callback()
metadata.LastFinished = time.Now()
err = j.saveMetadata(metadata)
if err != nil {
j.pluginAPI.LogError("failed to write job data", "err", err, "key", j.key)
}
waitInterval = j.nextWaitInterval(time.Now(), metadata)
}()
}
}
// Close terminates a scheduled job, preventing it from being scheduled on this plugin instance.
func (j *Job) Close() error {
j.stopOnce.Do(func() {
close(j.stop)
})
<-j.done
return nil
}