-
Notifications
You must be signed in to change notification settings - Fork 923
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
Conversation
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.
There was a problem hiding this 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 = |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. 🤔
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. 😆
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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);
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
How about also changing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
core/src/main/java/com/linecorp/armeria/client/ClientFactoryBuilder.java
Outdated
Show resolved
Hide resolved
The port information seems useful. Would you also want to include weight? |
} | ||
} | ||
} finally { | ||
lock.unlock(); | ||
} | ||
|
||
for (Meters meters : unusedMetersList) { | ||
meters.remove(meterRegistry); |
There was a problem hiding this comment.
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.
Oops, what I meant was using |
There was a problem hiding this 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!
} | ||
} finally { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Small suggestion:
} | |
} finally { | |
} | |
if (unusedMetersList.isEmpty()) { | |
garbageCollecting = false; | |
return; | |
} | |
} finally { |
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.
Motivation:
We observed that threads were blocked when multiple connections were closed simultaneously and the endpoint had a small number of event loops.
armeria/core/src/main/java/com/linecorp/armeria/client/ConnectionPoolMetrics.java
Lines 79 to 85 in fa76e99
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:
ConnectionPoolListener
now implementsSafeCloseable
so users should close it when it is unused.Result:
EventLoop
is blocked for a long time byConnectionPoolListener.metricCollecting()
when a connection is closed.