Skip to content

Apache Spark instrumentation#4997

Merged
paul-laffon-dd merged 13 commits into
masterfrom
paul.laffon/spark-instrumentation
Apr 24, 2023
Merged

Apache Spark instrumentation#4997
paul-laffon-dd merged 13 commits into
masterfrom
paul.laffon/spark-instrumentation

Conversation

@paul-laffon-dd

@paul-laffon-dd paul-laffon-dd commented Mar 30, 2023

Copy link
Copy Markdown
Contributor

What Does This Do

Instrument apache spark applications by using the spark listener API to get notified when a spark job / stage / task started and finished. It is done using an entry advice on the method setupAndStartListenerBus to inject our custom listener that spark will call for every event

Tracing is done a bit differently than on regular instrumentations since everything can be done from the SparkListener interface and mostly managed by spark itself

  • This listener runs inside the spark driver and is called by the same thread
  • If an exception is raised, it will be caught be spark and not impact the rest of the spark application
  • If the driver stop/restart, the whole spark execution is relaunched (new container, new JVM ...)
  • Spark jobs/stages are pretty expensive operations, so there is generally not that many per applications

Motivation

Additional Notes

Only supporting spark >= 2.4.0 with scala 2.12 for now

@paul-laffon-dd
paul-laffon-dd force-pushed the paul.laffon/spark-instrumentation branch 6 times, most recently from 92bd061 to 36bbe1a Compare April 3, 2023 16:08
@paul-laffon-dd
paul-laffon-dd force-pushed the paul.laffon/spark-instrumentation branch from 36bbe1a to f581020 Compare April 3, 2023 19:58
@paul-laffon-dd paul-laffon-dd changed the title [WIP] Spark instrumentation Spark instrumentation Apr 4, 2023
@paul-laffon-dd
paul-laffon-dd marked this pull request as ready for review April 4, 2023 09:08
@paul-laffon-dd
paul-laffon-dd requested a review from a team as a code owner April 4, 2023 09:08

@dougqh dougqh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I haven't reviewed this closely yet, but there are several issues that I can already see need to be addressed...

First, in terms of Java style...
1 - The member variables aren't private - not idiomatic for Java
2 - Many member variables that should be final are not final

Second, memory management - the tracer is pretty strictly prohibited from having unbounded collections like those in this listener.

The first step towards fixing this is to remove elements from the map as those jobs & tasks finish.
The second step towards this to check that the map isn't over capacity as insertion.

Third, it is clear what the thread safety rules are the listener, so this code may not be safe. Please indicate the thread safety behavior of the Spark listener with a comment in the code.

}

@Override
public void onApplicationStart(SparkListenerApplicationStart applicationStart) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are the listener methods always called in the same thread? If not, then this code isn't thread safe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

- Removed serviceName, will be apply automatically
- Added private/final to most of the attributes
- Removed stageLongestTask, complexify the PR and might not be useful at first
- Remove elements for the maps when receiving the end event
- Adding capping on the number of elements in maps
- Replaced Optional by a check if it is defined

@paul-laffon-dd paul-laffon-dd left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for the review! I am not used to write java, I tried to adapt the style related to your comments

For the memory management, I added a check that we are not above a certain size and removing the spans on the end event

For the thread-safety, all the callbacks are called from the same thread on the driver, so we should be safe on that (added a comment about it in the code)

}

@Override
public void onApplicationStart(SparkListenerApplicationStart applicationStart) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

int stageId = stageSubmitted.stageInfo().stageId();
int stageAttemptId = stageSubmitted.stageInfo().attemptNumber();

if (!stageToJob.containsKey(stageId)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

containsKey followed by get is redundant unless you store nulls into the map.
You can just do stageToJob.get(stageId). The only caveat is that you'll get back an Integer rather than int and then need to check against null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed it to here

Integer jobId = stageToJob.get(stageId);
if (jobId == null) {
  return;
}

submissionTimeMs = System.currentTimeMillis();
}

