Skip to content

Commit 127afb7

Browse files
CRE-6266: Fix scheduler leak (#729)
1 parent 977e430 commit 127afb7

3 files changed

Lines changed: 92 additions & 10 deletions

File tree

cron/trigger/trigger.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ func (s *Service) RegisterTrigger(ctx context.Context, triggerID string, metadat
210210
if err != nil {
211211
return nil, caperrors.NewPublicSystemError(fmt.Errorf("failed to look up fastest schedule interval: %w", err), caperrors.Internal)
212212
}
213-
capErr := enforceFastestSchedule(s.lggr, jobDef, limit)
213+
capErr := enforceFastestSchedule(s.lggr, gocron.NewScheduler, jobDef, limit)
214214
if capErr != nil {
215215
return nil, capErr
216216
}

cron/trigger/trigger_test.go

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1229,8 +1229,79 @@ func TestEnforceFastestSchedule_NonUniformSecondsField(t *testing.T) {
12291229
maximumFastest := 5 * time.Second
12301230

12311231
jobDef := gocron.CronJob(schedule, true)
1232-
capErr := enforceFastestSchedule(logger.Nop(), jobDef, maximumFastest)
1232+
capErr := enforceFastestSchedule(logger.Nop(), gocron.NewScheduler, jobDef, maximumFastest)
12331233
require.NotNil(t, capErr, "should reject schedule with 1s gaps")
12341234
require.Equal(t, caperrors.LimitExceeded, capErr.Code())
12351235
require.Contains(t, capErr.Error(), "maximum fastest cron schedule is 5s")
12361236
}
1237+
1238+
type mockScheduler struct {
1239+
gocron.Scheduler
1240+
shutdowns int
1241+
}
1242+
1243+
func (s *mockScheduler) Shutdown() error {
1244+
s.shutdowns++
1245+
return s.Scheduler.Shutdown()
1246+
}
1247+
1248+
func mockFactory(mocks *[]*mockScheduler) schedulerFactory {
1249+
return func(options ...gocron.SchedulerOption) (gocron.Scheduler, error) {
1250+
sched, err := gocron.NewScheduler(options...)
1251+
if err != nil {
1252+
return nil, err
1253+
}
1254+
mock := &mockScheduler{Scheduler: sched}
1255+
*mocks = append(*mocks, mock)
1256+
return mock, nil
1257+
}
1258+
}
1259+
1260+
// TestEnforceFastestSchedule_ShutsDownTempScheduler covers a regression where the
1261+
// temporary scheduler's Shutdown was deferred below the NewJob error check, so every
1262+
// workflow-supplied cron string that gocron failed to parse retained a scheduler
1263+
// goroutine for the life of the plugin. Each schedule below exercises a different
1264+
// return path through the check; all of them must dispose of the scheduler.
1265+
func TestEnforceFastestSchedule_ShutsDownTempScheduler(t *testing.T) {
1266+
t.Parallel()
1267+
1268+
cases := []struct {
1269+
name string
1270+
schedule string
1271+
shouldErr bool
1272+
}{
1273+
{name: "valid schedule", schedule: everyMinute, shouldErr: false},
1274+
{name: "invalid timezone", schedule: "TZ=moon * * * * *", shouldErr: true},
1275+
{name: "empty schedule", schedule: "", shouldErr: true},
1276+
{name: "not a cron schedule", schedule: "d d d d d", shouldErr: true},
1277+
{name: "exceeds maximum fastest", schedule: everySecond, shouldErr: true},
1278+
}
1279+
1280+
for _, tt := range cases {
1281+
t.Run(tt.name, func(t *testing.T) {
1282+
t.Parallel()
1283+
1284+
var mocks []*mockScheduler
1285+
capErr := enforceFastestSchedule(logger.Nop(), mockFactory(&mocks), gocron.CronJob(tt.schedule, true), 5*time.Second)
1286+
if tt.shouldErr {
1287+
require.NotNil(t, capErr, "schedule %q should have been rejected", tt.schedule)
1288+
} else {
1289+
require.Nil(t, capErr)
1290+
}
1291+
1292+
require.Len(t, mocks, 1, "expected exactly one temporary scheduler to be created")
1293+
require.Equal(t, 1, mocks[0].shutdowns, "temporary scheduler was not shut down on this path")
1294+
})
1295+
}
1296+
}
1297+
1298+
func TestEnforceFastestSchedule_SchedulerConstructionFails(t *testing.T) {
1299+
t.Parallel()
1300+
1301+
failing := func(...gocron.SchedulerOption) (gocron.Scheduler, error) {
1302+
return nil, errors.New("cannot construct")
1303+
}
1304+
capErr := enforceFastestSchedule(logger.Nop(), failing, gocron.CronJob(everyMinute, true), 5*time.Second)
1305+
require.NotNil(t, capErr)
1306+
require.Equal(t, caperrors.Internal, capErr.Code())
1307+
}

cron/trigger/utils.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,38 @@ import (
1111
"github.com/smartcontractkit/chainlink-common/pkg/logger"
1212
)
1313

14-
func enforceFastestSchedule(lggr logger.Logger, jobDef gocron.JobDefinition, maximumFastest time.Duration) caperrors.Error {
14+
// schedulerFactory constructs a gocron scheduler. Production code passes
15+
// gocron.NewScheduler; tests pass a double to observe the scheduler's lifecycle.
16+
type schedulerFactory func(options ...gocron.SchedulerOption) (gocron.Scheduler, error)
17+
18+
// enforceFastestSchedule rejects a schedule that fires more often than maximumFastest.
19+
//
20+
// The scheduler constructor is a parameter so tests can assert the temporary scheduler
21+
// is always disposed of; production callers pass gocron.NewScheduler.
22+
func enforceFastestSchedule(lggr logger.Logger, newScheduler schedulerFactory, jobDef gocron.JobDefinition, maximumFastest time.Duration) caperrors.Error {
1523
var options []gocron.SchedulerOption
1624
// Use a fixed location and point in time for consistency across nodes.
1725
options = append(options, gocron.WithLocation(time.UTC))
1826
options = append(options, gocron.WithClock(clockwork.NewFakeClockAt(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC))))
1927

20-
tempScheduler, err := gocron.NewScheduler(options...)
28+
tempScheduler, err := newScheduler(options...)
2129
if err != nil {
2230
return caperrors.NewPublicSystemError(fmt.Errorf("failed to initialize temp scheduler: %w", err), caperrors.Internal)
2331
}
32+
// gocron starts a goroutine inside NewScheduler and Shutdown is the only thing that
33+
// stops it, so this defer belongs directly under the constructor: a return between
34+
// the two retains the scheduler for the life of the process.
35+
defer func() {
36+
if err := tempScheduler.Shutdown(); err != nil {
37+
lggr.Errorw("error shutting down enforceFastestSchedule temporary scheduler", "err", err)
38+
}
39+
}()
40+
2441
tempJob, err := tempScheduler.NewJob(jobDef, gocron.NewTask(func() {}))
2542
if err != nil {
2643
return caperrors.NewPublicUserError(fmt.Errorf("failed to initialize job: %w", err), caperrors.InvalidArgument)
2744
}
2845
tempScheduler.Start()
29-
defer func() {
30-
err := tempScheduler.Shutdown()
31-
if err != nil {
32-
lggr.Errorw("error shutting down enforceFastestSchedule temporary scheduler")
33-
}
34-
}()
3546

3647
// We need to check several runs to make sure there are enough to catch any short gaps (see unit test).
3748
// 12 is technically not enough in a general case but should work in practice when maximumFastest is between 5 and 60 seconds.

0 commit comments

Comments
 (0)