Async loggers silently discard events when the ring buffer is full and LMAX Disruptor 4 is used
维护者通常 1 天内回复
@ramanathan1504 已经在做这个了。
开始于 2026年9月26日。
评估
- 难度
- 4/5
- 预计耗时
- 3-5 天
- 新手友好度
- 70/100
- Issue 类型
- 缺陷
- 描述清晰度
- 描述清楚
- 活跃度
- 活跃
- 技术栈
- java
调研方向
Start with AsyncLoggerDisruptor.java and its getEventRoute() and remainingDisruptorCapacity() entry points, then compare the corresponding logic in AsyncLoggerConfigDisruptor. Run the supplied Log4jDisruptor4Loss.java reproducer with Disruptor 4.0.0 and inspect behavior under a full ring buffer. Done means the queue-full policy is reached during saturation and the reproducer retains all expected events for both async logger paths.
由索引模型根据 Issue 内容生成。
描述
Title: Async loggers silently discard events when the ring buffer is full and LMAX Disruptor 4 is used
Description
When AsyncLoggerContextSelector is combined with LMAX Disruptor 4.0.0, log events are dropped without any trace once the ring buffer runs full. In our server, a burst of warnings at start-up (about 180k events in under 2 s from 16 threads) made single lines of unrelated threads and appenders disappear. The standalone reproducer below loses 56–82% of its events. Nothing is written to the StatusLogger (tested with status="WARN") or to stderr.
Cause
Disruptor 4.0.0 changed MultiProducerSequencer.next() to claim a sequence first and wait for free capacity afterwards:
long current = cursor.getAndAdd(n); // claims before capacity is available
long nextSequence = current + n;
long wrapPoint = nextSequence - bufferSize;
...
while (wrapPoint > (gatingSequence = Util.getMinimumSequence(gatingSequences, current))) {
LockSupport.parkNanos(1L);
}
While one thread waits in that loop, the cursor is ahead of the consumer by more than the buffer size. remainingCapacity() (bufferSize - (produced - consumed)) therefore returns a negative value, typically -1. Disruptor 3.x next() only advances the cursor with a CAS after capacity is available, so this never happens there.
AsyncLoggerDisruptor.getEventRoute() treats any negative capacity as "log4j has been shut down":
EventRoute getEventRoute(final Level logLevel) {
final int remainingCapacity = remainingDisruptorCapacity();
if (remainingCapacity < 0) {
return EventRoute.DISCARD;
}
return asyncQueueFullPolicy.getRoute(backgroundThreadId, logLevel);
}
remainingDisruptorCapacity() returns -1 only when disruptor == null, but it also passes through negative values from RingBuffer.remainingCapacity(). The sequence when the ring buffer is full:
- Thread A's
tryPublishfails,getEventRoute()returns ENQUEUE, and A entersenqueueLogMessageWhenQueueFull→publishEvent→next(). There it claims a slot beyond the buffer and parks. - Every other thread whose
tryPublishfails now seesremainingCapacity() == -1. It getsDISCARD, andhandleRingBufferFullclears the translator without a message.
Under sustained saturation effectively only the thread holding queueFullEnqueueLock gets its events in; all others lose theirs. The configured AsyncQueueFullPolicy is never consulted, because the DISCARD decision is made before it. log4j2.asyncLoggerSynchronizeEnqueueWhenQueueFull=false makes it worse, since more threads wait inside next() at the same time.
AsyncLoggerConfigDisruptor.getEventRoute() has the same check, so mixed async loggers (<AsyncLogger>) should be affected too; we have only tested AsyncLoggerContextSelector. The check is unchanged on the current 2.x branch.
A much rarer variant exists with Disruptor 3.x as well. remainingCapacity() reads consumed and produced separately. If the consumer advances between the two reads and producers immediately claim the freed slots, the result is briefly negative. We observed -51, one progress step of RingBufferLogEventHandler, when polling it while the buffer was full. We never saw an event lost this way, but the same check would discard it.
Suggested fix
Check for shutdown explicitly instead of inferring it from the capacity. In AsyncLoggerDisruptor (and likewise in AsyncLoggerConfigDisruptor):
EventRoute getEventRoute(final Level logLevel) {
if (hasLog4jBeenShutDown(disruptor)) {
return EventRoute.DISCARD;
}
return asyncQueueFullPolicy.getRoute(backgroundThreadId, logLevel);
}
With this change the reproducer below loses no events with Disruptor 4.0.0 (3 of 3 runs, 800,000 of 800,000 lines).
Configuration
Version: log4j 2.25.3. 2.24.3 and 2.25.2 behave the same with Disruptor 4.0.0. Disruptor 3.4.1 does not show the loss with any of them.
Operating system: Windows 11, 8 logical CPUs
JDK: Eclipse Temurin 25.0.3+9
Logs
Nothing is logged, and that is the core of the problem. With status="WARN", the StatusLogger stays silent and nothing appears on stderr.
Reproduction
Single-file program, run with Java 17+ in source-file mode. It uses the default ring buffer size, which is 4096 in garbage-free mode:
java -cp log4j-api-2.25.3.jar:log4j-core-2.25.3.jar:disruptor-4.0.0.jar Log4jDisruptor4Loss.java
Log4jDisruptor4Loss.java
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.BitSet;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class Log4jDisruptor4Loss {
static final int THREADS = 16;
static final int LINES = 50_000;
public static void main(String[] args) throws Exception {
Path dir = Files.createTempDirectory("log4j-loss");
Path log = dir.resolve("test.log");
Path config = dir.resolve("log4j2.xml");
Files.writeString(config, """
<Configuration status="WARN">
<Appenders>
<File name="file" fileName="%s">
<PatternLayout pattern="%%d %%-5p [%%t] %%c - %%m%%n"/>
</File>
</Appenders>
<Loggers>
<Root level="INFO"><AppenderRef ref="file"/></Root>
</Loggers>
</Configuration>
""".formatted(log.toAbsolutePath().toString().replace('\\', '/')));
System.setProperty("log4j2.contextSelector", "org.apache.logging.log4j.core.async.AsyncLoggerContextSelector");
System.setProperty("log4j2.configurationFile", config.toUri().toString());
Logger logger = LogManager.getLogger("test");
CountDownLatch start = new CountDownLatch(1);
List<Thread> threads = new ArrayList<>();
for (int t = 0; t < THREADS; t++) {
int thread = t;
Thread th = new Thread(() -> {
try {
start.await();
} catch (InterruptedException e) {
return;
}
for (int i = 0; i < LINES; i++) {
logger.info("LINE {} {} padding padding padding padding padding padding padding padding", thread, i);
}
}, "producer-" + t);
th.start();
threads.add(th);
}
long startNanos = System.nanoTime();
start.countDown();
for (Thread th : threads) {
th.join();
}
long producedMs = (System.nanoTime() - startNanos) / 1_000_000;
// stops the disruptor after draining it and closes the file
LogManager.shutdown();
BitSet[] seen = new BitSet[THREADS];
for (int t = 0; t < THREADS; t++) {
seen[t] = new BitSet(LINES);
}
for (String line : Files.readAllLines(log, StandardCharsets.UTF_8)) {
int at = line.indexOf(" - LINE ");
if (at < 0) {
continue;
}
String[] fields = line.substring(at + 8).split(" ", 3);
seen[Integer.parseInt(fields[0])].set(Integer.parseInt(fields[1]));
}
long expected = (long) THREADS * LINES;
long found = 0;
for (BitSet bits : seen) {
found += bits.cardinality();
}
String disruptor = com.lmax.disruptor.RingBuffer.class.getProtectionDomain().getCodeSource().getLocation().getPath();
System.out.printf("Disruptor %s: %d threads logged %d lines in %d ms, %d in the file, %d missing (%.1f%%)%n",
disruptor.substring(disruptor.lastIndexOf('/') + 1), THREADS, expected, producedMs, found,
expected - found, 100.0 * (expected - found) / expected);
}
}
Results, 3 runs each:
| Setup | Missing lines |
|---|---|
| log4j 2.25.3 + Disruptor 4.0.0 | 81.7%, 56.4%, 68.1% |
| log4j 2.25.3 + Disruptor 3.4.1 | 0, 0, 0 |
| log4j 2.25.3 + Disruptor 4.0.0 + suggested fix | 0, 0, 0 |
In a larger test with 16 flooding threads plus low-rate probe threads, repeated rounds and several appenders, we saw the same picture:
- Disruptor 4.0.0 lost 50–97% with log4j 2.24.3, 2.25.2 and 2.25.3.
- Disruptor 3.4.1 lost nothing in about 30 million lines under constant saturation.
- A ring buffer large enough never to fill (4M) also avoids the loss, but only because the queue-full path is never taken.
- 主要语言
- Java
- 星标
- 3.6k
- 派生
- 1.8k
- 平均合并
- 5 天 6 小时
- 30 天内合并 PR
- 24
环境准备
- 没有 Dockerfile 或 Docker Compose 文件
- 有 Pull Request 模板
- 没有贡献指南
从这里开始
- 先读完整个 Issue,再读项目的贡献指南。
- 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
- Fork 仓库,在一个分支上完成修改。
- 提交 Pull Request,并在描述里引用这个 Issue 编号。
apache/logging-log4j2 的其他 Issue
-
waiting-for-maintainer
难度 2/5 1-3 小时 新手友好度 63/100
apache/logging-log4j2#4475 ·
维护者通常 1 天内回复
-
Migrate `log4j-api-test` tests to JUnit 5可能重新可做 关联的 PR 已关闭且未合并。 未关闭api priority: P3 tests waiting-for-maintainer
难度 2/5 1-3 小时 新手友好度 68/100
apache/logging-log4j2#4430 · 1 条评论 ·
维护者通常 1 天内回复
-
`Rfc5424Layout` does not escape the `newLineEscape` replacement in SD-PARAM-VALUE可能已有人在做 @lunarbae628 于 5 天前认领。 未关闭bug layouts priority: P3
apache/logging-log4j2#4464 · 2 条评论 · 已指派 1 人 ·
维护者通常 1 天内回复
-
configuration enhancement layouts
难度 4/5 3-5 天 新手友好度 55/100
apache/logging-log4j2#4463 ·
维护者通常 1 天内回复
-
`evaluateOnStartup="true"` saves logs under wrong file names after missing scheduled roll可能重新可做 关联的 PR 已关闭且未合并。 未关闭appenders:Rolling bug priority: P1 waiting-for-maintainer
难度 3/5 1-2 天 新手友好度 30/100
apache/logging-log4j2#4449 ·
维护者通常 1 天内回复
查看 apache/logging-log4j2 的全部 Issue
相似的 Issue
-
[Bug]: Nullable implicit operator on string extensible enums still throws for a null string可能已有人在做 关联的 PR 仍在进行中或已合并。 未关闭bug emitter:client:csharp
难度 2/5 1-3 小时 新手友好度 84/100
microsoft/typespec#12572 · 1 条评论 · 1 个 reaction ·
维护者通常 1 天内回复
-
[BUG]: Java SDK MountRequest always sends update-optional fields, resetting stored mount settings未关闭bug
难度 2/5 1-3 小时 新手友好度 75/100
维护者通常 1 天内回复
-
GCC 15.2: -Wcast-qual warning - cast discards const qualifier from pointer target type (H5trace.c)未关闭
难度 2/5 1-3 小时 新手友好度 73/100
维护者通常 1 天内回复
-
[Bug] RemotingHelper.connect swaps the socket send and receive buffer configuration可能已有人在做 @amalsaidov 今天认领。 未关闭
难度 2/5 1-3 小时 新手友好度 82/100
维护者通常 1 天内回复
-
needs-area-label
难度 2/5 1-3 小时 新手友好度 78/100
维护者通常 2 天内回复