AgentSpan stageSpan =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Seeing withStartTimestamp made me realize that we probably want to exclude these spans from tracking by profiling, since these spans aren't actually represent work within this JVM.

I'll make a note to think about that. It doesn't need to be a blocker for this initial prototype.

if (applicationStart.appAttemptId().isDefined())
applicationSpan.setTag("app_attempt_id", applicationStart.appAttemptId().get());

for (Tuple2<String, String> conf : sparkConf.getAll()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We actually don't to capture all values from config into a span.
That risks accidentally exposing sensitive information.

We need to build an inclusion list of config to include. We don't want to just include everything unconditionally.
It would also be fine to have the list be configurable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Add the SparkConfAllowList to only capture a safe subset of the config. Is it what you meant by have the list be configurable ?

"spark_application_metrics.available_executor_time", availableExecutorTime);

applicationSpan.finish(applicationEnd.time() * 1000);
tracer.flush();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You shouldn't explicitly flush the tracer that's bad for performance.
Also, tracer.flush doesn't provide the semantics that you probably want. As currently implemented, it is really only good for testing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the flush(). The idea was that the JVM will be shut down by spark pretty quickly after the onApplicationEnd() event is received and this was to make sure that the spans are sent to datadog

Would you know if there is a way to ensure that ?

public void onApplicationEnd(SparkListenerApplicationEnd applicationEnd) {
if (lastJobFailed) {
applicationSpan.setError(true);
applicationSpan.setTag(DDTags.ERROR_TYPE, "Spark Application Failed");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a better message available on the applicationEnd object?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

There is no error message in SparkListenerApplicationEnd, but we can provide the error message of the last job that failed

.start();

if (jobStart.properties() != null) {
for (final Map.Entry<Object, Object> entry : jobStart.properties().entrySet()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As with the config info, this needs to an inclusion list of know safe properties -- otherwise, there's a risk of leaking sensitive information.

}

private int stageSpanKey(int stageId, int attemptId) {
return stageId * 100000 + attemptId;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since you are dealing with two ints, you might as well just use a long if you want a composite key. Then you'll have no risk of accidental key collision.

It will also be more efficient to use shift operations / powers of 2 than multiplication.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Changed it to here

private long stageSpanKey(int stageId, int attemptId) {
  return ((long) stageId << 32) + attemptId;
}

// Some properties can change at runtime, so capturing properties of all jobs
if (jobStart.properties() != null) {
for (final Map.Entry<Object, Object> entry : jobStart.properties().entrySet()) {
if (SparkConfAllowList.canCaptureParameter(entry.getKey().toString())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wouldn't it better to have two different allow lists? I imagine the parameters are different for app vs a job. Are they not?


@Override
public void onJobEnd(SparkListenerJobEnd jobEnd) {
if (!jobSpans.containsKey(jobEnd.jobId())) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

You don't need the containsKey guard. You can just do a remove and test the result against null.
That's generally a better overall practice because it avoids rare race conditions when using maps synchronized across threads. It also happens to be a bit more efficient.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed all the remaining containsKey in this commit f69e40c

@dougqh dougqh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think pretty much all uses of containsKey are unnecessary. You can just do a get or remove then check the return value.

That's a good habit to get into because it will race condition problems when dealing with synchronized maps. Plus it is a tad bit more efficient.

@dougqh
dougqh requested a review from mcculls April 13, 2023 01:48
isMethod()
.and(named("setupAndStartListenerBus"))
.and(takesNoArguments())
.and(isDeclaredBy(named("org.apache.spark.SparkContext"))),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since we're instrumenting a known type, org.apache.spark.SparkContext, you don't need isDeclaredBy here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed it in this commit 17a8ccf


for (Tuple2<String, String> conf : sparkConf.getAll()) {
if (SparkConfAllowList.canCaptureParameter(conf._1)) {
applicationSpan.setTag("config." + conf._1.replace(".", "_"), conf._2);

@mcculls mcculls Apr 13, 2023

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is another place we could use a small cache to reduce string creation

(note the .->_ replace can be made part of the caching function, so the lookup key is the original string)

if (jobStart.properties() != null) {
for (final Map.Entry<Object, Object> entry : jobStart.properties().entrySet()) {
if (SparkConfAllowList.canCaptureParameter(entry.getKey().toString())) {
jobSpan.setTag("config." + entry.getKey().toString().replace('.', '_'), entry.getValue());

@mcculls mcculls Apr 13, 2023

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is another place we could use a small cache to reduce string creation

(note the .->_ replace can be made part of the caching function, so the lookup key is the original object)

span.setTag(DDTags.ERROR_TYPE, "Spark Stage Failed");
}

stageInfo.rddInfos().foreach(rdd -> span.setTag("rdd." + rdd.name(), rdd.toString()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this is another place we could use a small cache to reduce string creation

@mcculls

mcculls commented Apr 13, 2023

Copy link
Copy Markdown
Contributor

Agree with the comments from @dougqh about avoiding containsKey and going straight to the get or remove method.

Likewise patterns like this:

    if (!stageMetrics.containsKey(stageSpanKey)) {
      stageMetrics.put(stageSpanKey, new SparkAggregatedTaskMetrics());
    }
    stageMetrics.get(stageSpanKey).addTaskMetrics(taskEnd);

can be simplified in Java 8 to:

    stageMetrics.computeIfAbsent(stageSpanKey, SparkAggregatedTaskMetrics::new).addTaskMetrics(taskEnd)

There's also the opportunity to add some caches to limit creation of duplicate strings - however, if the number of stages and jobs is small then you may decide adding these caches is not worthwhile (we typically use them for resource name transformations inside very hot methods.)

@paul-laffon-dd

Copy link
Copy Markdown
Contributor Author

I removed all usage of containsKey to directly use remove/get instead.

About the caching to limit creation of duplicate strings, I would think that they might not be worthwhile since there is not that many stages per spark application (generally less than 100 for applications running for tens of minutes)

@dougqh dougqh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The code around applicationSpan isn't obviously correct to me.

I think having a trace encompass the whole application might be the wrong choice, but it really depends on the lifetime of the application object.

Just based on the names, I think it would probably be better to have a trace per job -- not a trace per application.

@paul-laffon-dd

Copy link
Copy Markdown
Contributor Author

The application is started when the spark driver is spin up and ends when the driver is shut down.

Spark applications are launched with a spark-submit command to produce an output and the application lifecycle is tied to this command

./bin/spark-submit \
  --class org.apache.spark.examples.SparkPi \
  --master local[8] \
  /path/to/examples.jar \
  100

The application metrics are the most useful since they provide an overall picture of what happened during the entire program. Looking into Spark jobs is mainly useful for troubleshooting when the application has failed or performed poorly

@paul-laffon-dd

Copy link
Copy Markdown
Contributor Author

Excluding the spark instrumentation from semeru jdk since hadoop, on which spark in depending is not behaving well on it, https://issues.apache.org/jira/browse/HADOOP-18174

@paul-laffon-dd
paul-laffon-dd merged commit b62e16b into master Apr 24, 2023
@paul-laffon-dd
paul-laffon-dd deleted the paul.laffon/spark-instrumentation branch April 24, 2023 07:32
@github-actions github-actions Bot added this to the 1.13.0 milestone Apr 24, 2023
@richardstartin richardstartin added inst: others All other instrumentations inst: apache spark Apache Spark instrumentation labels May 2, 2023
@richardstartin richardstartin changed the title Spark instrumentation Apache Spark instrumentation May 2, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

inst: apache spark Apache Spark instrumentation inst: others All other instrumentations

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants