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

Periodically remove inactive connection pool metrics #6024

Merged
merged 10 commits into from
Dec 11, 2024

Conversation

ikhoon
Copy link
Contributor

@ikhoon ikhoon commented Dec 9, 2024

Motivation:

We observed that threads were blocked when multiple connections were closed simultaneously and the endpoint had a small number of event loops.

lock.lock();
try {
final Meters meters = metersMap.get(commonTags);
if (meters != null) {
meters.decrement();
assert meters.activeConnections() >= 0 : "active connections should not be negative. " + meters;
if (meters.activeConnections() == 0) {

We have no exact evidence, but I guess Micrometer's remove() operation may take a long time. The other logic is a simple HashMap operation that does not block for a long time.

Modifications:

  • Add a dedicated GC thread to remove inactive meters whose active connections are 0.
    • A jitter is added to prevent GC from executing simultaneously.
    • Unsed meters are removed every hour + jitter.
  • ConnectionPoolListener now implements SafeCloseable so users should close it when it is unused.

Result:

  • Fix the bug where EventLoop is blocked for a long time by ConnectionPoolListener.metricCollecting() when a connection is closed.

Motivation:

We observed that threads were blocked when multiple connections were
closed simultaneously and the endpoint has a small number of event
loops.
https://github.com/line/armeria/blob/fa76e99fa6132545df3a8d05eeb81c5681ec8953/core/src/main/java/com/linecorp/armeria/client/ConnectionPoolMetrics.java#L79-L85

We have no exact evidence, but I guess the `remove()` operation of
Micrometer may take a long time. The other logic is a simple HashMap
operation, so it does not block for a long time.

Modifications:

- Add a dedicated gc thread to remove inactive meters whose active
  conections is 0.
  - A jitter is added to prevent gc from executing at the same time.
  - Unsed meters are removed every an hour + jitter.

Result:

- Fix the bug where `EventLoop` is blocked for a long time by
  `ConnectionPoolListener.metricCollecting()` when a connection is closed.
@ikhoon ikhoon added the defect label Dec 9, 2024
@ikhoon ikhoon added this to the 1.31.3 milestone Dec 9, 2024
Copy link
Contributor

@minwoox minwoox left a comment

Choose a reason for hiding this comment

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

Looks good all in all. 👍

final class ConnectionPoolMetrics {
final class ConnectionPoolMetrics implements SafeCloseable {

private static final ScheduledExecutorService CLEANUP_EXECUTOR =
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 use the blocking task executor similar you did in k8sEndpointGroup?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I prefer a dedicated thread. We don't know how long the remove() operations takes and as the number of ConnectionPoolMetrics increases, the number of jobs will also increases. An isolated environment may be better than a blocking task executor used for handling requests.

Copy link
Contributor

Choose a reason for hiding this comment

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

That is true. But I also don't want to waste resources creating a thread that is used once an hour. 🤔

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 makes sense. I am convinced.

Copy link
Contributor

Choose a reason for hiding this comment

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

We don't know how long the remove() operations takes and as the number of ConnectionPoolMetrics increases,

Yeah, but I believe it's really a problem when multiple threads trying to remove each meter from the meter registry.
Now it's done by just one thread, so I think there are not so many contentions.

Copy link
Contributor

Choose a reason for hiding this comment

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

It makes sense. I am convinced.

Thank you for your understanding. 🙏
If you want, I can push a commit for you. Or you can do it by yourself after you get back from the day-off.
I think this is not urgent, so we don't have to hurry. Please get some rest when you day-off. 😆

Copy link
Contributor Author

@ikhoon ikhoon Dec 10, 2024

Choose a reason for hiding this comment

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

if we use a blocking task executor, multiple tasks may be scheduled in different threads depending on the number of ConnectionPoolMetrics. Although the chance of contention would be extremely low.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you want, I can push a commit for you.

Please push the commit. I think that it would be a trivial change.

Copy link
Contributor

Choose a reason for hiding this comment

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

if we use a blocking task executor, multiple tasks may be scheduled in different threads depending on the number of ConnectionPoolMetrics. Although the chance of contention would be extremely low.

That is true. It can be a problem when a lot of client factories are used. But as you mentioned, the chance of contention would be extremely low. 😉

Please push the commit. I think that it would be a trivial change.

I've actually left another suggestion that might bring a breaking change. 🤣
#6024 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don’t want to add additional breaking changes in this PR as we are going to release a patch version for the PR.

}
}
} finally {
lock.unlock();
}

for (Meters meters : unusedMetersList) {
meters.remove(meterRegistry);
Copy link
Contributor

Choose a reason for hiding this comment

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

I believe we should do this in the lock block, other the newly added meter might be removed.
Because cleanupInactiveMeters is accessed only by one thread, I think we can move this logic into the lock block above.

Copy link
Contributor Author

@ikhoon ikhoon Dec 9, 2024

Choose a reason for hiding this comment

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

I understood what you mentioned. However, I don't think moving meters.remove(meterRegistry) to the lock block is good because the lock could block event loops when increaseConn{Opened,Closed}() and cleanupInactiveMeters() are invoked together.

Would it be better to asynchronously perform increaseConnOpened and increaseConnClosed() in a blocking executor and revert this PR?

Copy link
Contributor

@minwoox minwoox Dec 10, 2024

Choose a reason for hiding this comment

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

the lock could block event loops when increaseConn{Opened,Closed}() and cleanupInactiveMeters() are invoked together.

I thought it's okay since cleanupInactiveMeters is only invoked by the executor.
If you worry about the situation, how about adding an additional tag then? e.g. creation.index
connections{protocol="...", remote.ip="...", local.ip="...", creation.index=x }
If we use the tag, we can distinguish from the previous one. Also we can change the lock with the hashmap to concurrent hash map.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, it may not work. Let me do the brief PoC to see if that works or not.

Copy link
Contributor

@minwoox minwoox Dec 10, 2024

Choose a reason for hiding this comment

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

I realized that we still need the lock

void cleanupInactiveMeters() {
    final List<Meters> unusedMetersList = new ArrayList<>();
    lock.lock();
    try {
        for (final Iterator<Entry<List<Tag>, Meters>> it = metersMap.entrySet().iterator();
             it.hasNext();) {
            final Entry<List<Tag>, Meters> entry = it.next();
            final Meters meters = entry.getValue();      
            if (meters.activeConnections() == 0) {
                metersMap.remove(commonTags);
                unusedMetersList.add(meters);
            }
        }
    } finally {
        lock.unlock();
    }
    unusedMetersList.forEach(meters -> meters.remove(meterRegistry));
}

private static final class Meters {

    private static final AtomicLong COUNTER = new AtomicLong();

    private final Counter opened;
    private final Counter closed;
    private final Gauge active;
    private int activeConnections;

    Meters(MeterIdPrefix idPrefix, List<Tag> commonTags, MeterRegistry registry) {
        final String index = String.valueOf(COUNTER.incrementAndGet());
        opened = Counter.builder(idPrefix.name("connections"))
                        .tags(commonTags)
                        .tag(STATE, "opened")
                        .tag("creation.index", index)
                        .register(registry);
        closed = Counter.builder(idPrefix.name("connections"))
                        .tags(commonTags)
                        .tag(STATE, "closed")
                        .tag("creation.index", index)
                        .register(registry);
        active = Gauge.builder(idPrefix.name("active.connections"), this, Meters::activeConnections)
                      .tags(commonTags)
                      .tag("creation.index", index)
                      .register(registry);
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree with you.

Copy link
Contributor

@jrhee17 jrhee17 Dec 10, 2024

Choose a reason for hiding this comment

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

Would it be better to asynchronously perform increaseConnOpened and increaseConnClosed() in a blocking executor and revert this PR?

I prefer this approach where a single dedicated thread is responsible for incrementing/decrementing/cleaning up metrics asynchronously.

The current proposed approach increases the number of tags per-endpoint. While this may be fine for the server recording metrics, this may not bode well for backends.

i.e. If a connection is closed every minute, that would mean 1440 time series are created each day per-endpoint.
I'm not exactly sure how long prometheus retains time series, but it seems like 15 days is the expiry for samples.

ref: https://prometheus.io/docs/prometheus/latest/storage/#operational-aspects
Another SO answer seems to indicate 2 hours is the expiry.

In any case, our internal monitoring system also stores all time series' (with a long expiry from my memory) which makes me concerned whether this approach is a good idea.

Copy link
Contributor

@minwoox minwoox Dec 10, 2024

Choose a reason for hiding this comment

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

i.e. If a connection is closed every minute, that would mean 1440 time series are created each day per-endpoint.
I'm not exactly sure how long prometheus retains time series, but it seems like 15 days is the expiry for samples.

Because the executor clear the metric once an hour, 24 time series are created at worst. Let me investigate it it's acceptable or not.

Copy link
Contributor

@minwoox minwoox Dec 10, 2024

Choose a reason for hiding this comment

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

I believe we have three potential approaches:

  • Using a blocking task executor
  • Implementing a garbage collection (GC)-like mechanism with a counter
  • Implementing a GC-like mechanism with a striping lock

Using a blocking task executor might be the simplest solution. However, delegating tasks to the blocking executor solely to increment a metric doesn't seem ideal from a performance standpoint.
The second option, as mentioned by @jrhee17, has its drawbacks. While increasing the interval might mitigate the issue, it's uncertain if that would provide a robust solution.
The third option appears to be the most promising. With this approach, when the meter registry is removed, only the thread accessing the striping lock would be impacted.

The third option is not a good idea because there are still event loops that wait for the lock.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Refactored the code to create Meters without lock. PTAL.

@minwoox
Copy link
Contributor

minwoox commented Dec 10, 2024

How about also changing remote.ip to remote.address and using EndpointToStringUtil.toShortString?

Copy link
Member

@trustin trustin left a comment

Choose a reason for hiding this comment

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

LGTM once @minwoox's comments are addressed. Thanks, @ikhoon!

@ikhoon
Copy link
Contributor Author

ikhoon commented Dec 10, 2024

How about also changing remote.ip to remote.address and using EndpointToStringUtil.toShortString?

The port information seems useful. Would you also want to include weight?

}
}
} finally {
lock.unlock();
}

for (Meters meters : unusedMetersList) {
meters.remove(meterRegistry);
Copy link
Contributor

@jrhee17 jrhee17 Dec 10, 2024

Choose a reason for hiding this comment

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

Would it be better to asynchronously perform increaseConnOpened and increaseConnClosed() in a blocking executor and revert this PR?

I prefer this approach where a single dedicated thread is responsible for incrementing/decrementing/cleaning up metrics asynchronously.

The current proposed approach increases the number of tags per-endpoint. While this may be fine for the server recording metrics, this may not bode well for backends.

i.e. If a connection is closed every minute, that would mean 1440 time series are created each day per-endpoint.
I'm not exactly sure how long prometheus retains time series, but it seems like 15 days is the expiry for samples.

ref: https://prometheus.io/docs/prometheus/latest/storage/#operational-aspects
Another SO answer seems to indicate 2 hours is the expiry.

In any case, our internal monitoring system also stores all time series' (with a long expiry from my memory) which makes me concerned whether this approach is a good idea.

@minwoox
Copy link
Contributor

minwoox commented Dec 10, 2024

The port information seems useful. Would you also want to include weight?

Oops, what I meant was using TextFormmater.socketAddress(). 😓

Copy link
Contributor

@minwoox minwoox left a comment

Choose a reason for hiding this comment

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

This approach looks nice. Thanks!

Comment on lines +144 to +145
}
} finally {
Copy link
Contributor

Choose a reason for hiding this comment

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

Small suggestion:

Suggested change
}
} finally {
}
if (unusedMetersList.isEmpty()) {
garbageCollecting = false;
return;
}
} finally {

@ikhoon ikhoon merged commit b2360c9 into line:main Dec 11, 2024
14 checks passed
@ikhoon ikhoon deleted the gc-connection-metrics branch December 11, 2024 05:21
ikhoon added a commit to ikhoon/armeria that referenced this pull request Dec 11, 2024
Motivation:

We observed that threads were blocked when multiple connections were
closed simultaneously and the endpoint had a small number of event
loops.

https://github.com/line/armeria/blob/fa76e99fa6132545df3a8d05eeb81c5681ec8953/core/src/main/java/com/linecorp/armeria/client/ConnectionPoolMetrics.java#L79-L85

We have no exact evidence, but I guess Micrometer's `remove()` operation
may take a long time. The other logic is a simple HashMap operation that
does not block for a long time.

Modifications:

- Add a dedicated GC thread to remove inactive meters whose active
connections are 0.
  - A jitter is added to prevent GC from executing simultaneously.
  - Unsed meters are removed every hour + jitter.
- `ConnectionPoolListener` now implements `SafeCloseable` so users
should close it when it is unused.

Result:

- Fix the bug where `EventLoop` is blocked for a long time by
`ConnectionPoolListener.metricCollecting()` when a connection is closed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants