Simulated EventBridge Scheduler
Yulin simulates Amazon EventBridge Scheduler in memory. Schedules run when simulated time advances,
and every management operation is authorized by simulated IAM. Import Scheduler-specific types from
@kensio/yulin/scheduler.
Scheduler is separate from EventBridge. It uses its own SDK client and ARN format. It also assumes an execution role to invoke a target, while EventBridge rules use the target’s resource policy.
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”Scheduler accepts three expression forms:
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 a wall-clock time. 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?.
The zone a schedule runs in
Section titled “The zone a schedule runs in”ScheduleExpressionTimezone says which zone the wall-clock time belongs to, and a schedule created
without one runs in UTC. Any IANA name the host’s own Intl timezone data recognises works, and a
name it does not is refused when the schedule is created.
await simAws.scheduler().createSchedule( new CreateScheduleCommand({ Name: "nightly-rollup", ScheduleExpression: "cron(0 2 * * ? *)", ScheduleExpressionTimezone: "Europe/London", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: functionArn, RoleArn: roleArn }, }),);That schedule is due at 01:00 UTC through British Summer Time and at 02:00 UTC through the winter, which is what a nightly job written for a zone actually does. The two days a year a zone changes offset are read the same way. A schedule keeps its hour on the clock across both.
The hour the clocks move takes the rule real Scheduler uses. A wall-clock time inside the hour that never happens falls due at the first instant after it (02:30 on the morning the clocks go forward fires at 03:30). A wall-clock time the clocks read twice falls due once, at the first of the two readings.
GetSchedule reports the zone back as it was written.
Firing a schedule
Section titled “Firing a schedule”A schedule fires on the simulation’s clock. Advance time past a due instant to invoke the target.
/** * 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”A schedule assumes the target’s RoleArn, and that role’s policies authorize delivery. An
EventBridge rule instead invokes as
events.amazonaws.com and depends on the target’s resource policy.
The execution role needs both:
- 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.
A trust policy may also carry the condition AWS recommends against the confused deputy problem. The
schedule’s group ARN is supplied as aws:SourceArn, and the account is supplied as
aws:SourceAccount. A role scoped to one schedule group is assumable only by schedules in that
group. CDK writes that condition into the execution roles it generates for a schedule target, so a
role from a synthesized template works unchanged.
If either permission is missing, advanceBy(...) still returns normally and the target is not
invoked. Read deliveryFailures to assert on the failed delivery:
/** * 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, ..."JSON.stringify on a failure carries the message alongside the schedule, the target, the role and
the instant it names.
A Lambda target has another boundary. Scheduler records a failure when it cannot assume the role,
the role cannot invoke the function, the function is missing or Lambda refuses the request. Once
Lambda accepts the asynchronous invocation, its event invoke config applies. Handler errors follow
Lambda’s retry, destination and function dead-letter queue settings and do not appear in
deliveryFailures.
Retrying and dead-lettering a delivery
Section titled “Retrying and dead-lettering a delivery”Set Target.RetryPolicy to retry a failure that may clear. MaximumRetryAttempts is the number of
attempts after the first one and accepts 0 through 185. MaximumEventAgeInSeconds accepts 60 through
86,400. If the policy leaves either member out, its effective value is 185 attempts or 86,400
seconds. GetSchedule still reports the policy as it was written.
Retries use the simulation’s clock. The first retry is due one second after the failed attempt. Each
following delay doubles to 2, 4, 8 seconds and so on. A retry does not run while the clock stands
still. advanceBy(...) runs every retry that becomes due in the interval and settles their work
before it returns.
Scheduler retries only failures that may clear. A missing target, missing execution role, invalid trust policy, or IAM denial is permanent and is abandoned after the initial attempt.
Set Target.DeadLetterConfig.Arn to a standard SQS queue ARN to keep an input that Scheduler abandons.
The execution role needs sqs:SendMessage on this queue as well as permission to invoke the target.
The message body is the target’s original Input. Its string message attributes follow Scheduler’s
shape and include the error, schedule ARN, target ARN, scheduled time and retry count.
EXHAUSTED_RETRY_CONDITION is MaximumRetryAttempts or MaximumEventAgeInSeconds for an exhausted
retryable failure. A permanent failure leaves that attribute out.
A successful DLQ send leaves deliveryFailures empty because the configured destination received the
input. A missing queue or denied sqs:SendMessage is recorded there instead.
One-time schedules and what happens after
Section titled “One-time schedules and what happens after”An at(...) schedule fires once. It remains in the account unless
ActionAfterCompletion: "DELETE" removes it. A retained schedule continues to appear in listings
and count against the schedule quota.
A disabled schedule remains incomplete when its only instant passes. It stays in the account
regardless of ActionAfterCompletion.
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.
Flexible time windows
Section titled “Flexible time windows”FlexibleTimeWindow: { Mode: "FLEXIBLE", MaximumWindowInMinutes: n } delays each invocation by up
to n minutes. The schedule still falls due at the times its expression gives. Each occurrence then
invokes its target at a moment drawn inside [due, due + n minutes). The moment is never before the
due time and always before the window closes.
The draw is made separately for every occurrence. A test that advances the clock past the end of an occurrence’s window sees exactly one invocation for it, whatever was drawn. A test that stops inside the window may or may not see it. Assert after the window has closed.
The draws come from the random source the SimAws was built with. It defaults to the host’s
Math.random, and the moments differ from run to run. Pass a SimSeededRandom to draw the same
moments every time (to reproduce a failure, for example).
/** * A schedule invoking its target up to fifteen minutes after each hour. */
import { CreateScheduleCommand, GetScheduleCommand,} from "@aws-sdk/client-scheduler";
import { SimAws, SimSeededRandom } from "@kensio/yulin";
// The same seed draws the same invocation moments on every run.const simAws = new SimAws({ random: new SimSeededRandom(2026) });
await simAws.scheduler().createSchedule( new CreateScheduleCommand({ Name: "hourly-digest", ScheduleExpression: "cron(0 * * * ? *)", FlexibleTimeWindow: { Mode: "FLEXIBLE", MaximumWindowInMinutes: 15 }, Target: { Arn: "arn:aws:lambda:us-east-1:888888888888:function:digest", RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole", }, }),);
const described = await simAws .scheduler() .getSchedule(new GetScheduleCommand({ Name: "hourly-digest" }));
console.log(described.FlexibleTimeWindow);// { Mode: "FLEXIBLE", MaximumWindowInMinutes: 15 }A FLEXIBLE window needs MaximumWindowInMinutes, a whole number from 1 to 1440. An OFF window
must leave it out. Either mistake is a ValidationException. GetSchedule reports the window the
schedule was created with.
A schedule deleted or replaced while an occurrence is waiting inside its window does not invoke for
that occurrence. A one-time schedule with ActionAfterCompletion: "DELETE" is removed once its
delayed invocation has been made.
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. Use EcsParameters to select the task definition and use Input for task overrides.
/** * 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 without a bound container 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 replaces the full schedule definition. Any optional value omitted from the update
is removed. The schedule must already 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.
Schedule groups
Section titled “Schedule groups”Every account and region starts with a default group. A schedule naming no group goes in it.
A schedule’s name is unique within its group, and the group is in the schedule’s ARN. Two deployments of one construct into the same account and region collide on schedule names unless each one brings its own group.
/** * A schedule group scoping the names of one deployment's schedules. */
import { CreateScheduleCommand, CreateScheduleGroupCommand, ListSchedulesCommand,} from "@aws-sdk/client-scheduler";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const scheduler = simAws.scheduler();
const group = await scheduler.createScheduleGroup( new CreateScheduleGroupCommand({ Name: "reporting-pr-412" }),);
console.log(group.ScheduleGroupArn);// "arn:aws:scheduler:us-east-1:888888888888:schedule-group/reporting-pr-412"
const created = await scheduler.createSchedule( new CreateScheduleCommand({ Name: "pageviews-hourly", GroupName: "reporting-pr-412", 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", }, }),);
// The group is in the schedule's ARN. The same schedule name is free in every// other group, including default.console.log(created.ScheduleArn);// ".../schedule/reporting-pr-412/pageviews-hourly"
const listed = await scheduler.listSchedules( new ListSchedulesCommand({ GroupName: "reporting-pr-412" }),);
console.log(listed.Schedules?.length); // 1A GroupName for a group that has yet to be created raises ResourceNotFoundException, and so
does a listing for one. Real Scheduler answers the same way. A schedule quietly moved into default
would carry an ARN naming a group it had never been put in.
GetScheduleGroup reports a group’s ARN, state and timestamps. ListScheduleGroups reports them
all in creation order, narrowed by NamePrefix and paged by MaxResults and NextToken.
DeleteScheduleGroup deletes the schedules in the group along with it, as AWS does. It refuses the
default group, which comes with the account. Losing that group would leave every request naming
no group with nowhere to go.
Permissions
Section titled “Permissions”Every operation is authorized against the schedule ARN, which carries the group. A Create or an
Update also hands Scheduler the Target.RoleArn the schedule fires as, and authorizes iam:PassRole
against that Role. See passing a Role to a service
in the IAM docs.
The schedule ARN is what an operation authorizes against:
/** * 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. Its properties follow
CreateSchedule, and the target ARN or execution role can use Fn::GetAtt references to resources
in the same template.
/** * 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, the logical ID and a tail derived from both, as
the CloudFormation docs
describe.
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.
Deploying a schedule group
Section titled “Deploying a schedule group”AWS::Scheduler::ScheduleGroup deploys too, so a stack can bring the group its schedules go in.
/** * A stack deploying its own schedule group, with a schedule in it. */
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const stack = await simAws.cloudFormation().deployTemplate({ stackName: "reporting-stack", template: { Resources: { ReportGroup: { Type: "AWS::Scheduler::ScheduleGroup", Properties: { Name: "reporting-pr-412" }, }, HourlyReport: { Type: "AWS::Scheduler::Schedule", Properties: { Name: "pageviews-hourly", GroupName: { Ref: "ReportGroup" }, ScheduleExpression: "rate(1 hour)", FlexibleTimeWindow: { Mode: "OFF" }, Target: { Arn: "arn:aws:sqs:us-east-1:888888888888:reports", RoleArn: "arn:aws:iam::888888888888:role/SchedulerRole", }, }, }, }, Outputs: { GroupArn: { Value: { "Fn::GetAtt": ["ReportGroup", "Arn"] } }, }, },});
await stack.waitForDeployComplete();
// An identity policy granting `scheduler:` actions on the group names this// ARN. So does the `aws:SourceArn` condition AWS recommends in a schedule// execution role's trust policy, which the simulation does not supply.console.log(stack.output("GroupArn"));// "arn:aws:scheduler:us-east-1:888888888888:schedule-group/reporting-pr-412"Ref returns the group’s name and Fn::GetAtt answers Arn, State, CreationDate and
LastModificationDate. A group the template leaves unnamed gets one generated from the stack name,
the logical ID and a tail derived from both, as a schedule does.
Tags on a group are recorded as an ignored property and the group deploys without them. The CDK
puts a stack’s tags on every taggable Resource in it, so a template gains them without asking.
Failing the whole stack over a tag would refuse a deployment for a property the simulation ignores
anyway. Read the record back from stack.getResource("<logicalId>")?.ignoredProperties.
Tearing the stack down removes the group. Its schedules go with it, whether or not they are Resources of the same stack.
Supported operations
Section titled “Supported operations”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. Lambda targets use asynchronous Event invocation. - ECS targets, running a simulated task as the execution role, with the task definition and
TaskCountfromEcsParametersand container overrides from the target’sInput. FlexibleTimeWindowin both modes, with eachFLEXIBLEinvocation drawn inside its window from the simulation’srandomsource.ActionAfterCompletion, anddeliveryFailuresfor invocations that did not happen.- Target retry policies driven by simulated time, and standard SQS dead-letter queues carrying the original input and Scheduler diagnostic attributes.
CreateScheduleGroup,GetScheduleGroup,DeleteScheduleGroupandListScheduleGroups, over thedefaultgroup every account and region starts with and any group created beside it.AWS::Scheduler::ScheduleandAWS::Scheduler::ScheduleGroupdeployed from a CloudFormation template.- Creation and modification timestamps from the simulation’s clock, and prefix-narrowed, state-narrowed, paged listings.
- IAM authorization against the schedule ARN.
iam:PassRoleauthorization of theTarget.RoleArna Create or an Update hands over.- 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 with the window
OFF. Real Scheduler invokes within a minute of the due time, and its promise is at-least-once. - Retry delays are deterministic powers of two seconds. Real Scheduler uses exponential backoff without publishing an exact sequence. The fixed sequence lets a test advance to a known retry instant.
- Delivery retries cover failures before the target accepts the request. A Lambda handler failure happens after Lambda accepts its asynchronous invocation and uses Lambda’s retry, destination and function dead-letter queue settings instead.
- A schedule group carries no tags.
CreateScheduleGrouprefusesTags, since the simulation stores them nowhere. A template’sTagsare recorded as an ignored property and the group still deploys. - A schedule group is
ACTIVEor gone. Deleting one removes its schedules in the same call, where real Scheduler holds the group inDELETINGuntil they have gone. - The
defaultschedule group cannot be deleted. AWS leaves the answer to that request undocumented. - A flexible window draws each invocation moment uniformly, to the millisecond. AWS documents only that the target is invoked inside the window after the scheduled time, and publishes no distribution.
- The wording of the refusals for a
FLEXIBLEwindow with noMaximumWindowInMinutes, and anOFFwindow with one, is Yulin’s own. AWS refuses both and does not document the message. The range refusals use the API’s constraint wording. ScheduleExpressionTimezoneis read from the host’s own timezone data throughIntl, so a zone the host has never heard of is refused even where AWS would take it. A schedule that names none runs in UTC.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
EventBridgeParameters,KinesisParameters,SageMakerPipelineParametersandSqsParametersare refused outright, as isEcsParameterson a target whose ARN names something other than an ECS cluster. ADeadLetterConfigmust name a standard SQS queue because simulated SQS supports only standard queues. - 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. Yulin makes each schedule management request once, so the token has no retry to make idempotent.
