plusterkopp opened a new issue, #4356:
URL: https://github.com/apache/logging-log4j2/issues/4356

   **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:
   
   ```java
   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":
   
   ```java
   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:
   
   1. Thread A's `tryPublish` fails, `getEventRoute()` returns ENQUEUE, and A 
enters `enqueueLogMessageWhenQueueFull` → `publishEvent` → `next()`. There it 
claims a slot beyond the buffer and parks.
   2. Every other thread whose `tryPublish` fails now sees `remainingCapacity() 
== -1`. It gets `DISCARD`, and `handleRingBufferFull` clears 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`):
   
   ```java
   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
   ```
   
   <details>
   <summary>Log4jDisruptor4Loss.java</summary>
   
   ```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);
       }
   }
   ```
   
   </details>
   
   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.
   
   
[Log4jDisruptor4Loss.java](https://github.com/user-attachments/files/32668923/Log4jDisruptor4Loss.java)
   
   
[AsyncLoggerDisruptor.java](https://github.com/user-attachments/files/32668961/AsyncLoggerDisruptor.java)


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to