Simulated EventBridge Scheduler
Yulin includes a simulated Amazon EventBridge Scheduler for tests and local development. Schedules
are held in memory and every operation is authorized by simulated IAM. Scheduler-specific types are
imported from the @kensio/yulin/scheduler subpath.
Scheduler is a separate service from EventBridge, not a corner of it. It has its own SDK client, its own ARN shape, and its own way of reaching a target. A schedule assumes an IAM execution role, where an EventBridge rule relies on a resource policy admitting a service principal. A project using Scheduler cannot be tested against simulated EventBridge rules. That is why this exists separately.
Creating a schedule
Section titled “Creating a schedule”/** * Creating a schedule that invokes a function every night. */
import { CreateScheduleCommand, GetScheduleCommand,} from "@aws-sdk/client-scheduler";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const scheduler = simAws.scheduler();
const created = await scheduler.createSchedule( new CreateScheduleCommand({ Name: "nightly-report", ScheduleExpression: "cron(0 2 * * ? *)", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: "arn:aws:lambda:us-east-1:888888888888:function:report", RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole", }, }),);
console.log(created.ScheduleArn);// "arn:aws:scheduler:us-east-1:888888888888:schedule/default/nightly-report"
const described = await scheduler.getSchedule( new GetScheduleCommand({ Name: "nightly-report" }),);
console.log(described.ScheduleExpression); // "cron(0 2 * * ? *)"FlexibleTimeWindow and Target are both required, as AWS requires them, and a target carries both
an Arn and the RoleArn it is invoked as. A schedule ARN always names its group, even the
default one. An EventBridge rule ARN differs, showing the bus only when it is not the default. An
IAM policy naming a schedule needs the group in it, or it matches no schedule.
Writing the schedule expression
Section titled “Writing the schedule expression”Three forms, and the same parser as an EventBridge scheduled rule with two differences:
at(yyyy-mm-ddThh:mm:ss)runs once, at that instant. The timezone is a separate setting on the schedule, outside the expression, and a trailingZis refused.rate(<value> <unit>)runs from when the schedule was created. The unit isminute,hourorday, and Scheduler lets it disagree with its value.rate(1 hours)is an hour here and a refusal on an EventBridge rule.cron(<six fields>)names absolute instants in UTC. Minutes, hours, day-of-month, month, day-of-week and year, so every day at two in the morning iscron(0 2 * * ? *). The day-of-month and day-of-week fields cannot both say something. Whichever is not deciding the day is written?.
Firing a schedule
Section titled “Firing a schedule”A schedule fires on the simulation’s clock. Advancing simulated time past a due instant invokes the target. Leave time alone and the target is never invoked. A nightly job takes no time at all to test.
/** * A schedule invoking a function three times in three simulated hours. */
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";import { CreateScheduleCommand } from "@aws-sdk/client-scheduler";
import { SimAws, SimFixedClock } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
const simAws = new SimAws({ clock: new SimFixedClock(new Date("2026-07-26T09:00:00.000Z")),});
const functionArn = "arn:aws:lambda:us-east-1:888888888888:function:report";const runs: string[] = [];
await simAws.lambda().createFunction({ input: { FunctionName: "report", Role: "arn:aws:iam::888888888888:role/ReportRole", Code: { ZipFile: makeLambdaZipFileInput(() => { runs.push("ran"); return { ok: true }; }), }, },});
// The execution role has to trust Scheduler, and be allowed to invoke.await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "SchedulerRole", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Principal: { Service: "scheduler.amazonaws.com" }, Action: "sts:AssumeRole", }, }), }),);
await simAws.iam().putRolePolicy( new PutRolePolicyCommand({ RoleName: "SchedulerRole", PolicyName: "InvokeReport", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Action: "lambda:InvokeFunction", Resource: functionArn, }, }), }),);
await simAws.scheduler().createSchedule( new CreateScheduleCommand({ Name: "hourly-report", ScheduleExpression: "rate(1 hour)", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: functionArn, RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole", }, }),);
await simAws.clock().advanceBy({ hours: 3 });
console.log(runs.length); // 3Firing is per due instant. Advancing an hour with a rate(1 minute) schedule
invokes the target sixty times, at sixty distinct simulated instants. advanceBy(...) returns once
every one of those invocations has settled, leaving the next line free to assert.
A target with an Input receives that text. One without receives an empty JSON object, which AWS
documents for a function with no payload. There is no envelope, since a schedule has no event of its
own to describe.
The execution role
Section titled “The execution role”This is the part that differs most from an EventBridge rule, and the part that
most often goes wrong in a real account. A rule reaches its target as the events.amazonaws.com
service principal, and the target’s own resource policy decides. A schedule assumes the RoleArn on
its target, and that role’s policies decide. No resource policy on the target is involved at all.
Two things therefore have to be right, and they are fixed in different places:
- The role’s trust policy has to let
scheduler.amazonaws.comassume it. A role copied from an EventBridge rule trustsevents.amazonaws.comand fails here. - A policy on the role has to allow the action on the target, being
lambda:InvokeFunction,sqs:SendMessage,sns:Publishorecs:RunTask.
When either is missing the target goes uninvoked and no error is thrown, exactly as on AWS, where the
failure goes to CloudWatch and nowhere the caller can see. advanceBy(...) still returns normally. A
test asserting on a failed invocation reads deliveryFailures:
/** * Finding out why a schedule's target was never invoked. */
import { CreateRoleCommand } from "@aws-sdk/client-iam";import { CreateScheduleCommand } from "@aws-sdk/client-scheduler";
import { SimAws, SimFixedClock } from "@kensio/yulin";
const simAws = new SimAws({ clock: new SimFixedClock(new Date("2026-07-26T09:00:00.000Z")),});
// A role that trusts EventBridge rules rather than Scheduler.await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "SchedulerRole", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Principal: { Service: "events.amazonaws.com" }, Action: "sts:AssumeRole", }, }), }),);
await simAws.scheduler().createSchedule( new CreateScheduleCommand({ Name: "hourly-report", ScheduleExpression: "rate(1 hour)", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: "arn:aws:lambda:us-east-1:888888888888:function:report", RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole", }, }),);
await simAws.clock().advanceBy({ hours: 1 });
const [failure] = simAws.scheduler().deliveryFailures;
console.log(failure?.message);// "The trust policy of arn:aws:iam::888888888888:role/SchedulerRole does not// allow scheduler.amazonaws.com to assume it, ..."One-time schedules and what happens after
Section titled “One-time schedules and what happens after”An at(...) schedule fires once and then stops. By default it stays in the Account afterwards, which
surprises people who expected it to clean up. It keeps counting against the schedule quota and keeps
turning up in listings. ActionAfterCompletion: "DELETE" is what removes it, and after that
GetSchedule reports it gone.
A schedule that is disabled when its only instant passes has not completed, since nothing was
invoked. It is still there afterwards whatever ActionAfterCompletion says.
State: "DISABLED" stops a recurring schedule firing while it is off, and an UpdateSchedule
enabling it picks up from the next due instant. What it missed is never replayed. An update that
changes the expression reschedules from the new one.
Running an ECS task on a schedule
Section titled “Running an ECS task on a schedule”A target whose ARN names an ECS cluster runs a simulated ECS task, in place of being invoked with a payload. That is the shape a nightly batch job usually has. A container runs, does its work and stops.
/** * A schedule running an ECS task every night. */
import { CreateClusterCommand, ListTasksCommand, RegisterTaskDefinitionCommand,} from "@aws-sdk/client-ecs";import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";import { CreateScheduleCommand } from "@aws-sdk/client-scheduler";
import { SimAws, SimFixedClock } from "@kensio/yulin";
const simAws = new SimAws({ clock: new SimFixedClock(new Date("2026-07-26T09:00:00.000Z")),});const ecs = simAws.ecs();const imported: string[] = [];
await ecs.createCluster(new CreateClusterCommand({ clusterName: "orders" }));
ecs.bindContainer({ family: "nightly-import", containerName: "app", run: () => { imported.push(process.env["IMPORT_MODE"] ?? ""); },});
await ecs.registerTaskDefinition( new RegisterTaskDefinitionCommand({ family: "nightly-import", containerDefinitions: [{ name: "app", image: "nightly-import:1" }], }),);
// The schedule runs the task as this role, so the role trusts Scheduler and is// allowed to run it.await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "SchedulerRole", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Principal: { Service: "scheduler.amazonaws.com" }, Action: "sts:AssumeRole", }, }), }),);
await simAws.iam().putRolePolicy( new PutRolePolicyCommand({ RoleName: "SchedulerRole", PolicyName: "RunImport", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Action: "ecs:RunTask", Resource: "*" }, }), }),);
await simAws.scheduler().createSchedule( new CreateScheduleCommand({ Name: "nightly-import", ScheduleExpression: "cron(0 2 * * ? *)", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: "arn:aws:ecs:us-east-1:888888888888:cluster/orders", RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole", EcsParameters: { TaskDefinitionArn: "nightly-import", TaskCount: 1, }, // An ECS target's Input is the task's overrides, since a task has // nowhere to receive a payload. Input: JSON.stringify({ containerOverrides: [ { name: "app", environment: [{ name: "IMPORT_MODE", value: "full" }], }, ], }), }, }),);
// Advancing past 02:00 fires the schedule and runs the task.await simAws.clock().advanceBy({ hours: 24 });
console.log(imported); // ["full"]
const tasks = await ecs.listTasks( new ListTasksCommand({ cluster: "orders", desiredStatus: "STOPPED" }),);
console.log(tasks.taskArns?.length); // 1The target ARN names the cluster. An ARN naming anything else in ECS is refused when the schedule
is created. EcsParameters names the task definition, as a family, a family:revision or a full
ARN, and the same one RunTask would take.
An ECS target’s Input is the task’s overrides, since a task has nowhere to receive a payload. A
target with no Input runs the task with no overrides.
EcsParameters on a target whose ARN names anything else is refused, since it would do nothing.
Simulated ECS decides which containers actually run. A container with a binding runs its handler, and a container without one is recorded as not simulated. A target naming a task definition with nothing bound therefore records a task that never started, and the schedule counts as invoked.
Updating and deleting
Section titled “Updating and deleting”/** * An update replaces the whole schedule rather than merging into it. */
import { CreateScheduleCommand, GetScheduleCommand, UpdateScheduleCommand,} from "@aws-sdk/client-scheduler";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const scheduler = simAws.scheduler();const target = { Arn: "arn:aws:lambda:us-east-1:888888888888:function:report", RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole",};
await scheduler.createSchedule( new CreateScheduleCommand({ Name: "nightly-report", Description: "The nightly reconciliation", ScheduleExpression: "cron(0 2 * * ? *)", FlexibleTimeWindow: { Mode: "OFF" }, Target: target, }),);
// Meaning only to change the expression, and leaving the description out.await scheduler.updateSchedule( new UpdateScheduleCommand({ Name: "nightly-report", ScheduleExpression: "rate(30 minutes)", FlexibleTimeWindow: { Mode: "OFF" }, Target: target, }),);
const described = await scheduler.getSchedule( new GetScheduleCommand({ Name: "nightly-report" }),);
console.log(described.ScheduleExpression); // "rate(30 minutes)"console.log(described.Description); // undefined, and not by accidentUpdateSchedule carries the whole of a schedule, and anything an earlier request set and this one
leaves out is gone. That is real behaviour and a common surprise. The schedule has to exist.
Updating one that is absent raises ResourceNotFoundException. EventBridge’s PutRule creates it.
CreateSchedule for a name that already exists raises ConflictException. A deployment running it
twice fails the second time here as it does on AWS. DeleteSchedule for a schedule that is absent
raises ResourceNotFoundException, where EventBridge’s DeleteRule succeeds.
Listing schedules
Section titled “Listing schedules”ListSchedules reports the schedules of a group in creation order, narrowed by NamePrefix and
State and paged by MaxResults and NextToken.
A listing carries less than a describe, as it does on AWS. It has the target’s ARN and no more of the target,
and no expression at all. Code reading ScheduleExpression off a listing gets
undefined from AWS, and gets undefined here too.
Permissions
Section titled “Permissions”Every operation is authorized against the schedule ARN, which carries the group:
/** * A Role allowed to manage one schedule and no other. */
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";import { CreateScheduleCommand } from "@aws-sdk/client-scheduler";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const role = await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "ScheduleAdministrator", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Principal: { AWS: "arn:aws:iam::888888888888:root" }, Action: "sts:AssumeRole", }, }), }),);
await simAws.iam().putRolePolicy( new PutRolePolicyCommand({ RoleName: "ScheduleAdministrator", PolicyName: "ManageNightlyReport", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Action: "scheduler:CreateSchedule", // The group is part of the ARN, so a policy without it matches nothing. Resource: "arn:aws:scheduler:us-east-1:888888888888:schedule/default/nightly-report", }, }), }),);
const created = await simAws.scheduler().createSchedule( new CreateScheduleCommand({ Name: "nightly-report", ScheduleExpression: "rate(1 hour)", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: "arn:aws:lambda:us-east-1:888888888888:function:report", RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole", }, }), { caller: { kind: "arn", arn: role.Role.Arn } },);
console.log(created.ScheduleArn !== undefined); // trueListSchedules names no schedule. IAM evaluates it against *, and only a policy whose Resource
is * allows it. A policy naming a schedule ARN allows no listing, here as on AWS.
That is the caller’s own permission to manage schedules, and it is a separate question from whether a
schedule’s execution role may invoke its target. The second is asked when the schedule fires,
against the RoleArn on the target.
Deploying from a CloudFormation template
Section titled “Deploying from a CloudFormation template”AWS::Scheduler::Schedule deploys through simulated CloudFormation. A stack
that declares its schedules can be exercised end to end, with no SDK calls of its own. Everything the
Resource carries lines up with CreateSchedule, and a target ARN or execution role resolved by
Fn::GetAtt from the same template works as it would in a real deployment.
/** * A schedule deployed from a template, firing as simulated time advances. */
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";import { ReceiveMessageCommand } from "@aws-sdk/client-sqs";
import { SimAws, SimFixedClock } from "@kensio/yulin";
const simAws = new SimAws({ clock: new SimFixedClock(new Date("2026-07-26T09:00:00.000Z")),});
const queueArn = "arn:aws:sqs:us-east-1:888888888888:reports";const roleArn = "arn:aws:iam::888888888888:role/SchedulerRole";
// The execution role has to trust Scheduler, and be allowed to send.await simAws.iam().createRole( new CreateRoleCommand({ RoleName: "SchedulerRole", AssumeRolePolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Principal: { Service: "scheduler.amazonaws.com" }, Action: "sts:AssumeRole", }, }), }),);
await simAws.iam().putRolePolicy( new PutRolePolicyCommand({ RoleName: "SchedulerRole", PolicyName: "SendReports", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Action: "sqs:SendMessage", Resource: queueArn, }, }), }),);
const stack = await simAws.cloudFormation().deployTemplate({ stackName: "reporting-stack", template: { Resources: { ReportQueue: { Type: "AWS::SQS::Queue", Properties: { QueueName: "reports" }, }, HourlyReport: { Type: "AWS::Scheduler::Schedule", Properties: { Name: "hourly-report", ScheduleExpression: "rate(1 hour)", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: { "Fn::GetAtt": ["ReportQueue", "Arn"] }, RoleArn: roleArn, Input: JSON.stringify({ report: "hourly" }), }, }, }, }, },});
await stack.waitForDeployComplete();
// Three simulated hours on, the schedule has invoked its target three times.await simAws.clock().advanceBy({ hours: 3 });
const received = await simAws.sqs().receiveMessage( new ReceiveMessageCommand({ QueueUrl: "https://sqs.us-east-1.amazonaws.com/888888888888/reports", MaxNumberOfMessages: 10, }),);
console.log(received.Messages?.length); // 3
// Nothing went wrong on the way, which is worth checking: a schedule that// could not reach its target says so here rather than by throwing.console.log(simAws.scheduler().deliveryFailures.length); // 0Ref returns the schedule’s name and Fn::GetAtt ... Arn its ARN, which carries the schedule
group as it always does. A schedule the template leaves unnamed gets one generated from the stack
name and the logical ID.
A property this simulation leaves out is refused at deploy time, naming the Resource. Deploying a schedule that behaves differently from the one declared would be worse. Tearing the stack down removes the schedules it created, and no schedule fires afterwards.
Available functionality
Section titled “Available functionality”CreateSchedule,GetSchedule,UpdateSchedule,DeleteScheduleandListSchedules.at(...),rate(...)and six-fieldcron(...)expressions, fired by advancing the simulation’s clock.- Lambda, SQS and SNS targets, with a target
Input, invoked as the target’s execution role and authorized against that role’s own policies. - ECS targets, running a simulated task as the execution role, with the task definition and
TaskCountfromEcsParametersand container overrides from the target’sInput. ActionAfterCompletion, anddeliveryFailuresfor invocations that did not happen.- The
defaultschedule group in every account and region, without one being created. - Creation and modification timestamps from the simulation’s clock, and prefix-narrowed, state-narrowed, paged listings.
- IAM authorization against the schedule ARN.
- SDK interception of
SchedulerClient.
Limitations
Section titled “Limitations”- A schedule only fires while a test advances the simulation’s clock. The host’s clock drives none of it, and a simulation left alone in real time never fires however long it is left.
- Firing is exact and exactly once. Real Scheduler invokes within a minute of the due time, and its promise is at-least-once.
- An invocation is attempted once. There is no retry and no dead letter queue. A target that throws
is recorded as a failure, and never redelivered. A failed invocation never rejects
advanceBy(...), and is read fromdeliveryFailures. - Schedule groups are absent as a manageable resource. Every schedule is in
default, and aGroupNamenaming any other group is refused. Putting it quietly indefaultwould give it an ARN naming the wrong group. FlexibleTimeWindowwithMode: "FLEXIBLE"is refused. Real Scheduler invokes the target at an unpredictable moment inside the window, and firing at the exact due time instead would let a test rely on timing AWS leaves unpromised.ScheduleExpressionTimezoneother thanUTCis refused outright, since running a schedule in the wrong zone fires it at the wrong hour.StartDateandEndDateare refused outright.- Targets are Lambda, SQS, SNS and ECS. The universal target
(
arn:aws:scheduler:::aws-sdk:<service>:<action>) and every other target service are refused when the schedule is created, ahead of the first due instant. - A target
DeadLetterConfig,RetryPolicy,EventBridgeParameters,KinesisParameters,SageMakerPipelineParametersandSqsParametersare refused outright, as isEcsParameterson a target whose ARN names something other than an ECS cluster. - An ECS target’s
EcsParameterstakesTaskDefinitionArnandTaskCount, and takes and ignoresLaunchType,PlatformVersion,NetworkConfigurationandCapacityProviderStrategy, since there is no placement and no network here for them to apply to. Anything else it can carry, such asGroup,TagsorPropagateTags, is refused outright. - An ECS target’s
Inputis read as the task’s overrides. AcontainerOverrideslist is how a schedule sets a container’s environment. AnInputthat is anything but a JSON object is refused on an ECS target, where every other target type takes any text. - A
TaskCountabove one runs that many simulated tasks, and a bound container handler runs once for each of them, in this process and one after another. KmsKeyArnis refused, andClientTokenis accepted and ignored. Nothing here retries, so it has no request to make idempotent.AWS::Scheduler::ScheduleGroupis absent as a CloudFormation resource type.AWS::Scheduler::Scheduleis there, under deploying from a template.
Software Engineering by Kensio Software
This page as plain text: llms.txt
Documenting Yulin v1.20.2
