Files
tasks/chasm/lib/scheduler/generator_tasks.go
Lina JodoinandGitHub a646b2d5ef [Scheduler] Introduce EventLog for internal history (#10258)
I'm sending the review for the mechanism (and an example callsite), and
I'll send out a review that makes use of it throughout the scheduler
code afterwards.

## What changed?
- CHASM scheduler components each parent a new EventLog component, which
we'll use to record decision logic details that are normally too spammy
for logs.
- No plan to expose this through any APIs just yet; it isn't meant to be
customer-facing (though, I am thinking about how we can make it easy for
dev success to get at)

## Why?
- V1 debugging was often done through use of the workflow's event
history; without an analogue for V2, it's difficult to get a clear
picture of how the logic arrived at a certain output state.
- Info logging in the same capacity would likely be too spammy for our
observability system.
- It's difficult to trace a complete picture for an individual schedule
*over time* using just Loki. Schedules are often long-lived, with state
transitions taking place in short blips over periods of days or weeks.
Being able to get a complete list on demand for a relevant scheduler
will greatly improve debugging time.

## How did you test it?
- [ ] built
- [ ] run locally and tested manually
- [ ] covered by existing tests
- [x] added new unit test(s)
- [ ] added new functional test(s)

## Potential risks
- This could increase scheduler state size, however:
- V2 already measures notably lighter on storage than V1 (not to
mention, no event history data whatsoever)
- Log is bounded to max 30 messages per component (usually scheduler,
generator, invoker) of max 1000 characters each (e.g., ~100KB), as a
safety mechanism
- Most messages logged will be small and informational, not large state
dumps (e.g., "scheduler unpaused", "generator scheduled for ...").
- Backfillers each get their own log, which shares a Backfiller's
lifecycle.
2026-05-27 15:21:52 -07:00

169 lines
5.1 KiB
Go

package scheduler
import (
"fmt"
"go.temporal.io/server/chasm"
"go.temporal.io/server/chasm/lib/scheduler/gen/schedulerpb/v1"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/log/tag"
"go.temporal.io/server/common/metrics"
queueerrors "go.temporal.io/server/service/history/queues/errors"
"go.temporal.io/server/service/worker/scheduler"
"go.uber.org/fx"
"google.golang.org/protobuf/types/known/durationpb"
"google.golang.org/protobuf/types/known/timestamppb"
)
type (
GeneratorTaskHandlerOptions struct {
fx.In
Config *Config
MetricsHandler metrics.Handler
BaseLogger log.Logger
SpecProcessor SpecProcessor
SpecBuilder *scheduler.SpecBuilder
}
GeneratorTaskHandler struct {
chasm.PureTaskHandlerBase
config *Config
metricsHandler metrics.Handler
baseLogger log.Logger
SpecProcessor SpecProcessor
specBuilder *scheduler.SpecBuilder
}
)
func NewGeneratorTaskHandler(opts GeneratorTaskHandlerOptions) *GeneratorTaskHandler {
return &GeneratorTaskHandler{
config: opts.Config,
metricsHandler: opts.MetricsHandler,
baseLogger: opts.BaseLogger,
SpecProcessor: opts.SpecProcessor,
specBuilder: opts.SpecBuilder,
}
}
func (g *GeneratorTaskHandler) Execute(
ctx chasm.MutableContext,
generator *Generator,
_ chasm.TaskAttributes,
_ *schedulerpb.GeneratorTask,
) error {
scheduler := generator.Scheduler.Get(ctx)
logger := newTaggedLogger(g.baseLogger, scheduler)
metricsHandler := newTaggedMetricsHandler(g.metricsHandler, scheduler)
invoker := scheduler.Invoker.Get(ctx)
now := ctx.Now(generator)
// If we have no last processed time, this is a new schedule.
if generator.LastProcessedTime == nil {
createdAt := timestamppb.New(now)
generator.LastProcessedTime = createdAt
scheduler.Info.CreateTime = createdAt
g.logSchedule(ctx, logger, "starting schedule", generator, scheduler)
}
// If the high water mark is earlier than when a schedule was updated, we must skip any actions that hadn't
// yet been processed.
if scheduler.Info.GetUpdateTime().AsTime().After(generator.LastProcessedTime.AsTime()) {
generator.LastProcessedTime = scheduler.Info.GetUpdateTime()
}
// Process time range between last high water mark and system time.
t1 := generator.LastProcessedTime.AsTime()
t2 := now.UTC()
if t2.Before(t1) {
logger.Error("time went backwards",
tag.Stringer("time", t1),
tag.Stringer("time", t2))
t2 = t1
}
result, err := g.SpecProcessor.ProcessTimeRange(
scheduler,
t1, t2,
scheduler.overlapPolicy(),
scheduler.WorkflowID(),
"",
false,
nil,
)
if err != nil {
// An error here should be impossible, send to the DLQ.
return queueerrors.NewUnprocessableTaskError(
fmt.Sprintf("failed to process a time range: %s", err.Error()))
}
// Emit metrics and update state for any dropped actions.
if result.DroppedCount > 0 {
logger.Warn("Buffer overrun, dropping actions",
tag.Int64("dropped-count", result.DroppedCount))
metricsHandler.Counter(metrics.ScheduleBufferOverruns.Name()).Record(result.DroppedCount)
scheduler.Info.BufferDropped += result.DroppedCount
}
// Enqueue newly-generated buffered starts.
if len(result.BufferedStarts) > 0 {
invoker.EnqueueBufferedStarts(ctx, result.BufferedStarts)
}
// Write the new high water mark and future action times.
generator.LastProcessedTime = timestamppb.New(result.LastActionTime)
generator.UpdateFutureActionTimes(ctx, g.specBuilder)
// Check if the schedule has gone idle.
idleTimeTotal := g.config.Tweakables(scheduler.Namespace).IdleTime
idleExpiration, isIdle := scheduler.getIdleExpiration(ctx, idleTimeTotal, result.NextWakeupTime)
if isIdle {
// Schedule is complete, no need for another buffer task. We keep the schedule's
// backing mutable state explicitly open for a the idle period, during which the
// customer can describe/modify/restart the schedule.
//
// Once the idle timer expires, we close the component.
ctx.AddTask(scheduler, chasm.TaskAttributes{
ScheduledTime: idleExpiration,
}, &schedulerpb.SchedulerIdleTask{
IdleTimeTotal: durationpb.New(idleTimeTotal),
})
return nil
}
// No more tasks if we're paused.
if scheduler.Schedule.State.Paused {
return nil
}
// Another buffering task is added if we aren't completely out of actions or paused.
generator.scheduleTask(ctx, result.NextWakeupTime)
return nil
}
func (g *GeneratorTaskHandler) logSchedule(ctx chasm.MutableContext, logger log.Logger, msg string, generator *Generator, sched *Scheduler) {
spec := jsonStringer{sched.Schedule.Spec}
policies := jsonStringer{sched.Schedule.Policies}
tw := g.config.Tweakables(sched.Namespace)
generator.EventLog.Get(ctx).LogEvent(ctx, fmt.Sprintf("%s:\nSpec: %s\nPolicies: %s\n", msg, spec, policies), tw.EventLogMaxEntries, tw.EventLogMaxMessageLen)
logger.Info(msg,
tag.Stringer("spec", spec),
tag.Stringer("policies", policies))
}
func (g *GeneratorTaskHandler) Validate(
ctx chasm.Context,
generator *Generator,
attrs chasm.TaskAttributes,
_ *schedulerpb.GeneratorTask,
) (bool, error) {
return validateTaskHighWaterMark(
generator.GetLastProcessedTime(),
attrs.ScheduledTime,
)
}