using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Session;
///
/// Records keychain changes into the vault they happened in, without making the save wait.
///
///
///
/// 's shape, and the reason is the same one stated a different way: the
/// caller is a Save the user is watching, and an encrypt-and-write on that path would put the log's cost
/// into every edit. So posts to a bounded channel and returns, and one background task
/// does the work.
///
///
/// Session-scoped, unlike the connection recorder. This one is created with the vault and dies with
/// it — there is no equivalent of a shell that outlives a lock, because an edit is finished by the time it
/// is recorded. That is why it is owned by rather than by the shell.
///
///
/// Every failure is swallowed. A log write that failed and surfaced would fail a save, and the whole
/// premise of the outbox is that saving works offline and cannot be refused. What is lost when this drops
/// something is one advisory line.
///
///
internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable
{
///
private const int QueueDepth = 512;
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
private readonly Channel pending = Channel.CreateBounded(
new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly ActivityLogRepository log;
private readonly Guid vaultId;
private readonly Guid actorUserId;
private readonly string deviceName;
private readonly TimeProvider clock;
private readonly CancellationTokenSource lifetime = new();
private readonly Task drain;
private int disposed;
/// Where entries go.
/// The vault they belong to.
/// Which account is making them.
/// What this machine calls itself.
/// Time source.
internal ActivityRecorder(
ActivityLogRepository log,
Guid vaultId,
Guid actorUserId,
string deviceName,
TimeProvider clock)
{
this.log = log;
this.vaultId = vaultId;
this.actorUserId = actorUserId;
this.deviceName = deviceName;
this.clock = clock;
drain = DrainAsync(lifetime.Token);
}
///
public void Record(
Guid vaultId,
SyncEntityType kind,
Guid entityId,
string label,
ActivityOperation operation,
IReadOnlyList changedFields)
{
ArgumentNullException.ThrowIfNull(changedFields);
if (vaultId != this.vaultId)
{
// A write to a vault this recorder is not for. Not currently reachable — one session, one active
// vault — and refused rather than filed under the wrong one, because that is the failure that
// would be hardest to notice once shared vaults land.
return;
}
var entry = new ActivityLogSecret
{
// The name rather than the number, so a build that has never heard of a kind still shows
// something a person can read. See ActivityLogSecretCodec.
ItemKind = Enum.GetName(kind) ?? kind.ToString(),
ItemId = entityId,
ItemLabel = label,
Operation = operation,
ChangedFields = string.Join(", ", changedFields),
At = clock.GetUtcNow(),
DeviceName = deviceName,
ActorUserId = actorUserId,
};
pending.Writer.TryWrite(entry);
}
///
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
pending.Writer.TryComplete();
try
{
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// Whatever is left goes unwritten, which is the same trade the queue's own DropOldest makes.
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await drain.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: cancelling is how the loop is asked to stop.
}
lifetime.Dispose();
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var entry in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await log.CreateAsync(vaultId, entry, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Swallowed. There is no caller left to tell, and the realistic failure is a cache that
// has gone away underneath a session being disposed.
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
}