ironnotify-dotnet/OfflineQueue.cs

250 lines
6.2 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace IronNotify.Client;
/// <summary>
/// File-based offline queue for notification events.
/// Persists failed sends to disk and retries with exponential backoff.
/// </summary>
public class NotifyOfflineQueue : IDisposable
{
private readonly string _queueFilePath;
private readonly int _maxQueueSize;
private readonly object _fileLock = new();
private readonly SemaphoreSlim _retrySemaphore = new(1, 1);
private readonly Timer? _retryTimer;
private readonly Func<QueuedEvent, Task<bool>> _sendFunc;
private int _retryAttempt;
private bool _disposed;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = false
};
/// <summary>
/// Creates a new offline queue.
/// </summary>
/// <param name="sendFunc">Function to send an event to server. Returns true on success.</param>
/// <param name="queueDirectory">Directory to store queue file. Defaults to app data.</param>
/// <param name="maxQueueSize">Maximum items to store. Oldest items dropped when exceeded.</param>
/// <param name="enableAutoRetry">Whether to automatically retry sending queued items.</param>
public NotifyOfflineQueue(
Func<QueuedEvent, Task<bool>> sendFunc,
string? queueDirectory = null,
int maxQueueSize = 500,
bool enableAutoRetry = true)
{
_sendFunc = sendFunc;
_maxQueueSize = maxQueueSize;
var directory = queueDirectory ?? GetDefaultQueueDirectory();
Directory.CreateDirectory(directory);
_queueFilePath = Path.Combine(directory, "notify_queue.json");
if (enableAutoRetry)
{
// Start retry timer - initial delay 30 seconds, then every 60 seconds
_retryTimer = new Timer(
_ => _ = RetryQueuedItemsAsync(),
null,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(60));
}
}
/// <summary>
/// Number of items currently in the queue.
/// </summary>
public int Count
{
get
{
lock (_fileLock)
{
var items = LoadQueue();
return items.Count;
}
}
}
/// <summary>
/// Enqueue an event that failed to send.
/// </summary>
public void Enqueue(NotifyEventRequest request)
{
lock (_fileLock)
{
var queue = LoadQueue();
queue.Add(new QueuedEvent
{
QueuedAt = DateTime.UtcNow,
Request = request
});
// Trim to max size (remove oldest)
while (queue.Count > _maxQueueSize)
{
queue.RemoveAt(0);
}
SaveQueue(queue);
}
}
/// <summary>
/// Try to send all queued items.
/// </summary>
public async Task<int> RetryQueuedItemsAsync()
{
if (!await _retrySemaphore.WaitAsync(0))
{
// Already retrying
return 0;
}
int sentCount = 0;
try
{
List<QueuedEvent> items;
lock (_fileLock)
{
items = LoadQueue();
}
if (items.Count == 0)
{
_retryAttempt = 0;
return 0;
}
var remaining = new List<QueuedEvent>();
foreach (var item in items)
{
var success = await _sendFunc(item);
if (success)
{
sentCount++;
}
else
{
remaining.Add(item);
}
}
// Save remaining items
lock (_fileLock)
{
SaveQueue(remaining);
}
if (remaining.Count == 0)
{
_retryAttempt = 0;
}
else
{
_retryAttempt++;
}
return sentCount;
}
catch
{
_retryAttempt++;
return 0;
}
finally
{
_retrySemaphore.Release();
}
}
/// <summary>
/// Clear all queued items without sending.
/// </summary>
public void Clear()
{
lock (_fileLock)
{
SaveQueue(new List<QueuedEvent>());
}
}
/// <summary>
/// Get all queued items (for display/export).
/// </summary>
public List<QueuedEvent> GetQueuedItems()
{
lock (_fileLock)
{
return LoadQueue();
}
}
private List<QueuedEvent> LoadQueue()
{
try
{
if (!File.Exists(_queueFilePath))
{
return new List<QueuedEvent>();
}
var json = File.ReadAllText(_queueFilePath);
return JsonSerializer.Deserialize<List<QueuedEvent>>(json, JsonOptions)
?? new List<QueuedEvent>();
}
catch
{
return new List<QueuedEvent>();
}
}
private void SaveQueue(List<QueuedEvent> items)
{
try
{
var json = JsonSerializer.Serialize(items, JsonOptions);
File.WriteAllText(_queueFilePath, json);
}
catch
{
// Ignore write errors
}
}
private static string GetDefaultQueueDirectory()
{
return Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"IronNotify",
"Queue");
}
public void Dispose()
{
if (!_disposed)
{
_retryTimer?.Dispose();
_retrySemaphore.Dispose();
_disposed = true;
}
}
}
/// <summary>
/// A queued notification event with metadata.
/// </summary>
public class QueuedEvent
{
public DateTime QueuedAt { get; set; }
public int RetryCount { get; set; }
public NotifyEventRequest Request { get; set; } = new();
}