Replies: 7 comments 7 replies
-
Draft implementation in #3 |
Beta Was this translation helpful? Give feedback.
-
General CommentsAlthough notation has the aim of allowing infrastructure setup code and 'runtime code' to live together, I think it is sensible to impose some guard rails to avoid the user accidentally running code at 'infrastructure deployment' time that should only be executed at runtime. Equally, the infrastructure setup code may also have to do its own side effects that should rightfully only be executed at deployment time. I think having a 'filesystem API' is a very good approach. Some suggestionsI apologise if these comments are out of scope of your RFC and really I should be sticking to commenting on the division of infrastructure FnConfig exportI wonder if it would lead to less foot guns to just require the user to explicitly pass the configuration I think this makes sense for the following reasons:
Function ResourcesFurther to the above, I wonder if there should be an intermediate 'function resource' infrastructure type between the 'apiRoute' resource and its 'handler' parameter Afterall, a 'lambda function' is a resource too. The function resource can be configured with the following things:
Example of what this might look like:
Note: I think there's still room for some helpful implicit behaviour here around implicitly setting up permissions for the lambda to be called |
Beta Was this translation helpful? Give feedback.
-
Thanks @Happy0! Great comments. Am I right in thinking that the fundamental change you are suggesting is to move the config from the runtime code to the infrastructure code? I'll leave my thoughts on why I didn't initially go with this approach. Would be great to hear a counter argument if you have one. Also totally fine to revise these arguments as we get clearer insights through testing one approach. My reluctance for taking this approach was that the config is inherently coupled to the function code, and to have it defined in an infra module would move it too far away from the function, creating a strong coupling with low locality. In real terms, when a developer writes a function, they would then also have to head over to the infrastructure side of the code base and declare its config. You make a good point though that some of the function config is coupled to other infrastructure components – for example an IAM role, or envs that originate from a cloud service. As you say, in some cases this can be calculated by virtue of the infrastructure graph. For example, a lambda connected to an API gateway needs IAM roles, a policy attachment, a lambda permission, an API integration. Similarly, a lamdba that calls Dynamo will probably need to be placed in a VPC and connected via a VPC endpoint. This is often predictable boilerplate that Notation can calculate based on best practices, and save the developer deciphering verbose documentation. Furthermore, if moving to another cloud, the underlying infrastructure will need to change and a different set of dependencies will be required to satisfy the relationship between the primary cloud resources. By abstracting away the infra boilerplate, Notation helps ease such a migration. The question then is whether there is any configuration related to other infra resources that can't be calculated by Notation, and whether this substantiates the argument to move the config into the infra module space. One other point to make about the config export: from an architectural PoV, I see these modules as something analogous to containers, with the default config export being analogous to a dockerfile. You can put multiple function in these modules, but they'll all be run in the same environment. So, it is partly by design that you can't have different runtime configurations of the same function (which is possible with your proposal). One specific point:
If the config is defined in an infra module, then yes. If it is in a function module, the config needs to be extracted statically, therefore allowing it to be defined with anything other than primitive types (e.g. imports) vastly increases the complexity of the extraction. |
Beta Was this translation helpful? Give feedback.
-
Approach 2 definitely seems best to me, and I am glad to see it reflected in the direction being taken here. I suspect that in reality configuration details like memory allocation and retries will frequently be the same from one function to the next. This for me is an argument for boosting it a level to the infrastructure, where it will be easier to see where a default template is being applied and where some variation has been made. In fact for developers unfamiliar with the cloud it may well be advantageous just to give them reasonable defaults and the option to override - it's very much preferable to be worrying about this during the "fine tuning" step rather than the "getting something to work" step. |
Beta Was this translation helpful? Give feedback.
-
There are now integration tests on the main branch with a sample app that can be played around with to test the draft compiler. |
Beta Was this translation helpful? Give feedback.
-
It strikes me that this is the old conflict between power and usability.
There is no world where importing arbitrary code just works. Any library
the user pulls in could end up making run time requests to some external
entity which require permissions to be set up. The user will need to
understand this at the outset.
So then the question becomes whether to have the developer explicitly set
up access on each and every occasion it is needed, or to abstract it. If it
is abstracted, it may well be better to use the model discussed previously
where the user deals with higher level abstractions around storage or
databases and lets Notation convert these into detail. Having special
handling and static analysis around Dynamo, Redis, Mongo etc strikes me as
potentially being a lot of work. But then an abstraction layer is also more
work than just giving the developer the tools to do things explicitly.
I guess it depends whether this is more targeted at helping developers who
know what they are doing work faster or helping developers who don't know
what they're doing make anything work at all.
…On Wed, 1 Nov 2023, 16:20 Daniel Grant, ***@***.***> wrote:
@Happy0 <https://github.com/Happy0>, just thinking about this use case
and the spectrum of possible solutions:
1. Most explicit: the developer imports a DynamoDB table resource and
attaches it to the lambda config
import { FnConfig, handle } from ***@***.***/aws/lambda";import { userTable } from "./tables";
// this is extract from the module using static analysisexport const config: FnConfig = {
service: "aws/lambda",
memory: 64,
accessRoles: [userTable.getItemAccessRole]};
// now do whatever you want with your tableconst client = thirdPartyDDBLib(userTable.name);
1. Semi-explicit: a DynamoDB client only allows operations for which
access has been enabled
import { FnConfig, handle } from ***@***.***/aws/lambda";import { dynamoClient } from ***@***.***/aws/dynamo";import { userTable } from "./tables";
export const config: FnConfig = {
service: "aws/lambda",
memory: 64};
// this is also extracted and interpreted at compile time (maybe as a macro)// when we create the orchestration graph, these access roles are added to the lambdaconst users = dynamoClient({
table: userTable,
access: ["getItem"] });
export const getUser = handler(() => {
// get item is the only TypeScript type available
return users.getItem();}
1. Implicit: The access role is attached to the lambda based on the
code the developers write
import { FnConfig, handle } from ***@***.***/aws/lambda";import { dynamoClient } from ***@***.***/aws/dynamo";import { userTable } from "./tables";
export const config: FnConfig = {
service: "aws/lambda",
memory: 64};
export const getUser = handler(() => {
// using static analysis, we track the DynamoDB resource and see how it is being used
return userTable.client.getItem();}
Do you think that covers the range of possibilities?
—
Reply to this email directly, view it on GitHub
<#2 (reply in thread)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/ALRLOOKGVI3S45PWLLZ7X33YCJSELAVCNFSM6AAAAAA5PWEN7CVHI2DSMVQWIX3LMV43SRDJONRXK43TNFXW4Q3PNVWWK3TUHM3TINBWHEYTI>
.
You are receiving this because you commented.
Message ID: <notationhq/notation/repo-discussions/2/comments/7446914@
github.com>
|
Beta Was this translation helpful? Give feedback.
-
For handling the DynamoDB access use case, #10 introduces a compile step that allows infrastructure modules to be imported into function modules. The compiler removes all code from the function module except that which is definitely safe to evaluate, enabling some really interesting use cases that would require dynamically attaching additional infrastructure resources to a function resource. |
Beta Was this translation helpful? Give feedback.
-
Context
The Notation SDK provides modules for building both infrastructure and runtime code to be deployed to that infrastructure. Infrastructure and runtime code exist in the same type space.
Notation's compiler produces both an infrastructure plan and packages containing the runtime code. To produce the infrastructure plan, infrastructure modules are evaluated after transpilation producing a topologically sorted graph.
The compiler has to ensure that when it evaluates the infrastructure code that it does not accidentally evaluate runtime code.
Runtime code, particularly in the case of a serverless function, may contain code that exists outside the handler scope i.e. code that is evaluated on the initialisation of the serverless function but not on subsequent invocations.
Approaches
Approach 1: Runtime code can be in the same module as infrastructure
Approach 2: Runtime code must be declared in a separate module
The runtime module could be identified by its path e.g.
get-num.fn.ts
orruntime/get-num.ts
.Challenges
Challenge 1: Handling side effects
A serverless function may declare code that is run only on initialisation. This is typically declared in the outer scope:
It is crucial that, while forming the orchestration graph, the orchestration compiler never evaluates user code, which may contain side effects.
Challenge 2: Identifying runtime code
In Approach 2, runtime code can be identified by virtue of a contract with the developer, namely that their runtime code is placed within a special module. By and large, the code within these modules could be ignored by the orchestration compiler.
In Approach 1, developers would be required to wrap their runtime code within a higher-order
handler
function. Using module replacement, the handler function could be swapped for a no-op function, ensuring the runtime code in its callback is never called. The limitation of this approach is that it would not allow developers to write setup code outside the handler scope.Challenge 3: Extracting infrastructure
Both approaches require a way of exposing the related infrastructure concerns of runtime modules to the orchestration compiler. For both approaches
handler
should return an infrastructure resource. This can be its default behaviour or achieved with module replacement.For Approach 2, the module cannot be evaluated because it may contain side effects. This could be worked around by introducing an exports API, in which the key infrastructure information is extracted at the transpilation stage. For example, a named export
config
would be identified as the infrastructure configuration for the serverless function, and the other named exports would identified as handlers. A new module could then be created by the transpiler containing only infrastructure declarations.Challenge 4: Avoiding uncanny valley
A potential advantage of Approach 2 is that it clearly demarcates what is infrastructure and what is runtime. While there is potentially an easiness to writing runtime code and infrastructure code in the same module, this could potentially place a cognitive overhead on the developer, or at worse, a foot gun.
Challenge 5: Nano functions ergonomics
Nano functions, characterised by their instant startup times and access to shared memory, are gaining popularity. They do not necessarily prescribe that their functions contain only a few lines of code, but they are certainly better suited for workflows that are broken into smaller units. As such, codebases with nano functions will be better organised if multiple small functions can exist in the same module.
Proposal
File system API
*.fn.ts
Export API
export const config: FnConfig
: A config object describing how the serverless function should be configuredexport const [handlerName]: FnHandler
: All other exports, including the default export, represent a serverless function handlerRuntime compilation
Orchestration compilation
Beta Was this translation helpful? Give feedback.
All reactions