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

Prevent SentryTracer.getLatestActiveSpan from copying all child spans to avoid OOMs #2815

Open
markushi opened this issue Jul 3, 2023 · 9 comments · May be fixed by #4247
Open

Prevent SentryTracer.getLatestActiveSpan from copying all child spans to avoid OOMs #2815

markushi opened this issue Jul 3, 2023 · 9 comments · May be fixed by #4247

Comments

@markushi
Copy link
Member

markushi commented Jul 3, 2023

Description

Exception java.lang.OutOfMemoryError:
  at java.util.Arrays.copyOf (Arrays.java:3136)
  at java.util.Arrays.copyOf (Arrays.java:3106)
  at java.util.concurrent.CopyOnWriteArrayList.toArray (CopyOnWriteArrayList.java:321)
  at java.util.ArrayList.<init> (ArrayList.java:191)
  at io.sentry.SentryTracer.getLatestActiveSpan (SentryTracer.java:692)
  at io.sentry.Scope.getSpan (Scope.java:201)
  at io.sentry.Hub.getSpan (Hub.java:769)
  at io.sentry.Sentry.getSpan (Sentry.java)

check SentryTracer.getLatestActiveSpan
and SentryTracer.hasAllChildrenFinished

@markushi
Copy link
Member Author

markushi commented Jul 3, 2023

Also applies to hasAllChildrenFinished() and probably others.

@stefanosiano
Copy link
Member

We should check for all places where we use the CopyOnWriteArrayList

@philipphofmann
Copy link
Member

These are the two locations the Tracer copies the list

public @Nullable Span getLatestActiveSpan() {
final List<Span> spans = new ArrayList<>(this.children);
if (!spans.isEmpty()) {
for (int i = spans.size() - 1; i >= 0; i--) {
if (!spans.get(i).isFinished()) {
return spans.get(i);
}
}
}
return null;
}

private boolean hasAllChildrenFinished() {
final List<Span> spans = new ArrayList<>(this.children);
if (!spans.isEmpty()) {
for (final Span span : spans) {
if (!span.isFinished()) {
return false;
}
}
}
return true;
}

@stefanosiano stefanosiano moved this from Needs Discussion to Backlog in Mobile & Cross Platform SDK Jul 5, 2023
@adinauer
Copy link
Member

adinauer commented Jul 5, 2023

Aren't those locations simply copying references to Span objects? They're not actually cloning the objects are they?

@philipphofmann
Copy link
Member

As far as I know, new ArrayList<>(this.children) is not doing a deep copy, yes, but it will still allocate a new ArrayList for the references.

@adinauer
Copy link
Member

adinauer commented Jul 7, 2023

OK so this shouldn't be the cause of OOM just where it surfaced. It should only allocate little memory and can be garbage collected right away, no risk of anyone holding on to references as we don't return the list. Shall we close this then?

@markushi
Copy link
Member Author

markushi commented Jul 7, 2023

@adinauer yes you're right, the elements of the array are not cloned / deep copied. According to SDK console this issue is still our no. 1 overall issue. I can imagine situations where there are many spans or SentryTracer.getLatestActiveSpan is called very frequently causing OOMs.

@romtsn romtsn changed the title Prevent SentryTracer.getLatestActiveSpan from copying all child spans Prevent SentryTracer.getLatestActiveSpan from copying all child spans to avoid OOMs Jul 20, 2023
@adinauer
Copy link
Member

adinauer commented Mar 10, 2025

We could just forgoe the copying of the array since CopyOnWriteArrayList creates an Iterator that does not throw ConcurrentModificationException.

We need to test to make sure that's actually the case and it doesn't throw when under load.

We need to make sure to use the iterator instead of iterating based on the index. To get reverse we can use:

  ListIterator<Span> iterator = this.children.listIterator();
  while (iterator.hasPrevious()) {
    Span s = iterator.previous();
    ...
  }

Both getLatestActiveSpan and hasAllChildrenFinished should be updated.

For Testing this:
We can replace: private final @NotNull List<Span> children = new CopyOnWriteArrayList<>(); with a normal ArrayList.

Create a setup that concurrently from multiple threads starts new spans and invokes the affected methods.

