Skip to content
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

[POC] Outputless Pulumi and Async Components #16019

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
136 changes: 107 additions & 29 deletions sdk/nodejs/output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

import { Resource } from "./resource";
import { registerOutputlessDependencies } from "./runtime/outputless";
import * as settings from "./runtime/settings";
import * as utils from "./utils";

Expand Down Expand Up @@ -166,6 +167,7 @@ class OutputImpl<T> implements OutputInstance<T> {
isKnown: Promise<boolean>,
isSecret: Promise<boolean>,
allResources: Promise<Set<Resource> | Resource[] | Resource> | undefined,
proxy: boolean = true
) {
// Always create a copy so that no one accidentally modifies our Resource list.
const resourcesCopy = copyResources(resources);
Expand Down Expand Up @@ -225,6 +227,10 @@ See https://www.pulumi.com/docs/concepts/inputs-outputs for more details.`;
return message;
};

if (!proxy) {
return;
}

return new Proxy(this, {
get: (obj, prop: keyof T) => {
// Recreate the prototype walk to ensure we find any actual members defined directly
Expand Down Expand Up @@ -325,6 +331,86 @@ To manipulate the value of this Output, use '.apply' instead.`);
);
return <Output<U>>(<any>result);
}

public async asPromise(): Promise<ResolvedOutput<T>> {
const [value, known, secret, dependencies] = await Promise.all([
this.promise(/*withUnknowns*/ true),
this.isKnown,
this.isSecret,
this.allResources!(),
]);

const urns: string[] = [];
for (const res of dependencies) {
const urn = await res.urn.promise();
if (urn) {
urns.push(urn);
}
}

registerOutputlessDependencies(urns);

return new ResolvedOutputImpl(value, known, secret, dependencies);
}
}

export interface ResolvedOutput<T, _T = unknown> extends OutputInstance<T> {
/**
* The value of the output. This is only available after the output has been resolved.
*/
readonly value: _T;
/**
* Whether or not the value is known. Will be true unless T is `unknown`.
*/
known(): this is ResolvedOutput<T, T>;
/**
* Whether or not this output wraps a secret value.
*/
readonly secret: boolean;
/**
* The list of resources that this output value depends on.
*/
readonly dependencies: Readonly<Resource[]>;
}

class ResolvedOutputImpl<T, _T = unknown> extends OutputImpl<T> implements ResolvedOutput<T, _T> {
/**
* A private field to help with RTTI that works in SxS scenarios.
*
* This is internal instead of being truly private, to support mixins and our serialization model.
* @internal
*/
// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
public __pulumiResolvedOutput: boolean = true;

value: _T;
#known: boolean;
dependencies: Resource[];
secret: boolean;
known(): this is ResolvedOutput<T, T> {
return this.#known;
}

/**
* internal
*/
constructor(value: T, known: boolean, secret: boolean, dependencies: Set<Resource>) {
super([], Promise.resolve(value), Promise.resolve(known), Promise.resolve(secret), Promise.resolve(dependencies), false);
this.value = value as any;
this.#known = known;
this.dependencies = [...dependencies];
this.secret = secret;

return this;
}

public toString = () => {
return `${this.value}`;
};

public toJSON = () => {
return this.value;
};
}

/** @internal */
Expand Down Expand Up @@ -696,44 +782,17 @@ function getResourcesAndDetails(
return [syncResources, isKnown, isSecret, allResources];
}

/**
* Unknown represents a value that is unknown. These values correspond to unknown property values received from the
* Pulumi engine as part of the result of a resource registration (see runtime/rpc.ts). User code is not typically
* exposed to these values: any Output<> that contains an Unknown will itself be unknown, so any user callbacks
* passed to `apply` will not be run. Internal callers of `apply` can request that they are run even with unknown
* values; the output proxy takes advantage of this to allow proxied property accesses to return known values even
* if other properties of the containing object are unknown.
*/
class Unknown {
/**
* A private field to help with RTTI that works in SxS scenarios.
*
* This is internal instead of being truly private, to support mixins and our serialization model.
* @internal
*/
// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
public readonly __pulumiUnknown: boolean = true;

/**
* Returns true if the given object is an instance of Unknown. This is designed to work even when
* multiple copies of the Pulumi SDK have been loaded into the same process.
*/
public static isInstance(obj: any): obj is Unknown {
return utils.isInstance<Unknown>(obj, "__pulumiUnknown");
}
}

/**
* unknown is the singleton unknown value.
* @internal
*/
export const unknown = new Unknown();
export const unknown: ResolvedOutput<any> = new ResolvedOutputImpl("unknown" as any, false, false, new Set());

/**
* isUnknown returns true if the given value is unknown.
*/
export function isUnknown(val: any): boolean {
return Unknown.isInstance(val);
return utils.isInstance<ResolvedOutputImpl<any>>(val, "__pulumiResolvedOutput") && val.known() !== true;
}

/**
Expand Down Expand Up @@ -888,6 +947,25 @@ export interface OutputInstance<T> {
* the dependency graph to be changed.
*/
get(): T;

/**
* Returns this output value as a promise.
*
* Callers are encouraged to check the "known" and "secret" properties of the return value to
* safely handle previews and secrets.
*
* All future resource operations will depend on this output, which may lead longer `pulumi
* destroy` times.
*
* Due to the behavior of resource replacements, this may cause unexpected resource deletions
* during an update. If the source output value is derived from a resource with
* delete-before-replace behavior, the deletion of the resource will "condemn" subsequent
* resources to deletion and recreation.
*
* TODO:
* - Address delete-before-replace resource condemnation.
*/
asPromise(): Promise<ResolvedOutput<T>>;
}

/**
Expand Down
46 changes: 45 additions & 1 deletion sdk/nodejs/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { unknownValue } from "./runtime/rpc";
import { getProject, getStack } from "./runtime/settings";
import { getStackResource } from "./runtime/state";
import * as utils from "./utils";
import { getAsyncComponentCompletion, getAsyncComponentParent } from "./runtime/component-v2/context";

export type ID = string; // a provider-assigned ID.
export type URN = string; // an automatically generated logical URN, used to stably identify resources.
Expand Down Expand Up @@ -387,13 +388,25 @@ export abstract class Resource {
if (!t) {
throw new ResourceError("Missing resource type argument", opts.parent);
}

const parent = opts.parent ?? getAsyncComponentParent() ?? getStackResource();
if (ComponentResource.isInstance(parent) && parent.__namingConvention) {
name = parent.__namingConvention({
parent: { type: parent.__pulumiType, name: parent.__name! },
child: {
type: t,
name,
inputs: props,
},
});
}

if (!name) {
throw new ResourceError("Missing resource name argument (for URN creation)", opts.parent);
}

// Before anything else - if there are transformations registered, invoke them in order to transform the properties and
// options assigned to this resource.
const parent = opts.parent || getStackResource();
this.__transformations = [...(opts.transformations || []), ...(parent?.__transformations || [])];
for (const transformation of this.__transformations) {
const tres = transformation({ resource: this, type: t, name, props, opts });
Expand Down Expand Up @@ -907,6 +920,8 @@ export interface ComponentResourceOptions extends ResourceOptions {

// !!! IMPORTANT !!! If you add a new field to this type, make sure to add test that verifies
// that mergeOptions works properly for it.

namingConvention?: ComponentNamingTransform;
}

/**
Expand Down Expand Up @@ -1012,6 +1027,20 @@ export abstract class ProviderResource extends CustomResource {
}
}

type NamingTransformArgs = {
parent: {
type: string;
name: string;
};
child: {
type: string;
name: string;
inputs: any;
};
};

export type ComponentNamingTransform = (args: NamingTransformArgs) => string;

/**
* ComponentResource is a resource that aggregates one or more other child resources into a higher
* level abstraction. The component resource itself is a resource, but does not require custom CRUD
Expand All @@ -1037,6 +1066,14 @@ export class ComponentResource<TData = any> extends Resource {
// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
public readonly __remote: boolean;

/** @internal */
// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
public readonly __asyncCompletion: Promise<void> | undefined;

/** @internal */
// eslint-disable-next-line @typescript-eslint/naming-convention,no-underscore-dangle,id-blacklist,id-match
public readonly __namingConvention: ComponentNamingTransform | undefined;

/**
* Returns true if the given object is an instance of CustomResource. This is designed to work even when
* multiple copies of the Pulumi SDK have been loaded into the same process.
Expand Down Expand Up @@ -1077,10 +1114,17 @@ export class ComponentResource<TData = any> extends Resource {
this.__remote = remote;
this.__registered = remote || !!opts?.urn;
this.__data = remote || opts?.urn ? Promise.resolve(<TData>{}) : this.initializeAndRegisterOutputs(args);
this.__asyncCompletion = getAsyncComponentCompletion();
if (opts.namingConvention) {
this.__namingConvention = opts.namingConvention;
}
}

/** @internal */
private async initializeAndRegisterOutputs(args: Inputs) {
if (this.__asyncCompletion) {
return undefined as any;
}
const data = await this.initialize(args);
this.registerOutputs();
return data;
Expand Down
114 changes: 114 additions & 0 deletions sdk/nodejs/runtime/component-v2/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright 2016-2024, Pulumi Corporation.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import { AsyncHook, AsyncLocalStorage, createHook, executionAsyncId } from "async_hooks";
import type { ComponentResource } from "../..";
import { PromiseResolvers, promiseWithResolvers } from "./promiseWithResolvers";

const asyncComponentContext = new AsyncLocalStorage<AsyncComponentContext>();


let instanceId = 0;
/**
* @internal
*/
export class AsyncComponentContext {
#instanceId = instanceId++;
#asyncIds: Set<number> = new Set();
#resolved: boolean = false;
#asyncCompletion: PromiseResolvers<void> = promiseWithResolvers();
#componentResource: ComponentResource | undefined;

get asyncCompletion() {
return this.#asyncCompletion.promise;
}

get registeredParent() {
return this.#componentResource;
}

constructor(parent: ComponentResource) {
this.#componentResource = parent;
asyncHook.enable();
}

run<R>(fn: (...args: any[]) => R, ...args: any[]): R {

Check failure on line 46 in sdk/nodejs/runtime/component-v2/context.ts

View workflow job for this annotation

GitHub Actions / CI / Lint / Lint SDKs

'args' is already declared in the upper scope
return asyncComponentContext.run(this, fn, ...args);
}

registerAsyncId(asyncId: number, type: any, triggerAsyncId: number) {
// n.b.: logging in this method can cause a stack overflow, as it is called for every async
// operation, including writing to stdout.
this.#asyncIds.add(asyncId);
}

unregisterAsyncId(asyncId: number) {
// console.log(`${this.#instanceId} - Leaving async context ${asyncId}`);
this.#asyncIds.delete(asyncId);
if (this.#asyncIds.size !== 0) {
return;
}
if (this.#resolved) {
return;
}

this.#resolved = true;
this.#asyncCompletion.resolve();
}
}

/**
* @internal
*/

export function getAsyncComponentParent(): ComponentResource | undefined {
return asyncComponentContext.getStore()?.registeredParent;
}
/**
* @internal
*/

export function getAsyncComponentCompletion(): Promise<void> | undefined {
return asyncComponentContext.getStore()?.asyncCompletion;
}

/**
* Async hook that tracks all spawned async contexts. In a wrapped async component context, if any
* task is spawned by Node.js - promises, emitters, etc., - it will be tracked here.
*
* When all async tasks are completed, the children of the component are resolved.
*
* @internal
*/
export const asyncHook: AsyncHook = createHook({
init(asyncId, type, triggerAsyncId, resource) {
const context = asyncComponentContext.getStore();
if (context) {
context.registerAsyncId(asyncId, type, triggerAsyncId);
}

},
destroy(asyncId) {
const context = asyncComponentContext.getStore();
if (context) {
context.unregisterAsyncId(asyncId);
}
},
promiseResolve(asyncId) {
const context = asyncComponentContext.getStore();
if (context) {
context.unregisterAsyncId(asyncId);
}
},
});