Skip to content

Track dependency resolution #852

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

Merged
merged 15 commits into from
Jan 16, 2018
Merged

Track dependency resolution #852

merged 15 commits into from
Jan 16, 2018

Conversation

raymondfeng
Copy link
Contributor

@raymondfeng raymondfeng commented Jan 11, 2018

Add ability to track dependency resolution and detect circular dependencies. This is a spin-off from #671.

See #535

Checklist

  • npm test passes on your machine
  • New tests added or existing tests modified to cover all changes
  • Code conforms with the style guide
  • Related API Documentation was updated
  • Affected artifact templates in packages/cli were updated
  • Affected example projects in packages/example-* were updated

@raymondfeng
Copy link
Contributor Author

The CI fails due to #853

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this pull request, it's much easier for me to review this smaller set of changes 👍

The changes in packages/context/src/inject.ts look too cryptic to me, I'll trust you here.

I reviewed the changes up to packages/context/test/unit/resolver.test.ts, I try to finish it ASAP.

I have one concern though: in session, you are storing injections and bindings as two distinctive arrays (stacks). Isn't it possible though to have a path that contains both injections and bindings? E.g. "resolve binding X via ctx.get" -> "resolve binding Y via custom injection" -> "resolve binding Z via "ctx.get". Will we print a correct path in such case? If this is already handled then please point me to the unit test(s) covering this scenario.

