Skip to content

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 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.

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 trailing Z is refused.
  • rate(<value> <unit>) runs from when the schedule was created. The unit is minute, hour or day, 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 is cron(0 2 * * ? *). The day-of-month and day-of-week fields cannot both say something. Whichever is not deciding the day is written ?.

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); // 3

Firing 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.

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.com assume it. A role copied from an EventBridge rule trusts events.amazonaws.com and fails here.
  • A policy on the role has to allow the action on the target, being lambda:InvokeFunction, sqs:SendMessage, sns:Publish or ecs: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, ..."

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.

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); // 1

The 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.

/**
* 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 accident

UpdateSchedule 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.

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.

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); // true

ListSchedules 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.

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); // 0

Ref 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.

  • CreateSchedule, GetSchedule, UpdateSchedule, DeleteSchedule and ListSchedules.
  • at(...), rate(...) and six-field cron(...) 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 TaskCount from EcsParameters and container overrides from the target’s Input.
  • ActionAfterCompletion, and deliveryFailures for invocations that did not happen.
  • The default schedule 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.
  • 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 from deliveryFailures.
  • Schedule groups are absent as a manageable resource. Every schedule is in default, and a GroupName naming any other group is refused. Putting it quietly in default would give it an ARN naming the wrong group.
  • FlexibleTimeWindow with Mode: "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.
  • ScheduleExpressionTimezone other than UTC is refused outright, since running a schedule in the wrong zone fires it at the wrong hour.
  • StartDate and EndDate are 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, SageMakerPipelineParameters and SqsParameters are refused outright, as is EcsParameters on a target whose ARN names something other than an ECS cluster.
  • An ECS target’s EcsParameters takes TaskDefinitionArn and TaskCount, and takes and ignores LaunchType, PlatformVersion, NetworkConfiguration and CapacityProviderStrategy, since there is no placement and no network here for them to apply to. Anything else it can carry, such as Group, Tags or PropagateTags, is refused outright.
  • An ECS target’s Input is read as the task’s overrides. A containerOverrides list is how a schedule sets a container’s environment. An Input that is anything but a JSON object is refused on an ECS target, where every other target type takes any text.
  • A TaskCount above one runs that many simulated tasks, and a bound container handler runs once for each of them, in this process and one after another.
  • KmsKeyArn is refused, and ClientToken is accepted and ignored. Nothing here retries, so it has no request to make idempotent.
  • AWS::Scheduler::ScheduleGroup is absent as a CloudFormation resource type. AWS::Scheduler::Schedule is there, under deploying from a template.

Documenting Yulin v1.20.2