[POC] Outputless Pulumi and Async Components #16019
Draft
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
This PR implements two orthogonal features and proves that they compose in the Node SDK and helps evaluate trade-offs in developer experience.
User code examples:
Outputless Pulumi
Adds a method on
Output<T>
to await the value as aPromise<ResolvedOutput<T>>
. The resolved promise value is a subclass ofOutput<T>
but it provides a type safe.value
property that can be inspected and used in iteration and conditional logic safely while tracking dependencies.Dependencies are tracked by global context. Using async components, described next, or a global
forkPulumiContext
function allows isolating these dependency trees.Summary
Good:
Bad:
.value
after checking if the value is known.apply()
on a ResolvedOutput returns an Output again, which necessitatesawait (...).asPromise()
.Ugly:
dependsOn
, or this behavior of implicit dependencies could cause cascading resource deletes very easily.Async Components
This adds two new mechanisms for defining components to understand the ergonomics of each. Both of these mechanisms use a
ComponentResource
underneath, and use async hooks to precisely track any asynchronous tasks spawned in the constructors. This allows both components to safely use asynchrony - including Outputless asynchrony - in spawned promises, emitters, intervals, and other forms of Node.js concurrency. Both of these implementations offer the same set of features:namingConvention
component resource option. By default the logical names of all child resources are prefixed.registerOutputs
on completion of child resources.Not implemented, but made possible by this implementation, we could precisely track inputs and outputs of the components in stack files. This would provide greatly increased visibility in Pulumi Cloud via Resource Search / Insights.
The two implementations are very similar, differing mostly in the developer experience and not in implementation:
@Component
is a class decorator, using TC-39 stage-3 compatible decorators. The user implements a class (which should not extendpulumi.ComponentResource
) and applies the decorator. The decorator creates an anonymous subclass of the user declared class, wrapping the constructor to run with the correct async context tracking, modifies theopts
, and then instantiates its parent class.FunctionalComponent
is a wrapper for declaring a component as a plain function. It takes a callback which resembles a resource constructor's arguments (name, inputs, opts), and provides all of the same facilities as the decorator.Summary
Good
Bad
Ugly:
While the Component seems more familiar, the proof of concept evaluation found two major downsides:
First, there were significant challenges in making
pulumi up
work. The class decorator syntax requires TypeScript 5.x, and the in-box ts-node andtsconfig
for Pulumi projects is very far from supporting@
syntax. Ordinarily, as when writing Pulumi projects with ESModule support, we would use a--loader
. However the ESBuild and thetsx
loader does not yet support decorator transpilation (evanw/esbuild#104). As a result, this only worked by installing a side-by-side version of TypeScript 5.x and down-transpiling to CommonJS and ES2022 syntax.Second, constructors in TypeScript must be synchronous. While these wrapped constructors do precisely track any tasks spawned during the constructor call, it's very awkward to do asynchronous work inside a constructor as it necessitates spawning a promise, and though
registerOutputs
is called at the correct time, the constructor must synchronously prepare all properties on the class, for example by creating promise resolvers and passing those to any spawned tasks.