resolutionSession,
);
if (isPromise(result)) {
if (result instanceof Promise) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is redundant and should be removed. We have already checked that result is a promise, plus instanceof Promise does not recognize user-land promise libraries like Bluebird.

Copy link
Contributor Author

@raymondfeng raymondfeng Jan 11, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, isPromise() only ensures the result object is PromiseLike, which has then(), but not catch().

@@ -237,7 +253,25 @@ export class Binding {
}
}
if (this._getValue) {
const result = this._getValue(ctx);
const resolutionSession = ResolutionSession.enterBinding(this, session);
let result: ValueOrPromise<BoundValue> = this._getValue(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible for _getValue to throw? Even if not today, can it happen in the future, e.g. because of a TypeError: undefined is not a function or similar?

I think we should wrap this code in a try/catch block and exit the session in the catch block.

const resolutionSession = ResolutionSession.enterBinding(this, session);
try {
  let result: ValueOrPromise<BoundValue> = this._getValue(/*...*/);
} catch (err) {
  resolutionSession.exit();
  throw err;
}

if (isPromise(result)) {
// etc.

I have an idea how to design ResolutionSession in a way that would not require us to call exit at all: instead of using a mutable state in ResolutionSession to keep the list of bindings visited, we should create a new session object for each binding visited instead, with a pointer to the previous (parent) session. That way session.binding becomes a regular property stored in the session, and session.bindings() will be computed by traversing the linked list of all parent sessions. I think this will also remove the need for session.clone(), since each session instance will be immutable. (A better name than "session" may be needed too.)

However, in order to speed up the review process, I am ok to leave that proposed improvement out of scope of this pull request. I am actually happy to implement it myself once this patch gets landed.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment, we abort the resolution session if any sync/async error happens. Try-Catch is not absolutely needed. I don't mind making it consistent.

* Object to keep states for a session to resolve bindings and their
* dependencies within a context
*/
export class ResolutionSession {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please move ResolutionSession to its own file, src/resolver.ts is already too long. The fact that you need two debug loggers (debug and debugSession) is IMO a good indicator supporting my claim.

* A stack of bindings for the current resolution session. It's used to track
* the path of dependency resolution and detect circular dependencies.
*/
readonly bindings: Binding[] = [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having two property names that differ only in the last letter (binding vs. bindings) makes code difficult to read, at least to me, because the distinction is so easy to overlook.

I am proposing more descriptive names. While I prefer both properties to be renamed, I don't mind too much if you decide to rename only one of them.

  • rename bindings to path, stack, parentBindings, or similar
  • rename binding too, for example to currentBinding

The same comment applies to injection vs. injections.

if (injection.resolve) {
// A custom resolve function is provided
return injection.resolve(ctx, injection);
resolved = injection.resolve(ctx, injection, session);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if injection.resolve throws an error (synchronously)? I think this line should be wrapped with a try/catch block to exit the session on errors.

.catch(e => {
session!.exitInjection();
throw e;
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a comment explaining that this is a workaround for missing Promise.prototype.finally? Note that support for this has been landed to TypeScript recently (see microsoft/TypeScript#20511). Unfortunately it hasn't been released yet.

I would consider extracting a helper function to keep the code in resolve() shorter and easier to understand.

return andFinally(resolved, () => session!.exitInjection());

session!.exitInjection();
return r;
})
.catch(e => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should be using a two-callback version of then here. Right now, if exitInjection throws an error, then this catch handler is invoked for that error, and exitInjection is called again.

resolved.then(
  r => { /*... */ },
  err => { /*... */});

);
});

it('will not report circular dependencies if two bindings', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if two bindings do what? I think the test name is missing something?

@raymondfeng raymondfeng force-pushed the track-dependency-resolution branch 4 times, most recently from d4d22e4 to 6035489 Compare January 11, 2018 17:31
@raymondfeng
Copy link
Contributor Author

I have one concern though: in session, you are storing injections and bindings as two distinctive arrays (stacks). Isn't it possible though to have a path that contains both injections and bindings? E.g. "resolve binding X via ctx.get" -> "resolve binding Y via custom injection" -> "resolve binding Z via "ctx.get". Will we print a correct path in such case? If this is already handled then please point me to the unit test(s) covering this scenario.

I had two separate stacks to track bindings and injections. As you pointed out, there is a possibility that some of the binding resolutions are not from injections. I didn't intend to correlate the two stacks.

* Push the injection onto the session
* @param injection Injection
*/
enterInjection(injection: Injection) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we rename this method to something like pushToInjectionStack or something like that? It could be a bit confusing with https://github.com/strongloop/loopback-next/pull/852/files#diff-13e19eacf303fe8ec33cfea0245346d3R56

return session;
}

static describeInjection(injection?: Injection) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we add a return type here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TypeScript will infer the return type. But for clarity of APIs, we can make it explicit.

@b-admike
Copy link
Contributor

I didn't mean to request changes! But I'm still doing another round of reviews soon.

@raymondfeng raymondfeng force-pushed the track-dependency-resolution branch from 601ecbf to 09b362c Compare January 12, 2018 00:05
@bajtos
Copy link
Member

bajtos commented Jan 12, 2018

I had two separate stacks to track bindings and injections. As you pointed out, there is a possibility that some of the binding resolutions are not from injections. I didn't intend to correlate the two stacks.

I am concerned about UX implications of such solution, but I am ok to improve this later, out of scope of this initial pull request. Incremental baby steps FTW ✌️

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 👍

I am little bit confused about the distinction between "binding" and "injection" paths, the two tests look almost identical to me:

expect(bindingPath).to.eql('x --> y --> z');
// vs.
expect(injectionPath).to.eql(
  'XClass.constructor[0] --> YClass.constructor[0] --> ZClass.prototype.myProp',
);

The thing I don't understand is why are there two different formats?

These test cases are executing the following sequences of binding/injection:

  • XClass depends on YClass via bindings, YClass depends on 'Zclass' via bindings, ZClass depends on p via binding getter
  • XClass depends on YClass via bindings, YClass depends on 'Zclass' via bindings, ZClass depends on p via injection

I feel we are missing a test for "via injection" to "via binding" transition - I am thinking about something like this:

class ZClass {
  constructor(
    @inject('p', {}, (c, injection, session) =>  c.get('someComputedKey, session) public p: string) {}
}

@bajtos
Copy link
Member

bajtos commented Jan 12, 2018

@raymondfeng please get @kjdelisle approval before landing this patch too.

Copy link
Contributor

@kjdelisle kjdelisle left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! Just a couple points:

  • Lots of manual concatenation and use of debug's string formatting; we should replace it with the in-line formatting for clarity and consistency
  • A few tests are using try-catch with expect instead of expect.throw; if the try doesn't fail, the test will pass.

@@ -302,6 +327,10 @@ export class Binding {
* ```
*/
toDynamicValue(factoryFn: () => BoundValue | Promise<BoundValue>): this {
/* istanbul ignore if */
if (debug.enabled) {
debug('Bind %s to dynamic value:', this.key, factoryFn);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the infix string formatting instead.

debug(`Bind ${this.key} to dynamic value: ${factoryFn}`);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not always the best to use infix string formatting for a few reasons:

  • Infix string incurs calculation immediately, for example, debug('%s', complexObjectWithExpensiveToString);
  • Infix string only uses toString, which is not ideal for objects

@@ -324,11 +353,16 @@ export class Binding {
* @param provider The value provider to use.
*/
public toProvider<T>(providerClass: Constructor<Provider<T>>): this {
/* istanbul ignore if */
if (debug.enabled) {
debug('Bind %s to provider %s', this.key, providerClass.name);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

try {
await instantiateClass(TestClass, ctx);
} catch (e) {
expect(e.message).to.eql('foo: error');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change this try-catch to be an expect.throw instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, expect.throw cannot allow await.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you considered using Assertion#rejectedWith() instead? I think something like this should work:

await expect(instantiateClass(TestClass, ctx))
  .to.be.rejectedWith('foo: error');

try {
await instantiateClass(TestClass, ctx);
} catch (e) {
expect(e.message).to.eql('bar: error');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

} else {
name = 'property ' + name + '.' + member.toString();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Change all of these to the inline format as well.

@raymondfeng raymondfeng force-pushed the track-dependency-resolution branch from b585686 to 2ca311b Compare January 12, 2018 18:14
@raymondfeng
Copy link
Contributor Author

@bajtos I went ahead to address your last concern. Now we use one stack to track both bindings and injections as resolution elements. ResolutionSession.getResolutionPath() will give us the full path, such as:

x --> @XClass.constructor[0] --> y --> @YClass.constructor[0]' --> z --> @ZClass.prototype.myProp'

Please note injections are prefixed with @.

@raymondfeng raymondfeng force-pushed the track-dependency-resolution branch 4 times, most recently from b6a854c to 502f1f6 Compare January 14, 2018 01:43
session?: ResolutionSession,
) {
// We need to clone the session for the getter as it will be resolved later
session = ResolutionSession.folk(session);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I find the name folk confusing, did you perhaps mean fork?

I was not able to find any dictionary entry for folk as a verb, it's typically used as a noun or adjective, see e.g. https://www.thefreedictionary.com/folk and https://dictionary.cambridge.org/dictionary/english/folk

// Catch the rejected promise to avoid
// (node:60994) UnhandledPromiseRejectionWarning: Unhandled promise
// rejection (rejection id: 1): error
p.catch(e => {});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thank you for fixing this after me! 👍

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for addressing my concerns. I have few more comments, see the one above about folk and the one below about ResolutionElement.

I think you should be also using Assertion#rejectedWith() instead of try-await-catch, something along the following lines:

await expect(instantiateClass(TestClass, ctx))
  .to.be.rejectedWith('foo: error');

get currentInjection(): Injection | undefined {
for (let i = this.stack.length - 1; i >= 0; i--) {
if (this.stack[i].type === 'injection')
return <Injection>this.stack[i].value;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The necessity for explicit type cast shows a problem in the design of ResolutionElement, because it allows invalid combinations like {type: 'binding', value: someInjection}.

Have you considered using a Tagged Union Types to more accurately describe the allowed values and get better type checking from TypeScript?

export interface BindingResolution {
  type: 'binding'; 
  value: Binding; 
};

export interface InjectionResolution {
  type: 'injection'; 
  value: Injection; 
};

export type ResolutionElement = BindingResolution | InjectionResolution;

See https://blog.mariusschulz.com/2016/11/03/typescript-2-0-tagged-union-types and https://www.typescriptlang.org/docs/handbook/advanced-types.html#discriminated-unions

@raymondfeng raymondfeng force-pushed the track-dependency-resolution branch from 502f1f6 to 36d63af Compare January 15, 2018 17:09
@raymondfeng raymondfeng force-pushed the track-dependency-resolution branch from 36d63af to 156a4b6 Compare January 15, 2018 17:10
@raymondfeng
Copy link
Contributor Author

@bajtos Comments addressed. PTAL.

Copy link
Member

@bajtos bajtos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found two places where we may be able to get rid of explicit type casts, see below. I am not sure if that's possible though.

Other than that, the patch LGTM.

return this.bindings.map(b => b.key).join(' --> ');
return this.stack
.filter(i => i.type === 'binding')
.map(b => (<Binding>b.value).key)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this type cast still required?

.filter(i => i.type === 'injection')
.map(
i =>
ResolutionSession.describeInjection(<Injection>i.value)!.targetName,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this type cast still required?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. TS is not smart enough to understand the array filtering criteria.

@raymondfeng raymondfeng merged commit 25a9e91 into master Jan 16, 2018
@bajtos bajtos mentioned this pull request Jan 18, 2018
3 tasks
@raymondfeng raymondfeng deleted the track-dependency-resolution branch February 1, 2018 00:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants