Skip to content

Commit 3a62c28

Browse files
committed
cap the per-event retry in AdoNetAppender #314
Retrying after a rolled back transaction was added in 3.4.0 to save the events around the one the database rejected. A batch that fails outright, a missing table or permission, then cost one round trip per event: measured 21 for a batch of 20, so 513 for a full buffer where there had been 1. It gives up after five consecutive failures. Failures spread through a batch do not count towards that, so a single rejected event still costs only itself. SendBuffer contains per-event failures and returns normally, so the retry loop had no way to tell one from a success. A private flag gives it one.
1 parent f5efd75 commit 3a62c28

4 files changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<entry xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xmlns="https://logging.apache.org/xml/ns"
4+
xsi:schemaLocation="https://logging.apache.org/xml/ns https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
5+
type="fixed">
6+
<issue id="314" link="https://github.com/apache/logging-log4net/pull/314"/>
7+
<description format="asciidoc">
8+
stop `AdoNetAppender` from retrying a whole batch one event at a time when none of them can be
9+
written. Retrying after a rolled back transaction was added in 3.4.0 to save the events around
10+
the one the database rejected, but a batch that fails outright, such as a missing table or a
11+
missing permission, then cost one round trip per event: 513 instead of 1 for a full buffer. It
12+
now gives up after five consecutive failures (implemented by @FreeAndNil)
13+
</description>
14+
</entry>

‎src/log4net.Tests/Appender/AdoNet/Log4NetCommand.cs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public int ExecuteNonQuery()
6060
}
6161
payload ??= CommandText;
6262

63+
AttemptCount++;
6364
if (ExceptionTrigger is not null
6465
&& payload?.IndexOf(ExceptionTrigger, StringComparison.Ordinal) >= 0)
6566
{
@@ -79,6 +80,11 @@ public int ExecuteNonQuery()
7980
/// </summary>
8081
public int ExecuteNonQueryCount { get; private set; }
8182

83+
/// <summary>
84+
/// Every <see cref="ExecuteNonQuery"/> attempt across all instances, failed ones included.
85+
/// </summary>
86+
internal static int AttemptCount { get; set; }
87+
8288
/// <summary>
8389
/// When set, <see cref="ExecuteNonQuery"/> throws for every command whose payload
8490
/// contains this string, simulating a database that rejects specific content.

‎src/log4net.Tests/Appender/AdoNetAppenderTest.cs‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -501,4 +501,130 @@ public void NullPropertyProgrammaticConfig()
501501
Assert.That(param.Value, Is.Not.EqualTo(SystemInfo.NullText));
502502
Assert.That(param.Value, Is.EqualTo(DBNull.Value));
503503
}
504+
505+
/// <summary>
506+
/// Retrying one by one exists to save the events around the one the database rejected. When
507+
/// every one of them fails, the batch is not the problem and each further round trip only
508+
/// learns that again.
509+
/// </summary>
510+
[Test]
511+
public void RetryingPerEventGivesUpAfterRepeatedFailures()
512+
{
513+
const int bufferSize = 19;
514+
// One inside the transaction, then five one by one before it gives up.
515+
const int expectedAttempts = 6;
516+
517+
Log4NetCommand.AttemptCount = 0;
518+
Log4NetCommand.ExceptionTrigger = "Message";
519+
try
520+
{
521+
XmlDocument log4NetConfig = new();
522+
log4NetConfig.LoadXml(
523+
"""
524+
<log4net>
525+
<appender name="AdoNetAppender" type="log4net.Appender.AdoNetAppender">
526+
<bufferSize value="19" />
527+
<useTransactions value="true" />
528+
<connectionType value="log4net.Tests.Appender.AdoNet.Log4NetConnection" />
529+
<connectionString value="data source=[database server]" />
530+
<commandText value="INSERT INTO Log ([Message]) VALUES (@message)" />
531+
<parameter>
532+
<parameterName value="@message" />
533+
<dbType value="String" />
534+
<size value="4000" />
535+
<layout type="log4net.Layout.PatternLayout">
536+
<conversionPattern value="%message" />
537+
</layout>
538+
</parameter>
539+
</appender>
540+
<root>
541+
<level value="ALL" />
542+
<appender-ref ref="AdoNetAppender" />
543+
</root>
544+
</log4net>
545+
""");
546+
547+
ILoggerRepository rep = LogManager.CreateRepository(Guid.NewGuid().ToString());
548+
XmlConfigurator.Configure(rep, log4NetConfig["log4net"]!);
549+
ILog log = LogManager.GetLogger(rep.Name, nameof(RetryingPerEventGivesUpAfterRepeatedFailures));
550+
551+
LogLog.ExecuteWithoutEmittingInternalMessages(() =>
552+
{
553+
// The event after the buffer is full flushes all twenty.
554+
for (int i = 0; i <= bufferSize; i++)
555+
{
556+
log.Debug("Message");
557+
}
558+
});
559+
560+
Assert.That(Log4NetCommand.AttemptCount, Is.EqualTo(expectedAttempts));
561+
}
562+
finally
563+
{
564+
Log4NetCommand.ExceptionTrigger = null;
565+
Log4NetCommand.ExecutedPayloads.Clear();
566+
}
567+
}
568+
569+
/// <summary>
570+
/// Giving up must not cost the events around a rejected one, which is the whole point of
571+
/// retrying: five failures spread out are not five in a row.
572+
/// </summary>
573+
[Test]
574+
public void RetryingPerEventKeepsGoingWhileFailuresAreSpreadOut()
575+
{
576+
const int bufferSize = 9;
577+
const int expectedWrites = 5;
578+
579+
Log4NetCommand.AttemptCount = 0;
580+
Log4NetCommand.ExceptionTrigger = "POISON";
581+
try
582+
{
583+
XmlDocument log4NetConfig = new();
584+
log4NetConfig.LoadXml(
585+
"""
586+
<log4net>
587+
<appender name="AdoNetAppender" type="log4net.Appender.AdoNetAppender">
588+
<bufferSize value="9" />
589+
<useTransactions value="true" />
590+
<connectionType value="log4net.Tests.Appender.AdoNet.Log4NetConnection" />
591+
<connectionString value="data source=[database server]" />
592+
<commandText value="INSERT INTO Log ([Message]) VALUES (@message)" />
593+
<parameter>
594+
<parameterName value="@message" />
595+
<dbType value="String" />
596+
<size value="4000" />
597+
<layout type="log4net.Layout.PatternLayout">
598+
<conversionPattern value="%message" />
599+
</layout>
600+
</parameter>
601+
</appender>
602+
<root>
603+
<level value="ALL" />
604+
<appender-ref ref="AdoNetAppender" />
605+
</root>
606+
</log4net>
607+
""");
608+
609+
ILoggerRepository rep = LogManager.CreateRepository(Guid.NewGuid().ToString());
610+
XmlConfigurator.Configure(rep, log4NetConfig["log4net"]!);
611+
ILog log = LogManager.GetLogger(rep.Name, nameof(RetryingPerEventKeepsGoingWhileFailuresAreSpreadOut));
612+
613+
LogLog.ExecuteWithoutEmittingInternalMessages(() =>
614+
{
615+
for (int i = 0; i <= bufferSize; i++)
616+
{
617+
log.Debug(i % 2 == 0 ? "POISON" : $"good {i}");
618+
}
619+
});
620+
621+
Assert.That(Log4NetCommand.ExecutedPayloads, Has.Count.EqualTo(expectedWrites));
622+
Assert.That(Log4NetCommand.ExecutedPayloads, Has.Member("good 9"));
623+
}
624+
finally
625+
{
626+
Log4NetCommand.ExceptionTrigger = null;
627+
Log4NetCommand.ExecutedPayloads.Clear();
628+
}
629+
}
504630
}

‎src/log4net/Appender/AdoNetAppender.cs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,7 @@ protected virtual void SendBuffer(IDbTransaction? dbTran, LoggingEvent[] events)
549549
// rejects must not stop the remaining events from being written. In transaction
550550
// mode the exception has to propagate - the transaction is in a failed state -
551551
// and SendBuffer retries the events individually after the rollback.
552+
_eventFailed = true;
552553
ErrorHandler.Error("Exception while writing a logging event to the database. Continuing with the remaining events.", ex);
553554
}
554555
}
@@ -577,6 +578,7 @@ protected virtual void SendBuffer(IDbTransaction? dbTran, LoggingEvent[] events)
577578
catch (Exception ex) when (dbTran is null && !ex.IsFatal())
578579
{
579580
// See the parameterized path above: contain per-event failures outside transactions.
581+
_eventFailed = true;
580582
ErrorHandler.Error("Exception while writing a logging event to the database. Continuing with the remaining events.", ex);
581583
}
582584
}
@@ -604,6 +606,7 @@ protected virtual void SendBuffer(IDbTransaction? dbTran, LoggingEvent[] events)
604606
/// </remarks>
605607
private void SendBufferPerEvent(LoggingEvent[] events)
606608
{
609+
int failuresInARow = 0;
607610
foreach (LoggingEvent e in events)
608611
{
609612
if (Connection is not { State: ConnectionState.Open })
@@ -612,17 +615,41 @@ private void SendBufferPerEvent(LoggingEvent[] events)
612615
return;
613616
}
614617

618+
_eventFailed = false;
615619
try
616620
{
617621
SendBuffer(null, [e]);
618622
}
619623
catch (Exception ex) when (!ex.IsFatal())
620624
{
625+
_eventFailed = true;
621626
ErrorHandler.Error("Exception while writing a logging event to the database. The event has been dropped.", ex);
622627
}
628+
629+
if (!_eventFailed)
630+
{
631+
failuresInARow = 0;
632+
}
633+
else if (++failuresInARow >= MaxFailuresInARow)
634+
{
635+
// Retrying one by one is meant to save the events around the one the database rejected.
636+
// This many in a row means the batch is not the problem, and the remaining events would
637+
// cost one round trip each to learn the same thing.
638+
ErrorHandler.Error(
639+
$"Giving up on the remaining logging events after {failuresInARow} consecutive failures.");
640+
return;
641+
}
623642
}
624643
}
625644

645+
/// <summary>
646+
/// Set by the contained per-event failure path in <see cref="SendBuffer(IDbTransaction, LoggingEvent[])"/>,
647+
/// which reports rather than throws, so <see cref="SendBufferPerEvent"/> can count failures.
648+
/// </summary>
649+
private bool _eventFailed;
650+
651+
private const int MaxFailuresInARow = 5;
652+
626653
/// <summary>
627654
/// Prepare entire database command object to be executed.
628655
/// </summary>

0 commit comments

Comments
 (0)