Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for scheduling functions #1527

Merged
merged 26 commits into from
Jul 19, 2024
Merged

feat: add support for scheduling functions #1527

merged 26 commits into from
Jul 19, 2024

Conversation

rtpascual
Copy link
Contributor

@rtpascual rtpascual commented May 16, 2024

Changes

Add the ability to schedule functions:

  • Natural language - starts with every then the rest is based on the unit of time
    • Minutes and Hours
      • every followed by a space then a positive whole number and either m for minute(s) or h for hour(s)
      • Examples: every 5m or every 1h
    • Days, Weeks, Months, Years
      • every followed by a space then either day, week, month, or year
      • Days will always start at 00:00
      • Weeks start on Sundays at 00:00
      • Months start on 1st day of the month at 00:00
      • Years start Jan 1st at 00:00
      • Examples: every day or every year
  • Cron expression - 5 or 6 cron "parts" (year is not required) which follow Amazon EventBridge cron expressions for possible values
    • What is not supported in this implementation:
      • JAN-DEC for months
      • SUN-SAT for days of the week
      • The L wildcard
      • The # wildcard
      • The W wildcard
  • An array that contains either of the above.
export const everyFiveMinutes = defineFunction({
    name: 'everyFiveMinutes',
    schedule: 'every 5m',
});

export const everyHour = defineFunction({
    name: 'everyHour',
    schedule: 'every 1h',
});

export const cronFiveThirtyDaily = defineFunction({
    name: 'cronFiveThirtyDaily',
    schedule: '30 17 * * *',
});

export const cronWednesdayAtNine = defineFunction({
    name: 'cronWednesdayAtNine',
    schedule: '0 9 * * 3',
});

export const arrayEvery = defineFunction({
    name: 'arrayEvery',
    schedule: [
        'every 4h',
        'every 40m'
    ],
});

export const arrayCron = defineFunction({
    name: 'arrayCron',
    schedule: [
        '25 * * * *',
        '* 3 * * *',
        '0 1 * * * *',
    ],
});

export const arrayMixed = defineFunction({
    name: 'arrayMixed',
    schedule: [
        'every 6h',
        '* 5 * * *',
    ],
});

Validation

  • Deployed with test project and observed lambdas with EventBridge rules
  • Unit and E2E tests

Checklist

  • If this PR includes a functional change to the runtime behavior of the code, I have added or updated automated test coverage for this change.
  • If this PR requires a change to the Project Architecture README, I have included that update in this PR.
  • If this PR requires a docs update, I have linked to that docs PR above.
  • If this PR modifies E2E tests, makes changes to resource provisioning, or makes SDK calls, I have run the PR checks with the run-e2e label set.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Copy link

changeset-bot bot commented May 16, 2024

🦋 Changeset detected

Latest commit: ca2d0bd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@aws-amplify/backend-function Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment on lines 39 to 49
export type Cron =
| `${string} ${string} ${string} ${string} ${string}`
| `${string} ${string} ${string} ${string} ${string} ${string}`;
export type Rate =
| `every ${number}m`
| `every ${number}h`
| `every day`
| `every week`
| `every month`
| `every year`;
export type TimeInterval = Rate | Cron;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Types were done this way because I started getting Expression produces a union type that is too complex to represent Typescript error when I introduced wildcards for cron. Cron type is less strict and we perform our own cron validation to validate what we support (which could change in the future).

@josefaidt josefaidt linked an issue May 19, 2024 that may be closed by this pull request
3 tasks
@rtpascual rtpascual marked this pull request as ready for review July 9, 2024 22:09
@rtpascual rtpascual requested review from a team as code owners July 9, 2024 22:09
@rtpascual rtpascual added the run-e2e Label that will include e2e tests in PR checks workflow label Jul 10, 2024
const { value, unit } = parseRate(interval);

if (value && !isPositiveWholeNumber(value)) {
throw new Error(`schedule must be set with a positive whole number`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AmplifyUserError

) {
const timeout = lambda.timeout.toSeconds();

throw new Error(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AmplifyUserError

const timeout = lambda.timeout.toSeconds();

throw new Error(
`schedule must be greater than the timeout of ${timeout} ${
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`schedule must be greater than the timeout of ${timeout} ${
`Function schedule rate must be greater than the function timeout of ${timeout} ${

}
} else {
if (!isValidCron(interval)) {
// TODO: Better error messaging here or throw within each part of isValidCron in order to give more concise error messages
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good callout, I think we should go ahead and do this. My personal preference is to collect all of the validation errors into one object and then throw a single error that explains all of the validation violations. This avoids fixing one thing, getting another error, fixing that, getting another error, etc.

schedule: Schedule.cron(translateToCronOptions(interval)),
});

rule.addTarget(new targets.LambdaFunction(this.lambda));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to create a new target for each rule or can we reuse the same target since they are all pointing to the same lambda?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I believe we should be able to create the target once and reuse for each rule.

/**
* Initialize EventBridge rules
*/
constructor(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocker: since this class is purely internal it's not a big deal, but this is a lot of logic for a ctor. I think this is a case where we may not even need a class and just a convertTimeIntervalsToEventRules function would suffice for this functionality.

I also think the caller should be responsible for attaching the event rule to the lambda rather than it happening internally here. It's a small single-responsibility violation.

};
default:
// This should never happen with strict types
throw new Error('Could not determine the schedule for the function');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AmplifyUserError?

@@ -228,6 +235,7 @@ class DataStorageAuthWithTriggerTestProject extends TestProjectBase {
await this.checkLambdaResponse(node16Lambda[0], expectedResponse);
await this.checkLambdaResponse(funcWithSsm[0], 'It is working');
await this.checkLambdaResponse(funcWithAwsSdk[0], 'It is working');
await this.checkLambdaResponse(funcWithSchedule[0], 'It is working');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically this isn't checking that the event trigger is working, just that the lambda can be invoked successfully. I'm not sure how worthwhile it is, but a more comprehensive test might be to create a lambda that sets a value in parameter store (or leaves some trace of execution), set an "every second" schedule for that function, wait a couple seconds after the deployment completes, then check for the execution breadcrumb.

Or maybe a simpler test is to check that the lambda can be invoked (ie what you have here) + check cloudtrail for the invocation event

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EventBridge has a minimum of 1 minute for rule schedules so we would have to wait for ~1 minute for the event to invoke the lambda.

I was thinking the unit tests checking the event rule is properly added (which I just realized I should also make sure the rule target is properly set) and this E2E test is enough to have confidence this will work. I can explore the 2nd option you suggested.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a way around fully testing this without having the test wait the minimum time it takes for the event to trigger.

Still trying different ways to reduce flakiness with current E2E implementation.

Copy link
Contributor

@edwardfoyle edwardfoyle Jul 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the 70 second wait you added is okay. If it proves to be slow / flaky we can reevaluate the strategy and value of the test. Thanks for investigating this!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Yeah I was playing with the time considering EventBridge may have a delay of several seconds so 70 seconds seemed like a good number with the minimum time of 1 minute for schedules.

};

// @public (undocumented)
export type NodeVersion = 16 | 18 | 20;

// @public (undocumented)
export type Rate = `every ${number}m` | `every ${number}h` | `every day` | `every week` | `every month` | `every year`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion for alternative namings:

type TimeInterval = `every xx`;
type CronSchedule = `{string} ...`
type Schedule = TimeInterval | CronSchedule

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was avoiding type Schedule because of the naming clash with the Schedule class from aws-cdk-lib/aws-events. Alternatively we can use FunctionSchedule?

} else {
if (!isValidCron(interval)) {
// TODO: Better error messaging here or throw within each part of isValidCron in order to give more concise error messages
throw new Error(`schedule cron expression is not valid`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The message may not be always true, e.g. if I say every 5 months it will throw invalid cron expression but I specified an interval.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With strict typing and the more exhaustive validation you mentioned below, I believe everything that gets to this point will be validation for cron expressions

}

const isRate = (timeInterval: TimeInterval): timeInterval is Rate => {
return timeInterval.split(' ')[0] === 'every';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More exhaustive validation

Suggested change
return timeInterval.split(' ')[0] === 'every';
const parts = timeInterval.split(' ');
return parts[0] === 'every' && (['m', 'h', 'day', 'week', 'year', 'month'].some(a => parts[1].endsWith(a))) && parts.length === 2

Because customers could be using this in a JS only app or have TS errors suppressed in their files or just creative enough to pass every 5 weeks until 2025 (Which our type would allow thinking it's a cron)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can also get rid of it by just using tryParseRate.

Comment on lines 75 to 76
const regex = /\d/;
if (interval.match(regex)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this not match just one digit? Ideally we should parse with a more confidence that it is indeed a rate. E.g. with regex ^/\d+[mh]$|^day|year|month$/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, my goal was to just see if there is a number in the second part of the "rate string" in order to get the number value and unit (m or h). Otherwise the second part would be day, week, etc.

The Rate type is doing most of the heavy lifting when I initially made the validation (which I'll revisit based on your previous point on customers using JS only or have TS errors suppressed)

edwardfoyle
edwardfoyle previously approved these changes Jul 16, 2024
Copy link
Contributor

@edwardfoyle edwardfoyle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, just one naming nit if you want to take it

packages/backend-function/src/factory.ts Outdated Show resolved Hide resolved
edwardfoyle
edwardfoyle previously approved these changes Jul 17, 2024
Copy link
Contributor

@edwardfoyle edwardfoyle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, couple nits if you want to address

packages/backend-function/src/schedule_parser.ts Outdated Show resolved Hide resolved
await this.checkLambdaResponse(funcWithSchedule[0], 'It is working');

// test schedule function event trigger once
if (this.checkInvocationCount) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming this is here to prevent duplicate checking, but I'm confused why it's necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reduced a lot of flakiness that I was seeing by only testing for invocation count on the first deployment instead of also on subsequent sandbox updates. This will also help us reduce time on e2e tests as we don't have to wait 70 seconds for each assertPostDeployment.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. Could be helpful to leave that context in a comment on this code.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ca2d0bd

edwardfoyle
edwardfoyle previously approved these changes Jul 18, 2024
Copy link
Contributor

@edwardfoyle edwardfoyle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Take or leave this comment: #1527 (comment)

@rtpascual rtpascual merged commit ec12ac8 into main Jul 19, 2024
53 checks passed
@rtpascual rtpascual deleted the schedule-functions branch July 19, 2024 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
run-e2e Label that will include e2e tests in PR checks workflow
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Gen2: Scheduled Lambdas are not possible anymore
4 participants