Updated
This commit is contained in:
1 parent
17d7426b44
commit
a0959ea2c7
11 files changed
+170
-34
No files matched your search
@@ -86,6 +86,7 @@ namespace EonaCat.LogStack.LogClient
|
||||
entry.Properties = JsonHelper.ToJson(properties);
|
||||
|
||||
_logQueue.Enqueue(entry);
|
||||
TrimQueue();
|
||||
|
||||
if (_logQueue.Count >= _options.BatchSize)
|
||||
{
|
||||
@@ -93,6 +94,22 @@ namespace EonaCat.LogStack.LogClient
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops the oldest entries once the queue exceeds the configured maximum size,
|
||||
/// so a server outage cannot grow the queue without bound.
|
||||
/// </summary>
|
||||
private void TrimQueue()
|
||||
{
|
||||
while (_logQueue.Count > _options.MaxQueueSize && _logQueue.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsExpired(LogEntry entry)
|
||||
{
|
||||
return DateTime.UtcNow - entry.Timestamp > _options.MaxRetention;
|
||||
}
|
||||
|
||||
private async Task FlushAsync()
|
||||
{
|
||||
if (_logQueue.IsEmpty)
|
||||
@@ -106,6 +123,12 @@ namespace EonaCat.LogStack.LogClient
|
||||
var batch = new List<LogEntry>();
|
||||
while (batch.Count < _options.BatchSize && _logQueue.TryDequeue(out var entry))
|
||||
{
|
||||
// Drop stale entries instead of holding/sending them indefinitely.
|
||||
if (IsExpired(entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
batch.Add(entry);
|
||||
}
|
||||
|
||||
@@ -155,11 +178,18 @@ namespace EonaCat.LogStack.LogClient
|
||||
Console.WriteLine($"[LogCentral] Failed to send logs to EonaCat: {ex.Message}");
|
||||
}
|
||||
|
||||
// Only hold onto entries that are still within the retention window;
|
||||
// never retry/re-queue indefinitely, and never exceed the max queue size.
|
||||
foreach (var entry in batch)
|
||||
{
|
||||
if (!IsExpired(entry))
|
||||
{
|
||||
_logQueue.Enqueue(entry);
|
||||
}
|
||||
}
|
||||
|
||||
TrimQueue();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task FlushAndDisposeAsync()
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace EonaCat.LogStack.LogClient
|
||||
public LogCentralEonaCatAdapter(LogBuilder logBuilder, LogCentralClient client)
|
||||
{
|
||||
_client = client;
|
||||
_logBuilder = logBuilder ?? throw new ArgumentNullException(nameof(logBuilder));
|
||||
_logBuilder.OnLog += LogSettings_OnLog;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,5 +17,17 @@ namespace EonaCat.LogStack.LogClient
|
||||
public int BatchSize { get; set; } = 1;
|
||||
public int FlushIntervalSeconds { get; set; } = 5;
|
||||
public bool EnableFallbackLogging { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of entries kept in memory while the server is unreachable.
|
||||
/// Oldest entries are dropped once this is exceeded to avoid unbounded memory growth.
|
||||
/// </summary>
|
||||
public int MaxQueueSize { get; set; } = 10000;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time an entry is kept in memory waiting to be (re)sent. Entries older
|
||||
/// than this are dropped instead of retried/re-queued forever.
|
||||
/// </summary>
|
||||
public TimeSpan MaxRetention { get; set; } = TimeSpan.FromMinutes(10);
|
||||
}
|
||||
}
|
||||
@@ -864,6 +864,7 @@ namespace EonaCat.LogStack
|
||||
// Request cancellation of the async pipeline if it's still running
|
||||
_asyncCts?.Cancel();
|
||||
_asyncCts?.Dispose();
|
||||
_deadLetterQueue?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,21 +15,53 @@ namespace EonaCat.LogStack.DeadLettering;
|
||||
/// Dead Letter Queue (DLQ) for capturing failed log attempts and enabling replay.
|
||||
/// Superior to data loss as it preserves logs that couldn't be delivered to any flow.
|
||||
/// </summary>
|
||||
public class DeadLetterQueue
|
||||
public class DeadLetterQueue : IDisposable
|
||||
{
|
||||
private readonly ConcurrentQueue<DeadLetterEvent> _queue = new ConcurrentQueue<DeadLetterEvent>();
|
||||
private readonly int _maxCapacity;
|
||||
private readonly TimeSpan _maxRetention;
|
||||
private int _currentCount;
|
||||
private long _totalDropped;
|
||||
private long _totalEnqueued;
|
||||
private long _totalExpired;
|
||||
private readonly object _statsLock = new object();
|
||||
private bool _disposed;
|
||||
|
||||
public event EventHandler<DeadLetterEvent> EventEnqueued;
|
||||
public event EventHandler<DeadLetterEventReplayed> EventReplayed;
|
||||
|
||||
public DeadLetterQueue(int maxCapacity = 100000)
|
||||
/// <param name="maxCapacity">Maximum number of events held in memory.</param>
|
||||
/// <param name="maxRetention">
|
||||
/// Maximum time an undeliverable event is kept in memory before being dropped.
|
||||
/// Defaults to 10 minutes so a persistently unreachable destination cannot cause
|
||||
/// unbounded memory growth.
|
||||
/// </param>
|
||||
public DeadLetterQueue(int maxCapacity = 100000, TimeSpan? maxRetention = null)
|
||||
{
|
||||
_maxCapacity = Math.Max(1000, maxCapacity);
|
||||
_maxRetention = maxRetention ?? TimeSpan.FromMinutes(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes events that have exceeded the retention window. The queue is FIFO,
|
||||
/// so the oldest entries are always at the front.
|
||||
/// </summary>
|
||||
private void PurgeExpired()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
while (_queue.TryPeek(out var oldest) && now - oldest.EnqueuedAt > _maxRetention)
|
||||
{
|
||||
if (!_queue.TryDequeue(out _))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_currentCount--;
|
||||
lock (_statsLock)
|
||||
{
|
||||
_totalExpired++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,6 +69,7 @@ public class DeadLetterQueue
|
||||
/// </summary>
|
||||
public bool Enqueue(LogEvent logEvent, string failureReason, Exception failureException = null)
|
||||
{
|
||||
PurgeExpired();
|
||||
|
||||
if (_currentCount >= _maxCapacity)
|
||||
{
|
||||
@@ -71,13 +104,18 @@ public class DeadLetterQueue
|
||||
/// <summary>
|
||||
/// Gets the count of events in the DLQ.
|
||||
/// </summary>
|
||||
public int GetCount() => _currentCount;
|
||||
public int GetCount()
|
||||
{
|
||||
PurgeExpired();
|
||||
return _currentCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Peeks at the next event without removing it.
|
||||
/// </summary>
|
||||
public bool TryPeek(out DeadLetterEvent dlEvent)
|
||||
{
|
||||
PurgeExpired();
|
||||
return _queue.TryPeek(out dlEvent);
|
||||
}
|
||||
|
||||
@@ -86,6 +124,7 @@ public class DeadLetterQueue
|
||||
/// </summary>
|
||||
public bool TryDequeue(out DeadLetterEvent dlEvent)
|
||||
{
|
||||
PurgeExpired();
|
||||
if (_queue.TryDequeue(out var evt))
|
||||
{
|
||||
_currentCount--;
|
||||
@@ -102,6 +141,7 @@ public class DeadLetterQueue
|
||||
/// </summary>
|
||||
public List<DeadLetterEvent> GetAll(int skip = 0, int take = 100)
|
||||
{
|
||||
PurgeExpired();
|
||||
return _queue.Skip(skip).Take(take).ToList();
|
||||
}
|
||||
|
||||
@@ -183,6 +223,7 @@ public class DeadLetterQueue
|
||||
/// </summary>
|
||||
public DeadLetterQueueStats GetStats()
|
||||
{
|
||||
PurgeExpired();
|
||||
lock (_statsLock)
|
||||
{
|
||||
return new DeadLetterQueueStats
|
||||
@@ -191,6 +232,7 @@ public class DeadLetterQueue
|
||||
MaxCapacity = _maxCapacity,
|
||||
TotalEnqueued = _totalEnqueued,
|
||||
TotalDropped = _totalDropped,
|
||||
TotalExpired = _totalExpired,
|
||||
QueueUtilizationPercent = (_currentCount * 100.0) / _maxCapacity,
|
||||
OldestEventAge = _queue.TryPeek(out var oldest)
|
||||
? DateTime.UtcNow - oldest.EnqueuedAt
|
||||
@@ -199,6 +241,23 @@ public class DeadLetterQueue
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the queue and releases event subscribers.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Clear();
|
||||
EventEnqueued = null;
|
||||
EventReplayed = null;
|
||||
_disposed = true;
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets events grouped by failure reason.
|
||||
/// </summary>
|
||||
@@ -300,6 +359,11 @@ public class DeadLetterQueueStats
|
||||
/// </summary>
|
||||
public long TotalDropped { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total events dropped for exceeding the retention window.
|
||||
/// </summary>
|
||||
public long TotalExpired { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Queue utilization as percentage.
|
||||
/// </summary>
|
||||
|
||||
@@ -241,23 +241,19 @@ public sealed class ExceptionContextBuilder
|
||||
/// </summary>
|
||||
public static class ExceptionContextManager
|
||||
{
|
||||
private static readonly object _lock = new object();
|
||||
private static readonly Dictionary<Exception, ExceptionContext> _contextMap = new();
|
||||
// ConditionalWeakTable keys don't keep exceptions alive - entries are collected
|
||||
// automatically once the exception itself is no longer referenced elsewhere,
|
||||
// instead of accumulating forever in a regular static Dictionary.
|
||||
// Not readonly: Clear() below swaps in a new instance (ConditionalWeakTable.Clear()
|
||||
// isn't available on netstandard2.0/net4.8).
|
||||
private static System.Runtime.CompilerServices.ConditionalWeakTable<Exception, ExceptionContext> _contextMap = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates context for an exception
|
||||
/// </summary>
|
||||
public static ExceptionContext GetOrCreateContext(Exception ex)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_contextMap.TryGetValue(ex, out var context))
|
||||
{
|
||||
context = new ExceptionContext();
|
||||
_contextMap[ex] = context;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
return _contextMap.GetValue(ex, static _ => new ExceptionContext());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -267,10 +263,8 @@ public static class ExceptionContextManager
|
||||
{
|
||||
var builder = ExceptionContext.Create();
|
||||
configure(builder);
|
||||
lock (_lock)
|
||||
{
|
||||
_contextMap[ex] = builder.Build();
|
||||
}
|
||||
_contextMap.Remove(ex);
|
||||
_contextMap.Add(ex, builder.Build());
|
||||
return ex;
|
||||
}
|
||||
|
||||
@@ -279,11 +273,7 @@ public static class ExceptionContextManager
|
||||
/// </summary>
|
||||
public static ExceptionContext? TryGetContext(Exception ex)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_contextMap.TryGetValue(ex, out var context);
|
||||
return context;
|
||||
}
|
||||
return _contextMap.TryGetValue(ex, out var context) ? context : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -291,10 +281,7 @@ public static class ExceptionContextManager
|
||||
/// </summary>
|
||||
public static void Clear()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_contextMap.Clear();
|
||||
}
|
||||
_contextMap = new System.Runtime.CompilerServices.ConditionalWeakTable<Exception, ExceptionContext>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -179,6 +179,7 @@ namespace EonaCat.LogStack.Flows
|
||||
_cts.Cancel();
|
||||
_samplerThread.Join(TimeSpan.FromSeconds(3));
|
||||
_cts.Dispose();
|
||||
_proc.Dispose();
|
||||
await base.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ public class DlqFlow : FlowBase
|
||||
public DlqFlow(
|
||||
string name = "DLQ",
|
||||
LogLevel minimumLevel = LogLevel.Warning,
|
||||
int maxCapacity = 100000)
|
||||
int maxCapacity = 100000,
|
||||
TimeSpan? maxRetention = null)
|
||||
: base(name, minimumLevel)
|
||||
{
|
||||
_dlq = new DeadLetterQueue(maxCapacity);
|
||||
_dlq = new DeadLetterQueue(maxCapacity, maxRetention);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -214,6 +215,8 @@ public class DlqFlow : FlowBase
|
||||
catch { }
|
||||
}
|
||||
|
||||
_dlq.Dispose();
|
||||
|
||||
await base.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,9 @@ public sealed class LokiFlow : FlowBase
|
||||
private readonly Dictionary<string, string> _staticLabels;
|
||||
private readonly int _batchSize;
|
||||
private readonly TimeSpan _batchInterval;
|
||||
private readonly ConcurrentQueue<(string Labels, string Line, long NanoTs)> _queue = new();
|
||||
private readonly int _maxQueueSize;
|
||||
private readonly TimeSpan _maxRetention;
|
||||
private readonly ConcurrentQueue<(string Labels, string Line, long NanoTs, DateTime EnqueuedAt)> _queue = new();
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Task _flushTask;
|
||||
private readonly SemaphoreSlim _batchSignal = new(0, int.MaxValue);
|
||||
@@ -63,7 +65,9 @@ public sealed class LokiFlow : FlowBase
|
||||
string? basicUser = null,
|
||||
string? basicPassword = null,
|
||||
HttpClient? httpClient = null,
|
||||
LogLevel minimumLevel = LogLevel.Trace)
|
||||
LogLevel minimumLevel = LogLevel.Trace,
|
||||
int maxQueueSize = 20000,
|
||||
TimeSpan? maxRetention = null)
|
||||
: base("Loki", minimumLevel)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lokiUrl))
|
||||
@@ -75,6 +79,10 @@ public sealed class LokiFlow : FlowBase
|
||||
_staticLabels = labels ?? new Dictionary<string, string>();
|
||||
_batchSize = batchSize > 0 ? batchSize : 50;
|
||||
_batchInterval = TimeSpan.FromMilliseconds(batchIntervalMs > 0 ? batchIntervalMs : 1000);
|
||||
_maxQueueSize = maxQueueSize > 0 ? maxQueueSize : 20000;
|
||||
// Never hold undeliverable data longer than this, to avoid unbounded memory growth
|
||||
// when Loki is unreachable.
|
||||
_maxRetention = maxRetention ?? TimeSpan.FromMinutes(10);
|
||||
|
||||
if (httpClient != null)
|
||||
{
|
||||
@@ -112,9 +120,16 @@ public sealed class LokiFlow : FlowBase
|
||||
var line = BuildLine(logEvent);
|
||||
var nanoTs = logEvent.Timestamp * 100L; // ticks → nanoseconds
|
||||
|
||||
_queue.Enqueue((labels, line, nanoTs));
|
||||
_queue.Enqueue((labels, line, nanoTs, DateTime.UtcNow));
|
||||
Interlocked.Increment(ref BlastedCount);
|
||||
|
||||
// Drop oldest entries once over capacity so a slow/unreachable Loki instance
|
||||
// cannot grow this queue without bound.
|
||||
while (_queue.Count > _maxQueueSize && _queue.TryDequeue(out _))
|
||||
{
|
||||
Interlocked.Increment(ref DroppedCount);
|
||||
}
|
||||
|
||||
if (_queue.Count >= _batchSize)
|
||||
{
|
||||
_batchSignal.Release(1);
|
||||
@@ -168,8 +183,16 @@ public sealed class LokiFlow : FlowBase
|
||||
|
||||
// Group by label-set (Loki stream key)
|
||||
var groups = new Dictionary<string, List<(string Line, long NanoTs)>>(StringComparer.Ordinal);
|
||||
var now = DateTime.UtcNow;
|
||||
while (_queue.TryDequeue(out var item))
|
||||
{
|
||||
// Drop stale entries instead of holding/sending them indefinitely.
|
||||
if (now - item.EnqueuedAt > _maxRetention)
|
||||
{
|
||||
Interlocked.Increment(ref DroppedCount);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!groups.TryGetValue(item.Labels, out var list))
|
||||
{
|
||||
list = new List<(string, long)>(16);
|
||||
|
||||
@@ -677,12 +677,21 @@ public sealed class MessageTemplate
|
||||
private static bool IsArgumentDriven(object?[] args) => args.Length > 0;
|
||||
|
||||
// Template cache to avoid re-parsing the same strings
|
||||
private const int MaxCacheSize = 5000;
|
||||
private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, MessageTemplate> _cache
|
||||
= new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Returns a cached parsed template (recommended for hot paths).</summary>
|
||||
public static MessageTemplate FromCache(string template) =>
|
||||
_cache.GetOrAdd(template, static t => Parse(t));
|
||||
public static MessageTemplate FromCache(string template)
|
||||
{
|
||||
// Guard against unbounded growth if callers pass many unique/dynamic template strings.
|
||||
if (_cache.Count >= MaxCacheSize)
|
||||
{
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
return _cache.GetOrAdd(template, static t => Parse(t));
|
||||
}
|
||||
|
||||
/// <summary>Clears the template parse cache.</summary>
|
||||
public static void ClearCache() => _cache.Clear();
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
namespace EonaCat.LogStack.Telemetry;
|
||||
public sealed class TelemetryServer : IDisposable
|
||||
{
|
||||
private const int MaxEvents = 5000;
|
||||
private readonly HttpListener _listener = new();
|
||||
public ConcurrentQueue<TelemetryEvent> Events { get; } = new();
|
||||
public int Count => Events.Count;
|
||||
@@ -30,6 +31,10 @@ public sealed class TelemetryServer : IDisposable
|
||||
if (item != null)
|
||||
{
|
||||
Events.Enqueue(item);
|
||||
// Drop the oldest events once capacity is exceeded to avoid unbounded growth.
|
||||
while (Events.Count > MaxEvents && Events.TryDequeue(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (ctx.Request.Url?.AbsolutePath == "/")
|
||||
|
||||
Reference in new issue
Block a user