Docs / EventBridge Scheduler

EventBridge Scheduler

LocalEmu implements all 12 EventBridge Scheduler operations. This is the standalone AWS::Scheduler::Schedule service, not aws.events rules with ScheduleExpression. Schedule records ride on moto for CRUD, but the actual scheduling behavior is LocalEmu-side: a process-wide polling thread ticks every second, evaluates each schedule's next-fire time, and dispatches matched firings to the configured target through a 32-worker thread pool. Targets reuse the same EventBridge TargetSenderFactory that backs EventBridge rules and Pipes, so the supported target set is identical across the three services.

Operation-level coverage: see the EventBridge Scheduler coverage matrix.

Quick start (rate expression)

Terminal
$ awsemu lambda create-function --function-name nightly-cleanup \
    --runtime python3.12 --handler index.handler \
    --role arn:aws:iam::000000000000:role/lambda-role \
    --zip-file fileb://cleanup.zip >/dev/null

$ awsemu scheduler create-schedule --name cleanup \
    --schedule-expression 'rate(1 minute)' \
    --target '{
      "Arn":"arn:aws:lambda:us-east-1:000000000000:function:nightly-cleanup",
      "RoleArn":"arn:aws:iam::000000000000:role/SchedulerRole",
      "Input":"{\"reason\":\"scheduled-cleanup\"}"
    }' \
    --flexible-time-window '{"Mode":"OFF"}' \
    --query Arn --output text
arn:aws:scheduler:us-east-1:000000000000:schedule/default/cleanup

# The polling thread (1 s tick, 32-worker dispatch pool) invokes the Lambda
# about a minute from now, then again every minute.
$ sleep 75 && awsemu logs filter-log-events \
    --log-group-name /aws/lambda/nightly-cleanup --limit 1 --query 'events[0].message'
"Got: {\"version\":\"0\",\"source\":\"aws.scheduler\",\"detail-type\":\"Scheduled Event\", ...}"

Polling has a 1 s tick (constant _TICK_SECONDS=1.0 at job_scheduler.py:38), so the first firing of a rate(1 minute) schedule lands roughly 60-61 s after create.

Cron expression

Terminal
$ awsemu scheduler create-schedule --name midnight-utc \
    --schedule-expression 'cron(0 0 * * ? *)' \
    --schedule-expression-timezone "UTC" \
    --target '{
      "Arn":"arn:aws:sqs:us-east-1:000000000000:reports",
      "RoleArn":"arn:aws:iam::000000000000:role/SchedulerRole",
      "Input":"{\"window\":\"daily\"}"
    }' \
    --flexible-time-window '{"Mode":"OFF"}'

# cron(min hour day-of-month month day-of-week year). The year field is parsed
# but stripped at fire time, so cron expressions that pin a year fire each year.

One-shot at() with FlexibleTimeWindow + auto-delete

Terminal
$ NEXT=$(python3 -c 'from datetime import datetime,timezone,timedelta;print((datetime.now(timezone.utc)+timedelta(minutes=2)).strftime("%Y-%m-%dT%H:%M:%S"))')

$ awsemu scheduler create-schedule --name one-shot \
    --schedule-expression "at($NEXT)" \
    --schedule-expression-timezone UTC \
    --target '{
      "Arn":"arn:aws:lambda:us-east-1:000000000000:function:nightly-cleanup",
      "RoleArn":"arn:aws:iam::000000000000:role/SchedulerRole",
      "Input":"{}"
    }' \
    --action-after-completion DELETE \
    --flexible-time-window '{"Mode":"FLEXIBLE","MaximumWindowInMinutes":1}'

# FLEXIBLE window jitters the fire time uniformly within the configured
# MaximumWindowInMinutes. ActionAfterCompletion=DELETE drops the schedule
# from moto after the one-shot fires.
$ sleep 200 && awsemu scheduler list-schedules --query 'Schedules[].Name'
["cleanup", "midnight-utc"]

Architecture

Code lives at services/scheduler/. SchedulerProvider at provider.py:18 is registered as scheduler:default via plux.ini:112. The dispatch table uses MotoFallbackDispatcher, so all 12 CRUD ops are served by moto. The real behavior lives in @patch hooks on moto.scheduler.models.create_schedule / update_schedule / delete_schedule / delete_schedule_group (provider.py:71-146), which keep an in-process job registry in sync with moto.

FileRole
provider.py (146 lines)SchedulerProvider, lifecycle hooks (on_before_start, on_before_stop, on_after_state_load), @patch decorators on moto write ops.
expression.py (169 lines)validate_schedule_expression() and compute_next_fire(expr, tz_name, after, flex_minutes, jitter_seconds).
job_scheduler.py (395 lines)Singleton polling thread, job registry, ThreadPoolExecutor(max_workers=32) for dispatch, graceful shutdown.
target_invoker.py (131 lines)Synthesizes the aws.scheduler / Scheduled Event envelope, delegates dispatch to the EventBridge TargetSenderFactory with caller_service_principal="scheduler".

Polling loop

Schedule expressions

FormNotes
rate(N unit)unit in days. N ≥ 1. Singular for N=1, plural for N>1.
cron(M H DoM Mo DoW Y)6-field AWS-cron, validated via python-crontab. The 6th (year) field is parsed but stripped before fire-time evaluation, since python-crontab only honors 5 fields.
at(YYYY-MM-DDTHH:MM:SS)One-shot wall-clock fire in ScheduleExpressionTimezone. If the time has already passed at create, the schedule fires on the next tick. Pair with ActionAfterCompletion=DELETE to auto-clean.

ScheduleExpressionTimezone is honored via IANA names (default UTC). StartDate and EndDate are honored: compute_next_fire is anchored at max(now, StartDate) and firings past EndDate are dropped. FlexibleTimeWindow.Mode=FLEXIBLE with MaximumWindowInMinutes adds a uniform-random jitter within the configured window.

Targets

All target dispatch delegates to the EventBridge TargetSenderFactory, with caller_service_principal="scheduler" so role trust policies that allow scheduler.amazonaws.com can be assumed. The synthesized event envelope follows AWS conventions:

{
  "version":      "0",
  "id":           "<uuid>",
  "source":       "aws.scheduler",
  "detail-type":  "Scheduled Event",
  "time":         "<iso-8601>",
  "resources":    ["arn:aws:scheduler:<region>:<account>:schedule/<group>/<name>"],
  "detail":       <Target.Input parsed as JSON if valid, else raw string>
}

Supported target services are exactly what EventBridge supports: Lambda, SQS, SNS, Step Functions, EventBridge bus, Kinesis. DeadLetterConfig.Arn, RetryPolicy.MaximumRetryAttempts, and MaximumEventAgeInSeconds are delegated to TargetSender.process_event(), the same path EventBridge rules use, so any DLQ + retry behavior wired for EventBridge also applies here.

Features supported

FeatureNotes
CRUDCreateSchedule, UpdateSchedule, DeleteSchedule, GetSchedule, ListSchedules.
Schedule groupsCreateScheduleGroup, DeleteScheduleGroup, GetScheduleGroup, ListScheduleGroups. Deleting a group cleans its schedules from the registry.
TagsTagResource, UntagResource, ListTagsForResource.
StateState=ENABLED ticks the schedule; State=DISABLED keeps the record but skips firings.
Expression formsrate(N unit), cron(...), at(...).
TimezonesScheduleExpressionTimezone (IANA names) honored for cron and at.
WindowingFlexibleTimeWindow.Mode=FLEXIBLE + MaximumWindowInMinutes: uniform jitter within the window.
BoundsStartDate + EndDate honored.
One-shot cleanupActionAfterCompletion=DELETE drops the schedule after the one-shot fires. NONE (default) keeps the record.
Persistencemoto schedule records survive PERSISTENCE=1 restart; on_after_state_load rebuilds the in-process job registry and resumes polling.
Terminal
$ awsemu scheduler list-schedules --query 'Schedules[].{Name:Name,Group:GroupName,State:State}'
[{"Name": "cleanup", "Group": "default", "State": "ENABLED"}]

$ awsemu scheduler update-schedule --name cleanup \
    --schedule-expression 'rate(5 minutes)' \
    --target '{
      "Arn":"arn:aws:lambda:us-east-1:000000000000:function:nightly-cleanup",
      "RoleArn":"arn:aws:iam::000000000000:role/SchedulerRole"
    }' \
    --flexible-time-window '{"Mode":"OFF"}' \
    --state DISABLED

# DISABLED keeps the moto record but the polling thread skips firings until
# the schedule is re-enabled via update-schedule --state ENABLED.

Configuration

EventBridge Scheduler does not introduce its own environment variables. Polling tick (1 s) and dispatch concurrency (32 workers) are constants at the top of job_scheduler.py: _TICK_SECONDS and _DISPATCH_WORKERS. Adjust these in source if you need different cadence in a fork. Retry attempts are locally capped at 5 (vs AWS's 185) via _MAX_RETRY_ATTEMPTS to keep tests bounded.

Integration points

ServiceHow it touches Scheduler
EventBridgeAll target dispatch flows through the EventBridge TargetSenderFactory. Adding a target type to EventBridge automatically makes it available to Scheduler.
LambdaInvoke with the Scheduled-Event envelope as the payload.
SQSSendMessage with the rendered envelope as the body.
SNS / Step Functions / KinesisSame target shapes as EventBridge rules.
CloudFormationAWS::Scheduler::Schedule and AWS::Scheduler::ScheduleGroup resource providers route through this provider.

Test coverage

Known limitations