init
This commit is contained in:
131
pkg/schedule/rule/periodic.go
Normal file
131
pkg/schedule/rule/periodic.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// Copyright (C) 2025 wangyusong
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed/block"
|
||||
"github.com/glidea/zenfeed/pkg/telemetry"
|
||||
"github.com/glidea/zenfeed/pkg/telemetry/log"
|
||||
telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model"
|
||||
timeutil "github.com/glidea/zenfeed/pkg/util/time"
|
||||
)
|
||||
|
||||
func newPeriodic(instance string, config *Config, dependencies Dependencies) (Rule, error) {
|
||||
return &periodic{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: "PeriodicRuler",
|
||||
Instance: instance,
|
||||
Config: config,
|
||||
Dependencies: dependencies,
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type periodic struct {
|
||||
*component.Base[Config, Dependencies]
|
||||
}
|
||||
|
||||
func (r *periodic) Run() (err error) {
|
||||
ctx := telemetry.StartWith(r.Context(), append(r.TelemetryLabels(), telemetrymodel.KeyOperation, "Run")...)
|
||||
defer func() { telemetry.End(ctx, err) }()
|
||||
r.MarkReady()
|
||||
|
||||
iter := func(now time.Time) {
|
||||
config := r.Config()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end := time.Date(today.Year(), today.Month(), today.Day(),
|
||||
config.end.Hour(), config.end.Minute(), 0, 0, today.Location())
|
||||
|
||||
buffer := 20 * time.Minute
|
||||
endPlusBuffer := end.Add(buffer)
|
||||
if now.Before(end) || now.After(endPlusBuffer) {
|
||||
return
|
||||
}
|
||||
if err := r.execute(ctx, now); err != nil {
|
||||
log.Warn(ctx, errors.Wrap(err, "execute, retry in next time"))
|
||||
}
|
||||
log.Debug(ctx, "rule executed", "now", now, "end", end)
|
||||
}
|
||||
|
||||
offset := timeutil.Random(time.Minute)
|
||||
log.Debug(ctx, "computed watch offset", "offset", offset)
|
||||
|
||||
tick := time.NewTimer(offset)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case now := <-tick.C:
|
||||
iter(now)
|
||||
tick.Reset(3 * time.Minute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *periodic) execute(ctx context.Context, now time.Time) error {
|
||||
// Determine the query interval based on now and config's start, end and crossDay.
|
||||
config := r.Config()
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var start, end time.Time
|
||||
if config.crossDay {
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
start = time.Date(yesterday.Year(), yesterday.Month(), yesterday.Day(),
|
||||
config.start.Hour(), config.start.Minute(), 0, 0, yesterday.Location())
|
||||
end = time.Date(today.Year(), today.Month(), today.Day(),
|
||||
config.end.Hour(), config.end.Minute(), 0, 0, today.Location())
|
||||
} else {
|
||||
start = time.Date(today.Year(), today.Month(), today.Day(),
|
||||
config.start.Hour(), config.start.Minute(), 0, 0, today.Location())
|
||||
end = time.Date(today.Year(), today.Month(), today.Day(),
|
||||
config.end.Hour(), config.end.Minute(), 0, 0, today.Location())
|
||||
}
|
||||
|
||||
// Query.
|
||||
ctx = log.With(ctx, "start", start, "end", end)
|
||||
feeds, err := r.Dependencies().FeedStorage.Query(ctx, block.QueryOptions{
|
||||
Query: config.Query,
|
||||
Threshold: config.Threshold,
|
||||
LabelFilters: config.LabelFilters,
|
||||
Start: start,
|
||||
End: end,
|
||||
Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query")
|
||||
}
|
||||
if len(feeds) == 0 {
|
||||
log.Debug(ctx, "no feeds found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Notify.
|
||||
r.Dependencies().Out <- &Result{
|
||||
Rule: config.Name,
|
||||
Time: start,
|
||||
Feeds: feeds,
|
||||
}
|
||||
log.Debug(ctx, "rule notified", "feeds", len(feeds))
|
||||
|
||||
return nil
|
||||
}
|
||||
253
pkg/schedule/rule/periodic_test.go
Normal file
253
pkg/schedule/rule/periodic_test.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/model"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed/block"
|
||||
"github.com/glidea/zenfeed/pkg/test"
|
||||
)
|
||||
|
||||
func TestPeriodicExecute(t *testing.T) {
|
||||
RegisterTestingT(t)
|
||||
|
||||
// --- Test types ---
|
||||
type givenDetail struct {
|
||||
config *Config
|
||||
feedStorageMock func(m *mock.Mock) // Function to set expectations
|
||||
}
|
||||
type whenDetail struct {
|
||||
now time.Time
|
||||
}
|
||||
type thenExpected struct {
|
||||
queryCalled bool
|
||||
queryOpts *block.QueryOptions // Only check relevant fields like start/end
|
||||
sentToOut *Result
|
||||
err error // Expected error (can be wrapped)
|
||||
isErr bool
|
||||
}
|
||||
|
||||
// --- Test cases ---
|
||||
mockFeeds := []*block.FeedVO{
|
||||
{Feed: &model.Feed{ID: 1, Labels: model.Labels{{Key: "content_hash", Value: "a"}}}},
|
||||
{Feed: &model.Feed{ID: 2, Labels: model.Labels{{Key: "content_hash", Value: "b"}}}},
|
||||
}
|
||||
baseConfig := &Config{
|
||||
Name: "test-periodic",
|
||||
EveryDay: "09:00~18:00", // Will be parsed in Validate
|
||||
Threshold: 0.7,
|
||||
Query: "test query",
|
||||
}
|
||||
// Manually parse time for expected values
|
||||
startTime, _ := time.ParseInLocation(timeFmt, "09:00", time.Local)
|
||||
endTime, _ := time.ParseInLocation(timeFmt, "18:00", time.Local)
|
||||
|
||||
crossDayConfig := &Config{
|
||||
Name: "test-crossday",
|
||||
EveryDay: "-22:00~06:00", // Will be parsed in Validate
|
||||
Threshold: 0.7,
|
||||
Query: "test query",
|
||||
}
|
||||
// Manually parse time for expected values
|
||||
crossStartTime, _ := time.ParseInLocation(timeFmt, "22:00", time.Local)
|
||||
crossEndTime, _ := time.ParseInLocation(timeFmt, "06:00", time.Local)
|
||||
|
||||
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
||||
{
|
||||
Scenario: "Non-crossDay, feeds found, should query and notify",
|
||||
Given: "a non-crossDay config and FeedStorage returns feeds",
|
||||
When: "execute is called within the configured day",
|
||||
Then: "FeedStorage should be queried with the correct daily time range and result sent to Out",
|
||||
GivenDetail: givenDetail{
|
||||
config: baseConfig,
|
||||
feedStorageMock: func(m *mock.Mock) {
|
||||
m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")).
|
||||
Return(mockFeeds, nil)
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
now: time.Date(2024, 1, 15, 10, 0, 0, 0, time.Local), // 10:00 AM
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
queryCalled: true,
|
||||
queryOpts: &block.QueryOptions{
|
||||
Start: time.Date(2024, 1, 15, startTime.Hour(), startTime.Minute(), 0, 0, time.Local),
|
||||
End: time.Date(2024, 1, 15, endTime.Hour(), endTime.Minute(), 0, 0, time.Local),
|
||||
Query: baseConfig.Query,
|
||||
Limit: 500,
|
||||
},
|
||||
sentToOut: &Result{
|
||||
Rule: baseConfig.Name,
|
||||
Time: time.Date(2024, 1, 15, startTime.Hour(), startTime.Minute(), 0, 0, time.Local),
|
||||
Feeds: mockFeeds,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "CrossDay, feeds found, should query and notify",
|
||||
Given: "a crossDay config and FeedStorage returns feeds",
|
||||
When: "execute is called within the configured day",
|
||||
Then: "FeedStorage should be queried with the correct cross-day time range and result sent to Out",
|
||||
GivenDetail: givenDetail{
|
||||
config: crossDayConfig,
|
||||
feedStorageMock: func(m *mock.Mock) {
|
||||
m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")).
|
||||
Return(mockFeeds, nil)
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
now: time.Date(2024, 1, 15, 03, 0, 0, 0, time.Local), // 03:00 AM
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
queryCalled: true,
|
||||
queryOpts: &block.QueryOptions{
|
||||
Start: time.Date(2024, 1, 14, crossStartTime.Hour(), crossStartTime.Minute(), 0, 0, time.Local),
|
||||
End: time.Date(2024, 1, 15, crossEndTime.Hour(), crossEndTime.Minute(), 0, 0, time.Local),
|
||||
Query: crossDayConfig.Query,
|
||||
Limit: 500,
|
||||
},
|
||||
sentToOut: &Result{
|
||||
Rule: crossDayConfig.Name,
|
||||
Time: time.Date(2024, 1, 14, crossStartTime.Hour(), crossStartTime.Minute(), 0, 0, time.Local),
|
||||
Feeds: mockFeeds,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "Non-crossDay, no feeds found, should query but not notify",
|
||||
Given: "a non-crossDay config and FeedStorage returns no feeds",
|
||||
When: "execute is called",
|
||||
Then: "FeedStorage should be queried but nothing sent to Out",
|
||||
GivenDetail: givenDetail{
|
||||
config: baseConfig,
|
||||
feedStorageMock: func(m *mock.Mock) {
|
||||
m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")).
|
||||
Return([]*block.FeedVO{}, nil) // Empty result
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
now: time.Date(2024, 1, 15, 11, 0, 0, 0, time.Local), // 11:00 AM
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
queryCalled: true,
|
||||
queryOpts: &block.QueryOptions{
|
||||
Start: time.Date(2024, 1, 15, startTime.Hour(), startTime.Minute(), 0, 0, time.Local),
|
||||
End: time.Date(2024, 1, 15, endTime.Hour(), endTime.Minute(), 0, 0, time.Local),
|
||||
Query: baseConfig.Query,
|
||||
Limit: 500,
|
||||
},
|
||||
sentToOut: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// --- Run tests ---
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Scenario, func(t *testing.T) {
|
||||
// --- Given ---
|
||||
configCopy := *tt.GivenDetail.config
|
||||
err := configCopy.Validate()
|
||||
Expect(err).NotTo(HaveOccurred(), "Config validation failed in test setup")
|
||||
|
||||
outCh := make(chan *Result, 1)
|
||||
var capturedOpts block.QueryOptions
|
||||
var mockStorageInstance *mock.Mock
|
||||
|
||||
// Create mock factory using feed.NewFactory and capture the mock instance
|
||||
mockOption := component.MockOption(func(m *mock.Mock) {
|
||||
mockStorageInstance = m // Capture the mock instance
|
||||
// Setup mock expectation for FeedStorage.Query, including option capture
|
||||
if tt.GivenDetail.feedStorageMock != nil {
|
||||
tt.GivenDetail.feedStorageMock(m)
|
||||
// Enhance the mock setup to capture arguments if the mock function exists
|
||||
// Find the Query expectation and add argument capture logic
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "Query" {
|
||||
// Replace the generic matcher for options with one that captures
|
||||
for i, arg := range call.Arguments {
|
||||
if _, ok := arg.(mock.AnythingOfTypeArgument); ok && i == 1 { // Assuming options is the second argument (index 1)
|
||||
call.Arguments[i] = mock.MatchedBy(func(opts block.QueryOptions) bool {
|
||||
capturedOpts = opts // Capture the options
|
||||
return true
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
break // Assume only one Query expectation per test case here
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
mockFeedFactory := feed.NewFactory(mockOption)
|
||||
mockFeedStorage, factoryErr := mockFeedFactory.New(component.Global, nil, feed.Dependencies{}) // Use factory to create mock
|
||||
Expect(factoryErr).NotTo(HaveOccurred())
|
||||
|
||||
dependencies := Dependencies{
|
||||
FeedStorage: mockFeedStorage, // Use the created mock storage
|
||||
Out: outCh,
|
||||
}
|
||||
|
||||
r := &periodic{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: "PeriodicRuler",
|
||||
Instance: "test-instance",
|
||||
Config: &configCopy,
|
||||
Dependencies: dependencies,
|
||||
}),
|
||||
}
|
||||
|
||||
// --- When ---
|
||||
err = r.execute(context.Background(), tt.WhenDetail.now)
|
||||
|
||||
// --- Then ---
|
||||
if tt.ThenExpected.isErr {
|
||||
Expect(err).To(HaveOccurred())
|
||||
// Use MatchError for potentially wrapped errors, providing a more precise check
|
||||
Expect(err).To(MatchError(tt.ThenExpected.err))
|
||||
Expect(len(outCh)).To(Equal(0))
|
||||
} else {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
if tt.ThenExpected.sentToOut != nil {
|
||||
Expect(len(outCh)).To(Equal(1))
|
||||
receivedResult := <-outCh
|
||||
Expect(receivedResult.Rule).To(Equal(tt.ThenExpected.sentToOut.Rule))
|
||||
Expect(receivedResult.Time.Unix()).To(Equal(tt.ThenExpected.sentToOut.Time.Unix()))
|
||||
Expect(receivedResult.Feeds).To(Equal(tt.ThenExpected.sentToOut.Feeds))
|
||||
} else {
|
||||
Expect(len(outCh)).To(Equal(0))
|
||||
}
|
||||
}
|
||||
|
||||
// Verify FeedStorage.Query call and options using the captured mock instance
|
||||
if mockStorageInstance != nil { // Ensure mock instance was captured
|
||||
if tt.ThenExpected.queryCalled {
|
||||
// Assert the expectation set up in feedStorageMockFn was met
|
||||
mockStorageInstance.AssertCalled(t, "Query", mock.Anything, mock.AnythingOfType("block.QueryOptions"))
|
||||
// Assert specific fields of the captured options
|
||||
Expect(capturedOpts.Start.Unix()).To(Equal(tt.ThenExpected.queryOpts.Start.Unix()), "Start time mismatch")
|
||||
Expect(capturedOpts.End.Unix()).To(Equal(tt.ThenExpected.queryOpts.End.Unix()), "End time mismatch")
|
||||
Expect(capturedOpts.Query).To(Equal(tt.ThenExpected.queryOpts.Query), "Query string mismatch")
|
||||
Expect(capturedOpts.Threshold).To(Equal(configCopy.Threshold), "Threshold mismatch")
|
||||
Expect(capturedOpts.LabelFilters).To(Equal(configCopy.LabelFilters), "LabelFilters mismatch")
|
||||
Expect(capturedOpts.Limit).To(Equal(tt.ThenExpected.queryOpts.Limit), "Limit mismatch")
|
||||
} else {
|
||||
mockStorageInstance.AssertNotCalled(t, "Query", mock.Anything, mock.Anything)
|
||||
}
|
||||
// Optionally, assert all expectations are met
|
||||
// mockStorageInstance.AssertExpectations(t) // Uncomment if you want strict expectation matching
|
||||
} else if tt.ThenExpected.queryCalled {
|
||||
// Fail if query was expected but mock instance wasn't captured (indicates setup issue)
|
||||
t.Fatal("Expected query call but mock instance was not captured")
|
||||
}
|
||||
|
||||
close(outCh)
|
||||
})
|
||||
}
|
||||
}
|
||||
166
pkg/schedule/rule/rule.go
Normal file
166
pkg/schedule/rule/rule.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (C) 2025 wangyusong
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package rule
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed/block"
|
||||
)
|
||||
|
||||
// --- Interface code block ---
|
||||
type Rule interface {
|
||||
component.Component
|
||||
Config() *Config
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Name string
|
||||
Query string
|
||||
Threshold float32
|
||||
LabelFilters []string
|
||||
|
||||
// Periodic type.
|
||||
EveryDay string // e.g. "00:00~23:59", or "-22:00~7:00" (yesterday 22:00 to today 07:00)
|
||||
start, end time.Time
|
||||
crossDay bool
|
||||
|
||||
// Watch type.
|
||||
WatchInterval time.Duration
|
||||
}
|
||||
|
||||
var (
|
||||
timeSep = "~"
|
||||
timeYesterdayPrefix = "-"
|
||||
timeFmt = "15:04"
|
||||
)
|
||||
|
||||
func (c *Config) Validate() error { //nolint:cyclop,gocognit
|
||||
if c.Name == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
if c.Query != "" && utf8.RuneCountInString(c.Query) < 5 {
|
||||
return errors.New("query must be at least 5 characters")
|
||||
}
|
||||
if c.Threshold == 0 {
|
||||
c.Threshold = 0.6
|
||||
}
|
||||
if c.Threshold < 0 || c.Threshold > 1 {
|
||||
return errors.New("threshold must be between 0 and 1")
|
||||
}
|
||||
if c.EveryDay != "" && c.WatchInterval != 0 {
|
||||
return errors.New("every_day and watch_interval cannot both be set")
|
||||
}
|
||||
switch c.EveryDay {
|
||||
case "":
|
||||
if c.WatchInterval < 10*time.Minute {
|
||||
c.WatchInterval = 10 * time.Minute
|
||||
}
|
||||
default:
|
||||
times := strings.Split(c.EveryDay, timeSep)
|
||||
if len(times) != 2 {
|
||||
return errors.New("every_day must be in format 'start~end'")
|
||||
}
|
||||
|
||||
start, end := strings.TrimSpace(times[0]), strings.TrimSpace(times[1])
|
||||
isYesterday := strings.HasPrefix(start, timeYesterdayPrefix)
|
||||
if isYesterday {
|
||||
start = start[1:] // Remove the "-" prefix
|
||||
c.crossDay = true
|
||||
}
|
||||
|
||||
// Parse start time.
|
||||
startTime, err := time.ParseInLocation(timeFmt, start, time.Local)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "parse start time")
|
||||
}
|
||||
|
||||
// Parse end time.
|
||||
endTime, err := time.ParseInLocation(timeFmt, end, time.Local)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "parse end time")
|
||||
}
|
||||
|
||||
// For non-yesterday time range, end time must be after start time.
|
||||
if !isYesterday && endTime.Before(startTime) {
|
||||
return errors.New("end time must be after start time")
|
||||
}
|
||||
|
||||
c.start, c.end = startTime, endTime
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
FeedStorage feed.Storage
|
||||
Out chan<- *Result
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Rule string
|
||||
Time time.Time
|
||||
Feeds []*block.FeedVO
|
||||
}
|
||||
|
||||
// --- Factory code block ---
|
||||
|
||||
type Factory component.Factory[Rule, Config, Dependencies]
|
||||
|
||||
func NewFactory(mockOn ...component.MockOption) Factory {
|
||||
if len(mockOn) > 0 {
|
||||
return component.FactoryFunc[Rule, Config, Dependencies](
|
||||
func(instance string, config *Config, dependencies Dependencies) (Rule, error) {
|
||||
m := &mockRule{}
|
||||
component.MockOptions(mockOn).Apply(&m.Mock)
|
||||
|
||||
return m, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return component.FactoryFunc[Rule, Config, Dependencies](new)
|
||||
}
|
||||
|
||||
func new(instance string, config *Config, dependencies Dependencies) (Rule, error) {
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "validate config")
|
||||
}
|
||||
|
||||
switch config.EveryDay {
|
||||
case "":
|
||||
return newWatch(instance, config, dependencies)
|
||||
default:
|
||||
return newPeriodic(instance, config, dependencies)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Implementation code block ---
|
||||
type mockRule struct {
|
||||
component.Mock
|
||||
}
|
||||
|
||||
func (m *mockRule) Config() *Config {
|
||||
args := m.Called()
|
||||
|
||||
return args.Get(0).(*Config)
|
||||
}
|
||||
122
pkg/schedule/rule/watch.go
Normal file
122
pkg/schedule/rule/watch.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (C) 2025 wangyusong
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed/block"
|
||||
"github.com/glidea/zenfeed/pkg/telemetry"
|
||||
"github.com/glidea/zenfeed/pkg/telemetry/log"
|
||||
telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model"
|
||||
timeutil "github.com/glidea/zenfeed/pkg/util/time"
|
||||
)
|
||||
|
||||
func newWatch(instance string, config *Config, dependencies Dependencies) (Rule, error) {
|
||||
return &watch{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: "WatchRuler",
|
||||
Instance: instance,
|
||||
Config: config,
|
||||
Dependencies: dependencies,
|
||||
}),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type watch struct {
|
||||
*component.Base[Config, Dependencies]
|
||||
}
|
||||
|
||||
func (r *watch) Run() (err error) {
|
||||
ctx := telemetry.StartWith(r.Context(), append(r.TelemetryLabels(), telemetrymodel.KeyOperation, "Run")...)
|
||||
defer func() { telemetry.End(ctx, err) }()
|
||||
r.MarkReady()
|
||||
|
||||
iter := func(now time.Time) {
|
||||
config := r.Config()
|
||||
end := time.Unix(now.Unix(), 0).Truncate(config.WatchInterval)
|
||||
// Interval 0, 1 are retry, to ensure success.
|
||||
// That means, one execution result at least send 3 times.
|
||||
// So the customer need to deduplicate the result by themselves.
|
||||
start := end.Add(-3 * config.WatchInterval)
|
||||
|
||||
if err := r.execute(ctx, start, end); err != nil {
|
||||
log.Warn(ctx, errors.Wrap(err, "execute, retry in next time"))
|
||||
}
|
||||
log.Debug(ctx, "watch rule executed", "start", start, "end", end)
|
||||
}
|
||||
|
||||
offset := timeutil.Random(time.Minute)
|
||||
log.Debug(ctx, "computed watch offset", "offset", offset)
|
||||
|
||||
tick := time.NewTimer(offset)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return nil
|
||||
case now := <-tick.C:
|
||||
iter(now)
|
||||
tick.Reset(r.Config().WatchInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *watch) execute(ctx context.Context, start, end time.Time) error {
|
||||
ctx = log.With(ctx, "start", start, "end", end)
|
||||
|
||||
// Query.
|
||||
config := r.Config()
|
||||
feeds, err := r.Dependencies().FeedStorage.Query(ctx, block.QueryOptions{
|
||||
Query: config.Query,
|
||||
Threshold: config.Threshold,
|
||||
LabelFilters: config.LabelFilters,
|
||||
Start: start,
|
||||
End: end,
|
||||
Limit: 500,
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "query")
|
||||
}
|
||||
if len(feeds) == 0 {
|
||||
log.Debug(ctx, "no feeds found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Split feeds by start time.
|
||||
feedsByStart := make(map[time.Time][]*block.FeedVO) // Start time -> feeds.
|
||||
for _, feed := range feeds {
|
||||
interval := time.Unix(feed.Time.Unix(), 0).Truncate(config.WatchInterval)
|
||||
feedsByStart[interval] = append(feedsByStart[interval], feed)
|
||||
}
|
||||
|
||||
// Notify.
|
||||
for start, feeds := range feedsByStart {
|
||||
r.Dependencies().Out <- &Result{
|
||||
Rule: config.Name,
|
||||
Time: start,
|
||||
Feeds: feeds,
|
||||
}
|
||||
}
|
||||
log.Debug(ctx, "rule notified", "feeds", len(feedsByStart))
|
||||
|
||||
return nil
|
||||
}
|
||||
279
pkg/schedule/rule/watch_test.go
Normal file
279
pkg/schedule/rule/watch_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package rule
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
. "github.com/onsi/gomega"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/mock"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/model"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed/block"
|
||||
"github.com/glidea/zenfeed/pkg/test"
|
||||
)
|
||||
|
||||
func TestWatchExecute(t *testing.T) {
|
||||
RegisterTestingT(t)
|
||||
|
||||
// --- Test types ---
|
||||
type givenDetail struct {
|
||||
config *Config
|
||||
feedStorageMock func(m *mock.Mock) // Function to set expectations
|
||||
}
|
||||
type whenDetail struct {
|
||||
start time.Time
|
||||
end time.Time
|
||||
}
|
||||
type thenExpected struct {
|
||||
queryCalled bool
|
||||
queryOpts *block.QueryOptions // Expected query options
|
||||
sentToOut map[time.Time]*Result // Expected results sent to Out, keyed by interval start time
|
||||
err error // Expected error (can be wrapped)
|
||||
isErr bool
|
||||
}
|
||||
|
||||
// --- Test cases ---
|
||||
watchInterval := 10 * time.Minute
|
||||
baseConfig := &Config{
|
||||
Name: "test-watch",
|
||||
WatchInterval: watchInterval,
|
||||
Threshold: 0.7,
|
||||
Query: "test query",
|
||||
LabelFilters: []string{"source:test"},
|
||||
}
|
||||
now := time.Date(2024, 1, 15, 10, 35, 0, 0, time.Local) // Example time: 10:35
|
||||
// The execute function calculates start/end based on its input 'end' time and interval
|
||||
// Let's define the input range for the 'execute' call
|
||||
execEnd := now
|
||||
execStart := execEnd.Add(-3 * watchInterval) // Matches the logic in watch.go iter()
|
||||
|
||||
// Define feed times relative to the interval
|
||||
interval1Start := time.Unix(now.Unix(), 0).Truncate(watchInterval) // 10:30
|
||||
interval2Start := interval1Start.Add(-watchInterval) // 10:20
|
||||
// interval3Start := interval2Start.Add(-watchInterval) // 10:10, covered by execStart
|
||||
|
||||
feedTime1 := interval1Start.Add(1 * time.Minute) // 10:31 (belongs to 10:30 interval)
|
||||
feedTime2 := interval2Start.Add(2 * time.Minute) // 10:22 (belongs to 10:20 interval)
|
||||
feedTime3 := interval2Start.Add(5 * time.Minute) // 10:25 (belongs to 10:20 interval)
|
||||
|
||||
mockFeeds := []*block.FeedVO{
|
||||
{Feed: &model.Feed{ID: 1, Time: feedTime1, Labels: model.Labels{{Key: "content_hash", Value: "a"}}}},
|
||||
{Feed: &model.Feed{ID: 2, Time: feedTime2, Labels: model.Labels{{Key: "content_hash", Value: "b"}}}},
|
||||
{Feed: &model.Feed{ID: 3, Time: feedTime3, Labels: model.Labels{{Key: "content_hash", Value: "c"}}}},
|
||||
}
|
||||
queryError := errors.New("database error")
|
||||
|
||||
tests := []test.Case[givenDetail, whenDetail, thenExpected]{
|
||||
{
|
||||
Scenario: "Feeds found, should query and notify grouped by interval",
|
||||
Given: "a watch config and FeedStorage returns feeds across intervals",
|
||||
When: "execute is called with a time range",
|
||||
Then: "FeedStorage should be queried, and results grouped by WatchInterval sent to Out",
|
||||
GivenDetail: givenDetail{
|
||||
config: baseConfig,
|
||||
feedStorageMock: func(m *mock.Mock) {
|
||||
m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")).
|
||||
Return(mockFeeds, nil)
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
start: execStart,
|
||||
end: execEnd,
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
queryCalled: true,
|
||||
queryOpts: &block.QueryOptions{
|
||||
Query: baseConfig.Query,
|
||||
Threshold: baseConfig.Threshold,
|
||||
LabelFilters: baseConfig.LabelFilters,
|
||||
Start: execStart,
|
||||
End: execEnd,
|
||||
Limit: 500,
|
||||
},
|
||||
sentToOut: map[time.Time]*Result{
|
||||
interval1Start: { // 10:30 interval
|
||||
Rule: baseConfig.Name,
|
||||
Time: interval1Start,
|
||||
Feeds: []*block.FeedVO{
|
||||
mockFeeds[0], // ID 1 at 10:31
|
||||
},
|
||||
},
|
||||
interval2Start: { // 10:20 interval
|
||||
Rule: baseConfig.Name,
|
||||
Time: interval2Start,
|
||||
Feeds: []*block.FeedVO{
|
||||
mockFeeds[1], // ID 2 at 10:22
|
||||
mockFeeds[2], // ID 3 at 10:25
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "No feeds found, should query but not notify",
|
||||
Given: "a watch config and FeedStorage returns no feeds",
|
||||
When: "execute is called",
|
||||
Then: "FeedStorage should be queried but nothing sent to Out",
|
||||
GivenDetail: givenDetail{
|
||||
config: baseConfig,
|
||||
feedStorageMock: func(m *mock.Mock) {
|
||||
m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")).
|
||||
Return([]*block.FeedVO{}, nil) // Empty result
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
start: execStart,
|
||||
end: execEnd,
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
queryCalled: true,
|
||||
queryOpts: &block.QueryOptions{
|
||||
Query: baseConfig.Query,
|
||||
Threshold: baseConfig.Threshold,
|
||||
LabelFilters: baseConfig.LabelFilters,
|
||||
Start: execStart,
|
||||
End: execEnd,
|
||||
Limit: 500,
|
||||
},
|
||||
sentToOut: map[time.Time]*Result{}, // Expect empty map or nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Scenario: "FeedStorage query error, should return error",
|
||||
Given: "a watch config and FeedStorage returns an error",
|
||||
When: "execute is called",
|
||||
Then: "FeedStorage should be queried and an error returned",
|
||||
GivenDetail: givenDetail{
|
||||
config: baseConfig,
|
||||
feedStorageMock: func(m *mock.Mock) {
|
||||
m.On("Query", mock.Anything, mock.AnythingOfType("block.QueryOptions")).
|
||||
Return([]*block.FeedVO{}, queryError)
|
||||
},
|
||||
},
|
||||
WhenDetail: whenDetail{
|
||||
start: execStart,
|
||||
end: execEnd,
|
||||
},
|
||||
ThenExpected: thenExpected{
|
||||
queryCalled: true,
|
||||
queryOpts: &block.QueryOptions{ // Still expect query options to be set
|
||||
Query: baseConfig.Query,
|
||||
Threshold: baseConfig.Threshold,
|
||||
LabelFilters: baseConfig.LabelFilters,
|
||||
Start: execStart,
|
||||
End: execEnd,
|
||||
Limit: 500,
|
||||
},
|
||||
sentToOut: nil, // Nothing sent on error
|
||||
err: errors.Wrap(queryError, "query"),
|
||||
isErr: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// --- Run tests ---
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.Scenario, func(t *testing.T) {
|
||||
// --- Given ---
|
||||
configCopy := *tt.GivenDetail.config // Use a copy for safety
|
||||
|
||||
outCh := make(chan *Result, 5) // Buffer size accommodates potential multiple sends
|
||||
var capturedOpts block.QueryOptions
|
||||
var mockStorageInstance *mock.Mock
|
||||
|
||||
// Create mock factory using feed.NewFactory and capture the mock instance
|
||||
mockOption := component.MockOption(func(m *mock.Mock) {
|
||||
mockStorageInstance = m // Capture the mock instance
|
||||
// Setup mock expectation for FeedStorage.Query, including option capture
|
||||
if tt.GivenDetail.feedStorageMock != nil {
|
||||
tt.GivenDetail.feedStorageMock(m)
|
||||
// Enhance the mock setup to capture arguments
|
||||
for _, call := range m.ExpectedCalls {
|
||||
if call.Method == "Query" {
|
||||
for i, arg := range call.Arguments {
|
||||
if _, ok := arg.(mock.AnythingOfTypeArgument); ok && i == 1 { // Assuming options is the second argument (index 1)
|
||||
call.Arguments[i] = mock.MatchedBy(func(opts block.QueryOptions) bool {
|
||||
capturedOpts = opts // Capture the options
|
||||
return true
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
break // Assume only one Query expectation per test case here
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
// NOTE: feed.NewFactory needs *config.App, we pass nil as it's not used by the mock
|
||||
mockFeedFactory := feed.NewFactory(mockOption)
|
||||
mockFeedStorage, factoryErr := mockFeedFactory.New(component.Global, nil, feed.Dependencies{}) // Use factory to create mock
|
||||
Expect(factoryErr).NotTo(HaveOccurred())
|
||||
|
||||
dependencies := Dependencies{
|
||||
FeedStorage: mockFeedStorage, // Use the created mock storage
|
||||
Out: outCh,
|
||||
}
|
||||
|
||||
// Use the specific type `watch` for testing its method
|
||||
r := &watch{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: "WatchRuler",
|
||||
Instance: "test-instance",
|
||||
Config: &configCopy,
|
||||
Dependencies: dependencies,
|
||||
}),
|
||||
}
|
||||
|
||||
// --- When ---
|
||||
err := r.execute(context.Background(), tt.WhenDetail.start, tt.WhenDetail.end)
|
||||
|
||||
// --- Then ---
|
||||
close(outCh) // Close channel to range over received results
|
||||
|
||||
if tt.ThenExpected.isErr {
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring(tt.ThenExpected.err.Error())) // Check if the error contains the expected wrapped message
|
||||
Expect(len(outCh)).To(Equal(0)) // No results sent on error
|
||||
} else {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
receivedResults := make(map[time.Time]*Result)
|
||||
for res := range outCh {
|
||||
receivedResults[res.Time] = res
|
||||
}
|
||||
|
||||
Expect(len(receivedResults)).To(Equal(len(tt.ThenExpected.sentToOut)), "Mismatch in number of results sent")
|
||||
for expectedTime, expectedResult := range tt.ThenExpected.sentToOut {
|
||||
receivedResult, ok := receivedResults[expectedTime]
|
||||
Expect(ok).To(BeTrue(), "Expected result for time %v not found", expectedTime)
|
||||
Expect(receivedResult.Rule).To(Equal(expectedResult.Rule))
|
||||
Expect(receivedResult.Time.Unix()).To(Equal(expectedResult.Time.Unix()))
|
||||
Expect(receivedResult.Feeds).To(ConsistOf(expectedResult.Feeds)) // Use ConsistOf for order-independent comparison
|
||||
}
|
||||
}
|
||||
|
||||
// Verify FeedStorage.Query call and options using the captured mock instance
|
||||
if mockStorageInstance != nil {
|
||||
if tt.ThenExpected.queryCalled {
|
||||
mockStorageInstance.AssertCalled(t, "Query", mock.Anything, mock.AnythingOfType("block.QueryOptions"))
|
||||
// Assert specific fields of the captured options
|
||||
Expect(capturedOpts.Query).To(Equal(tt.ThenExpected.queryOpts.Query), "Query string mismatch")
|
||||
Expect(capturedOpts.Threshold).To(Equal(tt.ThenExpected.queryOpts.Threshold), "Threshold mismatch")
|
||||
Expect(capturedOpts.LabelFilters).To(Equal(tt.ThenExpected.queryOpts.LabelFilters), "LabelFilters mismatch")
|
||||
Expect(capturedOpts.Start.Unix()).To(Equal(tt.ThenExpected.queryOpts.Start.Unix()), "Start time mismatch")
|
||||
Expect(capturedOpts.End.Unix()).To(Equal(tt.ThenExpected.queryOpts.End.Unix()), "End time mismatch")
|
||||
Expect(capturedOpts.Limit).To(Equal(tt.ThenExpected.queryOpts.Limit), "Limit mismatch")
|
||||
} else {
|
||||
mockStorageInstance.AssertNotCalled(t, "Query", mock.Anything, mock.Anything)
|
||||
}
|
||||
// mockStorageInstance.AssertExpectations(t) // Uncomment for strict expectation matching if needed
|
||||
} else if tt.ThenExpected.queryCalled {
|
||||
t.Fatal("Expected query call but mock instance was not captured")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
242
pkg/schedule/schedule.go
Normal file
242
pkg/schedule/schedule.go
Normal file
@@ -0,0 +1,242 @@
|
||||
// Copyright (C) 2025 wangyusong
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/glidea/zenfeed/pkg/component"
|
||||
"github.com/glidea/zenfeed/pkg/config"
|
||||
"github.com/glidea/zenfeed/pkg/schedule/rule"
|
||||
"github.com/glidea/zenfeed/pkg/storage/feed"
|
||||
"github.com/glidea/zenfeed/pkg/telemetry"
|
||||
"github.com/glidea/zenfeed/pkg/telemetry/log"
|
||||
telemetrymodel "github.com/glidea/zenfeed/pkg/telemetry/model"
|
||||
)
|
||||
|
||||
// --- Interface code block ---
|
||||
type Scheduler interface {
|
||||
component.Component
|
||||
config.Watcher
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Rules []rule.Config
|
||||
}
|
||||
|
||||
func (c *Config) Validate() error {
|
||||
for _, rule := range c.Rules {
|
||||
if err := (&rule).Validate(); err != nil {
|
||||
return errors.Wrap(err, "validate rule")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) From(app *config.App) *Config {
|
||||
c.Rules = make([]rule.Config, len(app.Scheduls.Rules))
|
||||
for i, r := range app.Scheduls.Rules {
|
||||
c.Rules[i] = rule.Config{
|
||||
Name: r.Name,
|
||||
Query: r.Query,
|
||||
Threshold: r.Threshold,
|
||||
LabelFilters: r.LabelFilters,
|
||||
EveryDay: r.EveryDay,
|
||||
WatchInterval: r.WatchInterval,
|
||||
}
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
RuleFactory rule.Factory
|
||||
FeedStorage feed.Storage
|
||||
Out chan<- *rule.Result
|
||||
}
|
||||
|
||||
// --- Factory code block ---
|
||||
type Factory component.Factory[Scheduler, config.App, Dependencies]
|
||||
|
||||
func NewFactory(mockOn ...component.MockOption) Factory {
|
||||
if len(mockOn) > 0 {
|
||||
return component.FactoryFunc[Scheduler, config.App, Dependencies](
|
||||
func(instance string, app *config.App, dependencies Dependencies) (Scheduler, error) {
|
||||
m := &mockScheduler{}
|
||||
component.MockOptions(mockOn).Apply(&m.Mock)
|
||||
|
||||
return m, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return component.FactoryFunc[Scheduler, config.App, Dependencies](new)
|
||||
}
|
||||
|
||||
func new(instance string, app *config.App, dependencies Dependencies) (Scheduler, error) {
|
||||
config := &Config{}
|
||||
config.From(app)
|
||||
if err := config.Validate(); err != nil {
|
||||
return nil, errors.Wrap(err, "validate config")
|
||||
}
|
||||
|
||||
s := &scheduler{
|
||||
Base: component.New(&component.BaseConfig[Config, Dependencies]{
|
||||
Name: instance,
|
||||
Instance: instance,
|
||||
Config: config,
|
||||
Dependencies: dependencies,
|
||||
}),
|
||||
rules: make(map[string]rule.Rule, len(config.Rules)),
|
||||
}
|
||||
|
||||
for i := range config.Rules {
|
||||
r := &config.Rules[i]
|
||||
rule, err := s.newRule(r)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, "create rule %s", r.Name)
|
||||
}
|
||||
s.rules[r.Name] = rule
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// --- Implementation code block ---
|
||||
type scheduler struct {
|
||||
*component.Base[Config, Dependencies]
|
||||
|
||||
rules map[string]rule.Rule
|
||||
}
|
||||
|
||||
func (s *scheduler) Run() (err error) {
|
||||
ctx := telemetry.StartWith(s.Context(), append(s.TelemetryLabels(), telemetrymodel.KeyOperation, "Run")...)
|
||||
defer func() { telemetry.End(ctx, err) }()
|
||||
|
||||
for _, r := range s.rules {
|
||||
if err := component.RunUntilReady(ctx, r, 10*time.Second); err != nil {
|
||||
return errors.Wrapf(err, "running rule %s", r.Config().Name)
|
||||
}
|
||||
}
|
||||
|
||||
s.MarkReady()
|
||||
<-ctx.Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scheduler) Reload(app *config.App) error {
|
||||
newConfig := &Config{}
|
||||
newConfig.From(app)
|
||||
if err := newConfig.Validate(); err != nil {
|
||||
return errors.Wrap(err, "validate config")
|
||||
}
|
||||
if reflect.DeepEqual(s.Config(), newConfig) {
|
||||
log.Debug(s.Context(), "no changes in schedule config")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
newRules := make(map[string]rule.Rule, len(newConfig.Rules))
|
||||
|
||||
if err := s.runOrRestartRules(newConfig, newRules); err != nil {
|
||||
return errors.Wrap(err, "run or restart rules")
|
||||
}
|
||||
if err := s.stopObsoleteRules(newRules); err != nil {
|
||||
return errors.Wrap(err, "stop obsolete rules")
|
||||
}
|
||||
|
||||
s.rules = newRules
|
||||
s.SetConfig(newConfig)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scheduler) Close() error {
|
||||
if err := s.Base.Close(); err != nil {
|
||||
return errors.Wrap(err, "close base")
|
||||
}
|
||||
|
||||
// Stop all rules.
|
||||
for _, r := range s.rules {
|
||||
_ = r.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scheduler) newRule(config *rule.Config) (rule.Rule, error) {
|
||||
return s.Dependencies().RuleFactory.New(config.Name, config, rule.Dependencies{
|
||||
FeedStorage: s.Dependencies().FeedStorage,
|
||||
Out: s.Dependencies().Out,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *scheduler) runOrRestartRules(config *Config, newRules map[string]rule.Rule) error {
|
||||
for _, r := range config.Rules {
|
||||
// Close or reuse existing rule.
|
||||
if existing, exists := s.rules[r.Name]; exists {
|
||||
if reflect.DeepEqual(existing.Config(), r) {
|
||||
newRules[r.Name] = existing
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if err := existing.Close(); err != nil {
|
||||
return errors.Wrap(err, "close existing rule")
|
||||
}
|
||||
}
|
||||
|
||||
// Create & Run new/updated rule.
|
||||
newRule, err := s.newRule(&r)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create rule")
|
||||
}
|
||||
newRules[r.Name] = newRule
|
||||
if err := component.RunUntilReady(s.Context(), newRule, 10*time.Second); err != nil {
|
||||
return errors.Wrapf(err, "running rule %s", r.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *scheduler) stopObsoleteRules(newRules map[string]rule.Rule) error {
|
||||
var lastErr error
|
||||
for name, r := range s.rules {
|
||||
if _, exists := newRules[name]; !exists {
|
||||
if err := r.Close(); err != nil {
|
||||
lastErr = errors.Wrap(err, "close obsolete rule")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
type mockScheduler struct {
|
||||
component.Mock
|
||||
}
|
||||
|
||||
func (m *mockScheduler) Reload(app *config.App) error {
|
||||
args := m.Called(app)
|
||||
|
||||
return args.Error(0)
|
||||
}
|
||||
Reference in New Issue
Block a user