With ArrayList in place this should throw ConcurrentModificationException and with CopyOnWriteArrayList it should not.

In a loop check affected methods.
In another loop on another thread create spans with a sleep in between.

Idea around copying the array on write instead of on read

We could move the copy of the array from "on read" to "on write". So instead of temporarily creating a new (shallow) copy of the child list for every invocation of getLatestActiveSpan and hasAllChildrenFinished we only permanently have a single copy allocated.

We can consider this an immutable list but the reference will be replaced on every write.

The methods getLatestActiveSpan and hasAllChildrenFinished can then get the current reference at the start and use it throughout its execution. They must however not retrieve the reference multiple times in one execution as that could mean unpredictable results.

Whenever this.children.add(span); is executed, we need to update the reference to a new copy of the list of children.

@lcian
Copy link
Member

lcian commented Mar 10, 2025

I've ran some tests

Using the following code
package io.sentry.samples.console;

import io.sentry.ISpan;
import io.sentry.ITransaction;
import io.sentry.Sentry;
import io.sentry.Span;
import io.sentry.TransactionOptions;
import java.util.List;
import java.util.Random;

public class Main {

  public static int ITERATIONS = 1000000000;

  public static void main(String[] args) throws InterruptedException {
    Sentry.init(
        options -> {
          options.setDsn("https://[email protected]/4508683222843393");
          options.setTracesSampleRate(1.0);
        });

    TransactionOptions opts = new TransactionOptions();
    opts.setBindToScope(true);
    ITransaction tx = Sentry.startTransaction("lol", "op", opts);

    Thread thread1 = new Thread(new SpanStarter());
    Thread thread2 = new Thread(new LatestSpanGetter());
    thread1.start();
    thread2.start();
    try {
      thread1.join();
      thread2.join();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  }
}

class SpanStarter implements Runnable {

  @Override
  public void run() {
    try {
      Random random = new Random();
      ITransaction tx = Sentry.getCurrentScopes().getTransaction();
      for (int i = 0; i < Main.ITERATIONS; i++) {
        List<Span> spans = tx.getSpans();
        if (!spans.isEmpty() & i % 3 == 0) {
          Integer w = random.nextInt(spans.size());
          spans.remove(w);
        }
        Integer r = random.nextInt(Integer.MAX_VALUE);
        tx.startChild(r.toString());
      }
    } catch (Exception e) {
      System.out.println(e.toString());
    }
    System.out.println("SpanStarter finished");
  }
}

class LatestSpanGetter implements Runnable {
  @Override
  public void run() {
    Integer x = 0;
    try {
      ITransaction tx = Sentry.getCurrentScopes().getTransaction();
      for (int i = 0; i < 100 * Main.ITERATIONS; i++) {
        ISpan span = tx.getLatestActiveSpan();
        if (span != null) {
          x += Integer.parseInt(span.getOperation());
        }
      }
    } catch (Exception e) {
      System.out.println(e.toString());
    }
    System.out.println(x);
    System.out.println("LatestSpanGetter finished");
  }
}

When replacing the current implementation of SentryTracer.children with an ArrayList, I've been able to trigger a ConcurrentModificationException only if I also modify SentryTracer.getLatestActiveSpan to use an iterator instead of index-based accessing.
Index-based accessing could also be problematic though. It could happen that one thread deletes some element and the other thread which is executing getLatestActiveSpan tries to access it (as it's relying on the old length of the list when iterating) causing an NPE.

CopyOnWriteArrayList prevents ConcurrentModificationException when using the iterator (tested with the above example code).

Therefore we will move forward with not copying and also accessing the list using an iterator.

To perform the checks starting from the last span we need to "consume" the iterator (calling .next() until the end) and only then start checking, as CopyOnWriteArrayList doesn't provide a direct way to get a reverse iterator in a thread-safe and copyless manner (see e.g. https://stackoverflow.com/questions/42045206/thread-safe-copyonwritearraylist-reverse-iteration).

@lcian lcian linked a pull request Mar 10, 2025 that will close this issue
7 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: Backlog
Development

Successfully merging a pull request may close this issue.

6 participants