-
Notifications
You must be signed in to change notification settings - Fork 56
Improve the experience of getting the Durable client in the new prog model #414
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
Comments
I think the main alternative that was missed was the option that mimics the old programming model. So something like this: const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext) => {
const client = df.getClient(context);
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
app.http('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
extraInputs: [df.input.durableClient()],
handler: httpStart,
}); The only place the user has to mention the client binding is when setting up the function (aka "function.json" in the old model and "extraInputs:" in the new model). Then when getting the client, the user doesn't have to pass in the binding config because under the covers we would search through the bindings for them. Is it an elegant, cool new way of doing things? No, but it might be the easiest way to avoid a "downgrade" |
I'd like to propose two other options. Keep in mind I am not very familiar with JS, so I cannot offer ways to implement this, or I am not aware if this goes against idiomatic JS.
const clientInput = df.input.durableClient();
const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext) => {
const client = context.getInput(clientInput); // Can we add or use an existing extensibility point in the Functions JS SDK so that the DF SDK can hook in here and handle converting to the client type.
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
app.http('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
extraInputs: [clientInput],
handler: httpStart,
});
The goals of this one is to keep the same const clientInput = df.input.durableClient();
const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext, client: DurableClient) => { // not sure what the exact DurableClient type is.
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
app.http('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
extraInputs: [clientInput],
handler: httpStart,
}); I would want to avoid using a const clientInput = df.input.durableClient();
const storageInput = // get storage input
const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext, client: DurableClient, storage: StorageClient) => { // not sure what the exact DurableClient or StorageClient type is.
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
app.http('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
extraInputs: [clientInput, storageInput],
handler: httpStart,
}); I am aware above would need a bunch of work around getting the types right, to allow |
In response to @jviau, specifically option 2. Here's the line that I interpret as the main difference: const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext, client: DurableClient) => { You already alluded to it, but the main difficulty is getting the types right. We know when a user calls const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext, ...args: unknown[]) => { I really don't like the On the flip side, we are able to provide some intellisense by making users get/set extra stuff on the context object. Details here. You might also notice that way more extra output types are supported than extra input types - that's because functions itself doesn't support many extra inputs (just one more sign that durable is a bit unique here). I believe the main difference between .NET and Node.js is that .NET has decorators that are used for extra inputs and can impact the handler's signature and enforce what the extra input types are. Node.js historically has not had decorators - there are a few implementations in various stages of preview, but they are not production-ready let alone idiomatic. We have an issue here to explore in the future. |
I want to share another proposal here by @cgillum:
I would also add that this might go well with Chris's other proposal too to follow Durable's example when registering functions too:
I think this would essentially flip the hierarchy of namespaces from import { sb, storage, http, HttpResponse } from `@azure/functions`
const blobInput = storage.input.blob(/* blob options */);
const queueOutput = storage.output.queue(/* queue options */);
const httpOutput = http.output(/* HTTP options */);
sb.app.topic({
// SB options
return: httpOutput,
extraInputs: [blobInput],
extraOutputs: [queueOutput],
handler: async function (message: unknown, context: InvocationContext): Promise<HttpResponse> {
// read blob input
const blobValue = storage.blob.getBlob(context, blobInput);
// set queue output
storage.queue.setMessage(context, queueOutput, `Hello, ${message}`);
// return value mapped to http output
return new HttpResponse({ response: 200 });
}
}); While the durable example would remain the same, at least the rest of triggers/bindings would follow a similar pattern, so Durable is less of the odd one out. I don't mind making this change at all, and if anything, I feel like it might even be more intuitive to understand on its own, regardless of the durable question. |
One more suggestion I want to throw on here: what if we add a method in the durable SDK to "transform" any method from import * as df from "durable-functions"
import { app } from "@azure/functions"
// register an HTTP-triggered client
const registerHttpClient = df.app.clientify(app.http) // haven't come up with a better name for this utility
const httpClientHandler: DurableClientHandler = async (request: HttpRequest, client: DurableClient, context: InvocationContext) => {
// client function content, using client directly
};
registerHttpClient('httpClient', {
handler: httpClientHandler
});
// register a blob-triggered client
const registerBlobClient = df.app.clientify(app.storageBlob);
const blobClientHandler: DurableClientHandler = async (msg: unknown, client: DurableClient, context: InvocationContext) => {
// client function content
};
registerBlobClient('blobClient', {
path: "blobs/file.txt",
connection: "someConnString",
handler: blobClientHandler
}); I'm not 100% sure if this can work while having the resulting function maintain the same type richness and intellisense of the original. I tried to hack together something, and I could get it to mostly work (it wasn't able to get the type of the |
I think to clarify @cgillum's point offline regarding On a side note, I did find that |
Interesting suggestion, @hossam-nasr. That Taking a step back, it doesn't seem to me like we fully explored @jviau's first suggestion. This one: "Keep it consistent with how you get input for other extensions:" const clientInput = df.input.durableClient();
const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext) => {
const client = context.getInput(clientInput); // Can we add or use an existing extensibility point in the Functions JS SDK so that the DF SDK can hook in here and handle converting to the client type.
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
app.http('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
extraInputs: [clientInput],
handler: httpStart,
}); @hossam-nasr / @ejizba: What prevents us from going this route? Is it simply that the resulting |
Implementation wise for my suggestion - could the returned interface InputConverter<T> {
getInput(context: InvocationContext): T
}
interface InvocationContext { // probably not an interface
getInput<T>(converter: InputConverter<T>): T
}
const clientInput = df.input.durableClient(); // this returns an InputConverter<DurableClient> now. |
@jviau Responding to your earlier comment:
I get the point about consistency, but I personally favor this way of doing it @davidmrdavid To your question:
More or less, yes. It is because the result of
It took me a while to get what you're saying, but I think I get it now, and yes, it would be possible. I see two main drawbacks of this though:
const myRandomInput = {
type: "someRandomType",
name: "someRandomName",
someKey: "someValue"
};
app.http('httpFunction', {
extraInputs: [myRandomInput],
handler: (request: HttpRequest, context: InvocationContext) => {
myRandomInputValue = context.extraInputs.get(myRandomInput);
},
}); Admittedly, this isn't the worst drawback in the world, but the above example would've been the most useful for easier migration from old model to the new. What I do like about this suggestion though is that it's a relatively easy* extensibility model to implement, and future-proofs us in case other SDKs/input types want to implement some sort of manipulation of the input themselves. Not sure how likely that is though. * I'm not sure if there might be complications I'm not foreseeing for actually implementing this. |
I appreciate the feedback and discussion around I also want to push back on the idea that Tbh, I'm getting a bit lost with all the suggestions in this thread. Could we maybe narrow the scope back down and fork unrelated discussions to separate issues? |
@ejizba understood, we can start a different discussion regarding @hossam-nasr - can we support both modes? Make |
Thank you @ejizba for narrowing our scope and getting us more focused. For separate discussions, I've added the Now, focusing on the client question, I've tried narrowing down the proposed solutions, filtering out the ones that require major structural changes to the core node library APIs, since as @ejizba pointed out, those have been informed by user studies and feedback from node.js partners, so probably won't change. Option 1: Essentially the equivalent of the old modelThis suggestion matches, but not necessary improves upon, the way the old model worked. The const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext) => {
const client = df.getClient(context);
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
app.http('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
extraInputs: [df.input.durableClient()],
handler: httpStart,
}); Frankly, the only reason this wasn't the first default implementation we went with was because, unlike the old model, there wasn't a way to retrieve all input bindings from the Based on this, let's make this option our new default baseline that we're comparing against, and we can decide if this experience is good enough already, or if we want to continue improving on it with one of the remaining options. Option 2: Retrieve the client the same way as any other inputconst clientInput = df.input.durableClient();
const httpStart: HttpHandler = async (request: HttpRequest, context: InvocationContext) => {
const client: DurableClient = context.extraInputs.get(clientInput);
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
app.http('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
extraInputs: [clientInput],
handler: httpStart,
}); There could be many ways to get this to work, but I think the clearest would be @jviau's suggestion of having Pros:
Cons:
Option 3: Provide trigger-specific APIs to register client functionsThis options treats client inputs as more of an exception than other input binding types. Given that Durable client inputs are somewhat special (they are required, not optional, and there can be only exactly one), I would argue treating as special is justified. const clientHandler: DurableClientHandler = async (context: InvocationContext, request: HttpRequest, client: DurableClient) => {
const instanceId = await client.startNew(request.query.get('orchestratorName'), undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
df.app.httpClient('durableOrchestrationStart1', {
route: 'orchestrators/{orchestratorName}',
handler: httpStart,
}); We obviously can't do this for all trigger types. There was a query that Eric shared at some point that showed ~40% of durable client trigger types were HTTP, and ~26% were timer trigger. Given that, I would suggest we only do this for HTTP and timer triggers, covering ~66% of scenarios, while the rest of the triggers can be covered by the "unhappy" scenario. Pros:
Cons:
Option 4: Generalizable version of option 3Instead of doing it for one trigger type, we generalize it: const clientHandler: DurableClientHandler = async (context: InvocationContext, request: HttpRequest, client: DurableClient) => {
const instanceId = await client.startNew(request.params.orchestratorName, undefined, request.text());
context.log(`Started orchestration with ID = '${instanceId}'.`);
return client.createCheckStatusResponse(request, instanceId);
};
df.app.client('durableOrchestrationStart1', {
trigger: trigger.http({ route: 'orchestrators/{orchestratorName}' }),
return: output.http({}),
handler: clientHandler,
}); Pros:
Cons:
Note that option 3 and option 4 can co-exist. We could have option 3 for HTTP and timer triggers to cover most scenarios, and option 4 for other trigger types. Option 5: A "
|
Thanks @hossam-nasr. I agree we should stick with option 1 (already merged agaik) for now and revisit further improvements later. There's some good ideas here that will take time to fully evaluate. |
Uh oh!
There was an error while loading. Please reload this page.
This issue is based off discussions here, here, and here.
In the current alpha release of the SDK (
3.0.0-alpha.1
) to support durable functions in the new programming model, this is the best experience for a user to register an HTTP-triggered durable client starter function:As seen above, this requires the user to explicitly declare a durable client input binding, specify it in the
extraInputs
parameter when registering the function, and pass it to thedf.getClient()
method. This is clearly not an ideal user experience, and arguably a downgrade from the old model experience.One suggestion/alternative would be to have the SDK provide an API that registers a "durable client handler," which takes in 3 arguments instead of the usual two (the trigger input, the context, and the durable client), while the SDK handles the input binding behind the scenes and provides the durable client directly to the handler. Such user code could look like this:
However, per the discussion here, this "appears to come at a tradeoff with the trigger (you lose all the intellisense and defaults of doing
app.http
)".Another alternative would be to provide some equivalent to
app.http()
, specifically for http-triggered durable client functions. As an example:However, since client functions could be triggered by any trigger, it begs the question of should we provide similar APIs for every possible trigger, which would be a lot of burden on the SDK.
This issue is to discuss alternatives to build upon or improve the current experience.
The text was updated successfully, but these errors were encountered: