-
Notifications
You must be signed in to change notification settings - Fork 13
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
Proposal: anonymous Lambda closures that can be inlined within Step Function closures #448
Comments
We will need to pass any captured closure state into the function, either as static environment variables or as input data. const table = new Table(scope, id);
new StepFunction(scope, id, async () => {
const variable: string = ??;
await inlineFunction(async () => {
variable; // variable captured in lexical scope must be passed as input since its value isn't known until runtime
table.tableArn; // value captured in lexical scope, but this one can be passed through environment variables like we do today
});
})); It will need to be possible to pass properties such as memory and timeout, and these values must be constrained to values known during synth or deployment time - values known at runtime cannot be passed. Do we need a new I noticed that |
Yup that would be one of the bigger challenges to solve if this convenience was deemed worth it. I tried to call this out in my example translation. There are two values that get captured in lexical scope and need to be injected into the input of the function invocation: await new Function(
this,
`Fn_${genId()}`,
async (event: { param_1: string; param_2: string }) => {
const client = new ConnectionClient(event.param_1); // translated from parent closure
await client.sendMessage(event.param_2); //translated from parent closure
}
)({
param_1: item.connectionUrl.S, // injected
param_2: event.body.message, // injected
});
This is a good point, and maybe a deal breaker for this convenience. I had thought about these values being passible as an optional second argument, but the implementation would have to be smart about making sure those values were known at synth or deployment time. My understanding of the compiler is rudimentary at this point, so this may not be feasible, but it seems like this could be enforced at compilation time by type InlineFunction = <I, O extends Promise<unknown>>(
handler: () => O,
props?: FunctionProps
) => O;
My mental model for thinking of an
Yup scope would use the same scope as the integration it is inlined within ideally. If this is not possible to reliably determine, then a scope argument could be added.
Agree ID should be deterministic, although I think this can be generated deterministically similarly to how most programing languages handle anonymous functions. However, I don't think developers need to have an opinion on what it should be, it just needs to be generated in a deterministic way to prevent collisions. This is one of the tradeoffs of using anonymous function, you lose control of naming and your call stack becomes harder to debug. For example in node an anonymous function becomes However, if I use a named function instead, I get a more debuggable stack trace: Note that in this example I can locate the error better because it is in the named function In my ideal world, users have both named (via |
Love the idea. We could do something like exec = makeIntegration<"Function.exec", (in: IN) => OUT>("Function.exec", {
asl: (call) => {
const closure = getClosure(call);
new Function(context.resource, makeId(), async () => {
// populate scope
const result = await closure(); // exec the closure
// return scope mutations and result to sfn
});
return // ASL to call the function, manipulate the input and out. etc
}
}) |
I don't understand. Can you show an example |
new ExpressStepFunction(
this,
"HandlePostLog",
async (event: { body: { message: string } }) => {
const connections = await $AWS.DynamoDB.Query({
Table: table,
KeyConditionExpression: "#pk = :pk",
ExpressionAttributeNames: {
"#pk": "pk",
},
ExpressionAttributeValues: {
":pk": { S: "connection" },
},
});
if (connections.Items == null) {
return;
}
for (const item of connections.Items) {
// Able to use a lambda runtime inline with the rest of
// the StepFunction domain logic
await Function.exec(
async () => {
const client = new ConnectionClient(item.connectionUrl.S); // can capture data from the parent closure
await client.sendMessage(event.body.message);
}
);
}
}
) |
the idea of allowing
|
How about this crazy idea? await Function(async () => {
..
}); |
Maybe this is helpful? We think of constructs new Function(scope, "foo");
new StepFunction(scope, "bar"); These are equivalent to syntax in a hypothetical language: lambda function foo() {}
step function bar() {} What would an anonymous function then be? Should it just be a function without an id? new Function(scope, async () => ) Later on down the road we can explore ways to infer scope, and ID can come from syntax like previously described. We should probably make sure our approaches are consistent with this mental model, but I'm also ok with exploring convenience features to extend the capabilities of step functions. |
Agreed in general, but that is not what this request was for. (Please correct me of I am taking this off track @tvanhens) This request is for a nested closure like ability where the nested closure is actually a Lambda Function instead of an SFN function. With your mental model, we can do this today lambda function foo(a: number, b: number) { return a + b; }
step function bar(input) {
[1,2,3].map(async (i) => foo(i, input.n));
} But how would we do this today? step function bar (input) {
const value = input.arr.map(lambda (i) => input.n + i );
} the anonymous function gets access to |
While functionless will be much better at reducing a stack to the program logic, step functions will have a similar issue where a bunch of lambdas are defined at the top of the file and then used within the step function. If we can find a way to write the lambda as it is needed, inline, we'll reduce that split brain issue event more (plus closured values). |
Anonymous functions were requested, their design should probably align with syntax design. |
This comment made it seem like we'd just solve the problem of giving the function a name, not the locallity. From the original request:
|
Your bring up a good point and that sounds challenging. Reflecting changes would be cool if possible. |
I think its possible, just need to wrap the start and the end of the function to setup the scope and then include the values in the request and updates in the response. new Function(context.resource, ... generate ID...., async (request) => {
// maybe separate by mutable and const?
let { ... free variables .... } = request.scope;
const closure = // inline the closure to get access to the scope...?
const response = closure(request.payload);
return {
scope: { ... mutable free variables ... },
response
}
}); |
and then {
"Type": "Task",
"Parameters": {
"scope": { ... free variables ... },
"payload": args[0] // evaluated etc...
},
"ResultPath": "output"
} And states to handle applying the output.payload and output.scope back to the state. |
Taking a step back, this was the problem I was hoping to solve. Although, the more I think about it, what I'm asking for isn't exactly an anonymous function - it's more of a contextual closure as @thantos highlighted:
I think I've caused confusion by labeling the feature as an anonymous function as opposed to a lambda-contextualized closure that can can be inlined within a Step Function definition closure. |
FWIW I do like the Another reason I like it is because I think it will also be desirable to inline Can't tell you how many times I tore my hair out trying to build something like this in the last few years. new StepFunction(this, "MyEDIWorkflow", async () => {
// Run this in a full step function context for auditing
// Step 1: Poll Files from Partner's FTP Server
const fileRefs = ExpressStepFunction.exec(async () => {
// Run this in an express context for cheaper orchestration
// Find all files that need to be downloaded
let fileNames = await Function.exec(async () => {
// Run this in a lambda context for ftp support
return await ftp.connect(async () => {
return await ftp.listFiles();
});
});
return await $SFN.map(
fileNames,
{ maxConcurrency: 3 },
async (fileName) => {
const [md5, content] = await Function.exec(async () => {
// Run this in a lambda context for ftp support
return await ftp.connect(async () => {
const content = await ftp.download(fileName);
return [md5(content), content];
});
});
await $AWS.DynamoDB.putItem({
table: DataStore,
item: {
pk: "inboundFiles",
sk: md5,
content,
},
});
return md5;
}
);
//...
// Step 2: Retrieve files by ref and transform saving the transformed result
// Step 3: Load transformed content into ERP
}); Being able to weave in and out of the execution context that makes architectural sense for the task without having to jump around a giant file to wrap your head around what's going on would be killer. |
Agree that is an awesome experience. One thing to consider is how developers maintain those inner resources. Can they add them to cloudwatch dashboards, create alarms, etc. How would we expose them? Do we need to allow developers to optionally provide scope, id as overloads? |
Was thinking, to help with providing sensible defaults for things like memory and timeout, perhaps we can provide helpers like Function.execLight
Function.execHeavy
Function.execShort Not sure if this is helpful. |
Couldn't an optional second argument allow users to set specific parameters for anonymous functions? Figuring out sane defaults for these would be tough. |
A potentially interesting CLI/UI feature for this would be a consolidated log view. See all the logs for the Step Function and all of its inner processes (Step Functions, Express Step Functions and Lambda Functions). |
While it is desirable to do everything in a functionless way, it is almost always necessary to call out to a proper lambda to get around limitations at some point. The
Function
construct is great for when you have to integrate with a lambda because no other integration is supported, however, it is cumbersome when you are only using a lambda as "glue" in a mostly functionlessStepFunction
. Being able to create anonymous functions inline would limit the context shifts that are necessary to build aStepFunction
.Note: this is probably more broadly applicable than just for
StepFunction
integrations.Current Experience
Desired Experience
Implications
Function
props.The text was updated successfully, but these errors were encountered: