Durable Promises
What is a Durable Promise?
Durable promises are like regular promises but they persist in storage. They have a unique identity that lives beyond the execution of the underlying function execution. By doing persisting promises, if something goes wrong (like a power outage or network hiccup), the application can look back at the saved results, quickly skip to where it left off, and keep going as if nothing ever happened, making your application resilient to unexpected failures.
This also enables functions in different processes to call and await on each other. See Resonate Call Graphs for learn more about how Resonate facilitates the execution of distributed functions in a single business process.
Storage
Where are Durable Promises stored?
There are two places where a Durable Promise can be stored, locally or remotely.
Local storage
“Local storage, sometimes called "Local mode", stores Durable Promises in local memory. There are multiple ways to visualize this dynamic.
The following is a component diagram of an Application Node running in local mode:”
The Application Node's local memory stores all Durable Promises.
You can visualize this dynamic in another way through a sequence diagram:
In the diagram above, promise 1 corresponding to the invocation of function 1 is stored in the local memory where the process is hosted.
Local mode still provides function-level recovery. In other words, if a called function fails (throws an error or rejects a promise) in local mode, it is retried within the bounds of the timeout budget.
The following psuedocode example uses local storage.
The main function (f1()
) synchronously awaits on two other functions (f2()
and f3()
).
fn f1(ctx: Context) {
await ctx.run(f2);
await ctx.run(f3);
return;
}
fn f2() {
// ...
return;
}
fn f3() {
// ...
return;
}
await resonate.run("f1", `your-proimise-id`);
This sequence graph could be visualized like the following:
The next level of detail could include the promises, which might look like this:
Notice that this sequence graphs above do not include the Resonate Server (supervisor).
If all the functions are meant to execute locally, within the same process, an Application Node may chose to rely soley on a local promise store.
However, if you want f1()
, f2()
, or f3()
to be resumable even if the process crashes, or the Application Node functions need to await on functions in other Application Nodes, then use Remote mode.
When to use
Local storage is ideal for starting out with Resonate. It enables you to integrate Distributed Async Await into a single Application Node without the overhead of running the Resonate Server.
It also serves use cases where there is a requirement of very low latency on single Application Nodes that do not need to make any Remote Function Calls and are not concerned with the Application Node restarting the execution from scratch should it crash, but you still want individual functions to be transparently retried if they fail.
Part 1 of the quickstart tutorials showcases this functionality:
- Python SDK quickstart - Part 1 Incremental adoption
- TypeScript SDK quickstart - Part 1 Incremental adoption
Remote storage
Remote storage, sometimes called "Remote mode", stores all Durable Promises in the Resonate Server (the supervisor service). Again, there are multiple ways to visualize this dynamic.
The following is a component diagram of an Application Node running in remote mode:
By default, the Resonate Server stores all Durable Promises in a database. Currently, to view all the promises stored in the Resonate Server, you can use the Resonate CLI.
durable=false
Depending on the requirements of the application, a function invocation (LFI) can choose to pass durable=false
, bypassing the creation and storage of a Durable Promise.
This is useful when the result of the function is not needed for recovery or when the function is idempotent and can be retried without side effects and extremely low latency is required.
You can visualize this dynamic in another way through a sequence diagram:
In the diagram above, promise 1 corresponding to the invocation of function 1 is stored in the Resonate Server.
Consider the same psuedocode example as above:
fn f1(ctx: Context) {
await ctx.run(f2);
await ctx.run(f3);
return;
}
fn f2() {
// ...
return;
}
fn f3() {
// ...
return;
}
await resonate.run("f1", `your-proimise-id`);
With Remote storage, the sequence would look like the following:
This sequence shows what happens "under the hood" when a Resonate Server (supervisor) is used.
Using Remote storage enables two things:
- It enables the "distribution" aspect of Distributed Async Await.
- It enables platform-level recovery.
In regards to platform-level recovery, for example when your (Application Node) crashes, Remote storage can ensure that when your process comes back up, any "in-progress" executions continue from where they left off. It also enables the Call Graph to recover on a completely different Application Node.
Part 2 of the quickstart tutorials showcases this functionality:
- Python SDK quickstart - Part 2 Durable Execution
- TypeScript SDK quickstart - Part 2 Durable Execution
Now let's look at the distribution aspect that is enabled by Remote storage.
The remote use case involves a Durable Promise that is created by one process and completed by another distinct process. The primary purpose is to facilitate coordination between different processes or services, serving as the foundation for features like:
-
Task framework: Durable promises allow you to distribute tasks across multiple machines for parallel execution and collect the operations’ results.
-
Notifications: When a durable promise is created or completed, it can trigger notifications to other processes or services that are interested in the result, enabling efficient communication and coordination.
-
Human in the Loop: Durable promises can seamlessly integrate human input into your automated workflows, allowing for manual intervention or approval steps when needed.
Identity
Each Durable Promise must have a unique identifier (ID) that distinguishes it from other promises.
This ID is used to map to a function invocation and store final result of the execution.
// UID uniquely identifies the purchase.
const uid = `purchase/user/${user.id}/song/${song.id}`;
// Run the registered 'purchase' function with the above uid and the following function arguments.
const val = await resonate.run("purchase", uid, user, song);
When designing the naming scheme for your durable promise IDs, keep the following considerations in mind:
- Uniqueness: The naming scheme should guarantee uniqueness to avoid conflicts between executions.
- Readability: Choose a naming scheme that is easy to understand and interpret, making it easier to debug and manage executions.
- Relevance: Incorporate relevant information into the naming scheme, such as the purpose or context of the execution.
If an ID is accidentally reused for a different execution, it will result in retrieving the stored result of the previous execution instead of starting a new one. This behavior differs from regular executions and can lead to confusion if not handled properly.
Common ID naming schemes
There are several approaches to ensure your Durable Promise IDs are unique but also readable and relevant.
Date-based
One very common approach is to use the date as part of the naming scheme to. For example, if you have a durable promise that fetches and aggregates news articles on a daily basis, you could include the date in the ID format to ensure uniqueness and provide clear indication of when the execution occurred.
news_feed_YYYY-MM-DD
Hierarchical
You can use a hierarchical naming scheme similar to file system paths to represent the identity of a durable promise. The naming scheme can include information such as the environment, service, and specific execution details. For example:
staging/analytics/monthly-report/2023-05
Platform-specific
If your durable promises are running on a specific platform or orchestrator, you can incorporate the platform's identity concepts into the naming scheme. For example, if you are using Kubernetes, you can include the namespace, pod, and other relevant information:
k8s/staging/namespace/analytics/gpu/h100/monthly-report-2023-05
Opaque with metadata
In this case, the durable promise ID is a randomly generated unique identifier, and you would store the associated metadata (such as environment, service, execution details) in a separate database that can be queried using the ID.
executions/a7b89c3d-f012-4e78-9a7d-89a3f6b2e1c7
Resolution Timeout
Functions await on other functions through Durable Promises. In Resonate, a timeout is associated with the Durable Promise resolution, not individual function executions.
Resonate attempts to resolve a Durable Promises until the specified timeout is reached. If the timeout is reached, Resonate marks the Durable Promise as failed.
See the following feature guidance for more information on how to set a Timeout: