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)
$ 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
$ 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
$ 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.
| File | Role |
|---|---|
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
- •Single process-wide thread. One thread polls every registered schedule (not one thread per schedule).
- •1 s tick (
_TICK_SECONDS=1.0). Sub-second precision is not preserved; a schedule fires on the first tick after its next-fire time. - •32-worker ThreadPoolExecutor (
_DISPATCH_WORKERS=32) bounds concurrent target invocations. - •Skip-on-busy:
job.currently_dispatching=Trueis set during a dispatch and the next firing for the same schedule is silently skipped if the previous invocation is still in flight. - •Graceful shutdown:
_stop()sets a stop event, joins the thread with a 5 s timeout, and shuts the executor without waiting.
Schedule expressions
| Form | Notes |
|---|---|
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
| Feature | Notes |
|---|---|
| CRUD | CreateSchedule, UpdateSchedule, DeleteSchedule, GetSchedule, ListSchedules. |
| Schedule groups | CreateScheduleGroup, DeleteScheduleGroup, GetScheduleGroup, ListScheduleGroups. Deleting a group cleans its schedules from the registry. |
| Tags | TagResource, UntagResource, ListTagsForResource. |
| State | State=ENABLED ticks the schedule; State=DISABLED keeps the record but skips firings. |
| Expression forms | rate(N unit), cron(...), at(...). |
| Timezones | ScheduleExpressionTimezone (IANA names) honored for cron and at. |
| Windowing | FlexibleTimeWindow.Mode=FLEXIBLE + MaximumWindowInMinutes: uniform jitter within the window. |
| Bounds | StartDate + EndDate honored. |
| One-shot cleanup | ActionAfterCompletion=DELETE drops the schedule after the one-shot fires. NONE (default) keeps the record. |
| Persistence | moto schedule records survive PERSISTENCE=1 restart; on_after_state_load rebuilds the in-process job registry and resumes polling. |
$ 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
| Service | How it touches Scheduler |
|---|---|
| EventBridge | All target dispatch flows through the EventBridge TargetSenderFactory. Adding a target type to EventBridge automatically makes it available to Scheduler. |
| Lambda | Invoke with the Scheduled-Event envelope as the payload. |
| SQS | SendMessage with the rendered envelope as the body. |
| SNS / Step Functions / Kinesis | Same target shapes as EventBridge rules. |
| CloudFormation | AWS::Scheduler::Schedule and AWS::Scheduler::ScheduleGroup resource providers route through this provider. |
Test coverage
- •
tests/unit/services/scheduler/test_expression.py:validate_schedule_expressionfor rate (singular + plural), cron field parsing, at-ISO;compute_next_firefor every form including the timezone + one-shot past-fire paths. - •
tests/unit/services/scheduler/test_job_scheduler.py: registry add/remove, polling-tick dispatch, one-shot deletion, flexible-window jitter, StartDate/EndDate bounds. - •
tests/unit/services/scheduler/test_target_invoker.py: envelope shape (source/detail-type/resources),Input-as-JSON parsing, missing-Arn noop, unsupported-service graceful fail. - •
tests/aws/services/scheduler/test_scheduler.py: smoke (list_schedules), tagging, invalid expressions (parametrized), valid cron + rate + at, schedule with Lambda target + IAM role.
Known limitations
- •Cron year field is stripped. The 6th field is validated at parse time but ignored at fire time (
python-crontabis 5-field). Schedules with year constraints fire as if the year were*. - •1 s tick granularity. A schedule whose
at()fire time lands at12:00:00.500will fire on the tick at12:00:01. - •Skip-on-busy is silent. If the previous invocation of a schedule is still running, the next tick's firing is dropped without any DLQ message; CloudWatch in real AWS would mark it as a missed invocation.
- •Retry attempts capped at 5 (vs AWS's 185 default). Configurable only by editing
_MAX_RETRY_ATTEMPTSat the top ofjob_scheduler.py. - •Target set is what EventBridge supports. Targets that EventBridge does not wire (Inspector, the full AWS "universal target" set, recently-added services) are logged-and-skipped without firing. The AWS "270+ universal targets" claim does not apply here.
- •
ActionAfterCompletion=NONE(the default) leaves one-shot at() schedules in moto. OnlyDELETEtriggers cleanup. List queries will keep showing fired one-shots until you delete them manually. - •No flexible-window queueing. A single jittered fire is performed within the window; the window is not used to batch deferred firings or recover missed ones.
- •
KmsKeyArnis metadata-only. Schedules are not encrypted at rest under that key.