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

Add support for tracing methods asynchronously #86

Merged
merged 10 commits into from
Apr 20, 2018

Conversation

HaloFour
Copy link
Collaborator

@HaloFour HaloFour commented Mar 9, 2018

Adds support for tracing methods that return objects indicating asynchronous completion of a given task. The span itself remains open until the task is completed as determined by an implementation of AsyncTraceService that can support the return type of the method and register for completion. The duration of the method execution itself is logged via a timer on the span.

This PR includes support for Scala's Future[T] trait.

@codecov-io
Copy link

codecov-io commented Mar 9, 2018

Codecov Report

Merging #86 into master will decrease coverage by 0.13%.
The diff coverage is 90.56%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master      #86      +/-   ##
==========================================
- Coverage   90.98%   90.85%   -0.14%     
==========================================
  Files          30       35       +5     
  Lines         599      645      +46     
  Branches       75       76       +1     
==========================================
+ Hits          545      586      +41     
- Misses         54       59       +5
Impacted Files Coverage Δ
...ey/core/async/ScalaFutureNotificationHandler.scala 100% <100%> (ø)
.../src/main/scala/com/comcast/money/core/Money.scala 100% <100%> (ø) ⬆️
.../scala/com/comcast/money/aspectj/TraceAspect.scala 100% <100%> (ø) ⬆️
...a/com/comcast/money/core/async/AsyncNotifier.scala 100% <100%> (ø)
...y/core/async/AsyncNotificationHandlerFactory.scala 100% <100%> (ø)
...cast/money/core/async/DirectExecutionContext.scala 33.33% <33.33%> (ø)
...scala/com/comcast/money/aspectj/FutureAspect.scala 81.25% <81.25%> (ø)
... and 2 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update c5d087d...943197d. Read the comment docs.

@@ -76,6 +79,12 @@ class TraceAspectSpec extends WordSpec
throw new RuntimeException("not-ignored")
}

@Traced(
Copy link
Member

Choose a reason for hiding this comment

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

As opposed to adding an async flag, with aspectj anyway we can add a different joinpoint / pointcut on functions with annotations @Trace that return a promise (I think). Have you tried that? Unsure off the top of my head if that would work

Copy link
Collaborator Author

@HaloFour HaloFour Mar 9, 2018

Choose a reason for hiding this comment

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

That would probably work although might require some effort to avoid ambiguity with existing pointcut. The problem with that is that it locks it to supporting only specific types and there is a fairly wide ecosystem of these types out there since Java didn't add one offering a callback until Java 8. Promise[T] is quite specific to Scala and its uncommon to see in other Java libraries.

Copy link
Member

Choose a reason for hiding this comment

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

Right, I am unclear if it would work. Can we have a test where, if someone sets async to be true, on something that is not async, that everything still works?

Imagine

@Traced(value="sync", async=true)
def plusOne(x: Int): Int = x + 1

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Agreed, the tests are a little anemic right now. This PR is more a spike for the purpose of discussion.

I might need to approach the unit tests a little differently as they should validate that log records aren't written until the promise/future is complete and the current helper methods don't really lend themselves to that, short of waiting for a timeout to expire. I'm thinking of mocking the Tracer instead.

Copy link
Member

Choose a reason for hiding this comment

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

The main test that I would like to try out is nested futures (futures with flatmaps).

When I worked with scala Futures in the past, the onComplete of a future was executed in odd ways.

Imagine you have 3 levels of functions / nested futures via flatmaps and maps. You create child spans at each level, so 3 levels deep in a span tree. The onComplete and context that was passed was "off".

Sorry I don't have more information, I have to check out your PR and try and re-create the issue I saw

Copy link
Member

@pauljamescleary pauljamescleary Apr 19, 2018

Choose a reason for hiding this comment

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

Also, I think your example is odd. Seems like you have a future that yields a future as a value. That doesn't seem like a valid use case. I am curious if some of the contrived tests I wrote are things that people would never write?

Certainly .flatMap { x => Future { x + 1 } } is valid

But Future { Future { x } } is not

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Which example is that? I'm trying to duplicate (or at least reinterpret) the unit tests you had around traced futures. Some of the examples are contrived intentionally but I also wouldn't discount my flubbing things up. I've not had the luxury of dedicating my attention to this recently.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I was playing with going down a path of having TraceAspect replace the existing Span with a wrapper AsyncSpan which internally would use an AtomicInteger as a reference counter. Invoking a terminal method on Future like onComplete would bump that counter up. When that callback would complete it would call stop, which would decrease the counter, as would when the instrumented future completed. Only when the counter reached zero would the span actually be stopped. I don't think that's quite what you were talking about, though.

Copy link
Member

Choose a reason for hiding this comment

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

That’s a great idea!

Copy link
Member

Choose a reason for hiding this comment

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

I was thinking that perhaps my old TraceAspect unit tests had test cases that were invalid.

@@ -24,7 +24,7 @@ object AsyncTracingService {
private lazy val services = loadServices()

def findTracingService(future: AnyRef): Option[AsyncTracingService] =
services.find(_.supports(future))
if (future != null) services.find(_.supports(future)) else None
Copy link
Member

Choose a reason for hiding this comment

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

nitpicky, but...

Option(future).map(f => services.find(_.supports(f))

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

nitpicky back at you, but: 😁

Option(future).flatMap(f => services.find(_.supports(f))

@HaloFour
Copy link
Collaborator Author

I've reworked the asynchronous tracing aspect so that it behaves a bit differently.

Two spans are created instead of one. The first span represents the synchronous execution of the method. The second span, which is a child of the first span, represents the asynchronous operation represents by the future returned from the method. The second span is popped from the stack of spans and is left open after the method returns. When the future is complete, that triggers the second span to be closed based on the result of the future.

That second span is never closed if the following situations happen:

  1. The method returns null.
  2. The method returns something that isn't recognized to be a future
  3. The method throws an exception

The success of the second span depends on the success of the future and respects the same ignored exceptions.

Given that the second asynchronous span is popped from the SpanLocal would there be any negative consequences from never stopping it?

@HaloFour
Copy link
Collaborator Author

I'll also note that I don't particularly care for the name AsyncTracingService, but I haven't thought of a good alternative. I didn't want to waste too much time on it and I'm open to suggestions.


private def traceMethodAsync(joinPoint: ProceedingJoinPoint, traceAnnotation: Traced): AnyRef = {
val key = traceAnnotation.value()
val asyncKey = key + "-async"
Copy link
Member

Choose a reason for hiding this comment

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

why do we append -async to the name?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'm creating two spans. I'm giving the second span which represents the async task the suffix -async. I could change that to do something else, or make it configurable.

Copy link
Member

Choose a reason for hiding this comment

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

Ok, that is where I was confused. I don't understand why we need two separate spans.

We use Money heavily with scala futures, and all tracing is declaritive in the code. We want to give the user the ability to start / stop however we want.

It is a little unclear here with the @Traced annotation as to when the span actually starts. The way we have done this is that the span starts when the method is invoked and completes when the Future (Promise) ends. Something conceptually like this...

def doSomethingTraced(foo: String): Future[String] = {
  val span = startSpan("myName")
  val fut = doSomethingInTheFuture(foo)
  fut.onComplete { case Success(x) => span.stop(true); case Failure(e) => span.stop(false) }
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I see. That is the approach I was taking in the first iteration and I have no problem going back to that model. If I do that then the code above could be reduced to:

@Traced(value = "myName", async = true)
def doSomethingTraced(foo: String): Future[String] =
    doSomethingInTheFuture(foo)

…fter the method is invoked until the future is complete
@HaloFour
Copy link
Collaborator Author

Okie dokie, I've reverted the logic back to the previous implementation, where a single span is created prior to the annotated method being called and that span not being stopped if the method returns a recognized future that is not yet complete. I've updated all of the unit tests to also reflect this change.

@pauljamescleary
Copy link
Member

Thumbing this up. Thanks for the contribution!!!

Let me know if there are any other pending modifications to this, and if I can merge this before your other PR.

@HaloFour
Copy link
Collaborator Author

Great. I am sitting on a couple of additional unit tests but no modification to the current functionality.

I was toying with the idea of switching from using the Java ServiceLoader to instead using the same typesafe Config mechanism that Money uses for handlers and other configuration. I reached for ServiceLoader since I was more familiar with it coming from Jersey and it's frequently used for pluggable connectors like this but I'd want it to fit in with the established paradigm used by Money. Kris didn't have an opinion either way.

@pauljamescleary
Copy link
Member

Good to merge @HaloFour ?


When("the method is invoked")
val promise = Promise[String]()
val future = asyncMethodWithIgnoredException(promise)
Copy link
Collaborator

@ptravers ptravers Mar 22, 2018

Choose a reason for hiding this comment

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

should future maybe be promise. I feel like I'm being lied to

@HaloFour
Copy link
Collaborator Author

Refactored to using typesafe Config to load the AsyncNotificationHandler instances (and changed their name to fit better with SpanHandler). Refactoring some bits to be more idiomatic Scala (with some help from @ptravers who knows Scala much better than I).

Open questions:

  1. The AsyncNotificationHandler returns a value of AnyRef. This is to allow the handler to replace the return value with another compatible return value, just in case we'd want our tracing callback to happen before other registered callbacks. This does complicate the implementation and may not be that important.

  2. I have implemented my own ExecutionContext which invokes the callback on the current thread. While this is generally frowned upon the thought is that the callback to finish tracing should be very fast and it would be a waste of resources and could potentially bloat the span times to put that back onto the global ExecutionContext. I expose the ExecutionContext parameter through the AsyncNotificationHandler trait, which is probably a bit overkill, and I could probably isolate that to just the ScalaFutureNotificationHandler class instead.


object AsyncNotificationHandlerFactory {

def create(config: Config): AsyncNotificationHandler = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

What about object AsyncNotificationHandler and create as apply would that look bad at call site? I guess this way you can just import AsyncNotificationHandlerFactory.create


def add(log: String, message: String) = messages.addBinding(log, message)
def add(log: String, message: String): Unit = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I kind of like it better without the brackets but...

@HaloFour
Copy link
Collaborator Author

I ended up removing the ExecutionContext parameter. All it really did was add a lot of noise and the interface would only really make sense in Scala-specific contexts. I still use the DirectExecutionContext directly within ScalaFutureNotificationHandler to keep the span callback on the same thread.

@HaloFour
Copy link
Collaborator Author

Unless there are further comments regarding implementation or style I believe that this should be ready to merge.

@HaloFour
Copy link
Collaborator Author

Okay, I lied. The complexity of TraceAspect#adviseMethodsWithTracing bothered me so I refactored the async portion out into a helper method with lots of comments and I refactored a bit of the method itself to hopefully be a little more clear.

Now I think I'm good to merge this PR. 😀

@HaloFour
Copy link
Collaborator Author

I've commited the FutureAspect and converted tests. Most of them pass but a few are kind of flaky and I'm still looking into the reason why.

There are three tests which fail regularly and I don't have a good answer for them. They rely on terminal/non-composed methods on Future[T] which create an inherent race condition between their execution and the span being stopped due to the future being completed. To my knowledge there is no prescribed order in which the registered callbacks of a future are executed and those callbacks are subsequently submitted to an execution context which makes the order of operations even more nondeterministic. I'll keep poking at ideas but I have a feeling that such operations wouldn't really compose well into a span.

The methods in question are:

onComplete
onSuccess
onFailure
foreach

@HaloFour
Copy link
Collaborator Author

I wonder if you would be amenable to breaking this into separate PRs. I see two orthogonal concerns here:

  1. Support the tracing of methods that return a value representing a "future" where the span remains open until that "future" is complete.
  2. Support "capturing" and "continuing" the current Span on the callbacks registered to a particular "future" so that whatever they trace can be correlated together.

The former I think is pretty cut and dry.

The latter gets into the weeds of implementation details around each implementation of "future", as we're seeing with scala.concurrent.Future. We'd probably run into the same kinds of problems trying to extend that support to java.util.concurrent.CompletionStage, com.google.common.util.concurrent.ListenableFuture, rx.Observable or any of the other potential libraries that exist out there.

@pauljamescleary
Copy link
Member

I think that cutting this up makes sense.

@HaloFour
Copy link
Collaborator Author

I've reset this branch back to the commit prior to diving into the Future rabbit hole. If a method returns a Future and is annotated with @Traced(value = "whatever", async = true) then the Span will not be closed until after that Future is completed. Additional handlers can be configured to support other future types also, like java.util.concurrency.CompletionStage.

Speaking of, I'd love to make that a module of this project but it looks like Money currently targets Java 6. Is that to retain compatibility with older projects or would it be possible to bump the version up to 8?

@kristomasette kristomasette merged commit 13d9c86 into Comcast:master Apr 20, 2018
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.

5 participants