using System.Runtime.ExceptionServices;
using AcDream.App.Rendering;
namespace AcDream.App.Composition;
///
/// Owns resources acquired inside one startup-composition phase until each
/// resource is published to its long-lived owner. Unpublished resources roll
/// back in reverse dependency order; successful cleanup never replays.
///
internal sealed class CompositionAcquisitionScope : IRetryableResourceCleanup
{
internal enum EntryState
{
Owned,
Transferred,
Released,
}
internal sealed class Entry(string name, object resource, Action release)
{
public string Name { get; } = name;
public object Resource { get; } = resource;
public Action Release { get; } = release;
public EntryState State { get; set; } = EntryState.Owned;
}
private readonly List _entries = [];
private bool _cleanupActive;
private bool _closed;
public bool IsCleanupComplete =>
_entries.All(static entry => entry.State is not EntryState.Owned);
public CompositionAcquisitionLease Acquire(
string name,
Func factory,
Action release)
where T : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(release);
EnsureAcceptingOwnership();
T resource = factory()
?? throw new InvalidOperationException(
$"Composition factory '{name}' returned null.");
return Own(name, resource, release);
}
///
/// Campaign V slice V6h: acquires a resource a backend may legitimately not
/// have. A null factory result is an absent owner, not a failure — the
/// publication still runs so the long-lived shell records the same slot on
/// both backends — and nothing enters the rollback ledger.
///
public CompositionAcquisitionOptionalLease AcquireOptional(
string name,
Func factory,
Action release)
where T : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(factory);
ArgumentNullException.ThrowIfNull(release);
EnsureAcceptingOwnership();
T? resource = factory();
return resource is null
? new CompositionAcquisitionOptionalLease(null)
: new CompositionAcquisitionOptionalLease(
Own(name, resource, release));
}
public CompositionAcquisitionLease Own(
string name,
T resource,
Action release)
where T : class
{
ArgumentException.ThrowIfNullOrWhiteSpace(name);
ArgumentNullException.ThrowIfNull(resource);
ArgumentNullException.ThrowIfNull(release);
EnsureAcceptingOwnership();
var entry = new Entry(name, resource, () => release(resource));
_entries.Add(entry);
return new CompositionAcquisitionLease(
this,
entry,
resource);
}
///
/// Closes a successful phase. Every acquisition must already have moved to
/// a typed long-lived owner or aggregate owner.
///
public void Complete()
{
if (_cleanupActive)
throw new InvalidOperationException(
"Composition cleanup is currently active.");
if (_closed)
return;
if (!IsCleanupComplete)
{
string pending = string.Join(
", ",
_entries
.Where(static entry => entry.State == EntryState.Owned)
.Select(static entry => entry.Name));
throw new InvalidOperationException(
$"Composition phase completed with unpublished resources: {pending}.");
}
_closed = true;
}
///
/// Rolls back the unpublished prefix and then rethrows the construction
/// failure. If cleanup itself fails, the returned exception retains this
/// exact scope as retry ownership.
///
public void RollbackAndThrow(Exception constructionFailure)
{
ArgumentNullException.ThrowIfNull(constructionFailure);
_closed = true;
try
{
RetryCleanup();
}
catch (AggregateException cleanupFailure)
{
var failures = new List { constructionFailure };
failures.AddRange(cleanupFailure.InnerExceptions);
throw new CompositionAcquisitionException(
"Startup composition failed and rollback remains incomplete.",
this,
failures);
}
ExceptionDispatchInfo.Capture(constructionFailure).Throw();
}
public void RetryCleanup()
{
if (_cleanupActive || IsCleanupComplete)
return;
_closed = true;
_cleanupActive = true;
List? failures = null;
try
{
for (int i = _entries.Count - 1; i >= 0; i--)
{
Entry entry = _entries[i];
if (entry.State != EntryState.Owned)
continue;
try
{
entry.Release();
entry.State = EntryState.Released;
}
catch (Exception failure)
{
(failures ??= []).Add(new InvalidOperationException(
$"Composition cleanup operation '{entry.Name}' failed.",
failure));
}
}
}
finally
{
_cleanupActive = false;
}
if (failures is not null)
{
throw new AggregateException(
"Startup composition rollback remains incomplete.",
failures);
}
}
private void EnsureAcceptingOwnership()
{
if (_closed || _cleanupActive)
throw new InvalidOperationException(
"The composition acquisition scope is no longer accepting ownership.");
}
private void Transfer(Entry entry, T expected)
where T : class
{
if (_closed || _cleanupActive)
throw new InvalidOperationException(
"The composition acquisition scope is no longer transferable.");
if (!ReferenceEquals(entry.Resource, expected))
throw new InvalidOperationException(
"The acquisition lease does not own the expected resource.");
if (entry.State != EntryState.Owned)
throw new InvalidOperationException(
$"Composition resource '{entry.Name}' has already been transferred.");
entry.State = EntryState.Transferred;
}
internal sealed class CompositionAcquisitionLease
where T : class
{
private readonly CompositionAcquisitionScope _scope;
private readonly Entry _entry;
internal CompositionAcquisitionLease(
CompositionAcquisitionScope scope,
Entry entry,
T resource)
{
_scope = scope;
_entry = entry;
Resource = resource;
}
public T Resource { get; }
public T Transfer()
{
_scope.Transfer(_entry, Resource);
return Resource;
}
public T Publish(Action publish)
{
ArgumentNullException.ThrowIfNull(publish);
publish(Resource);
return Transfer();
}
}
/// A lease over a resource the active backend may not own at all.
internal sealed class CompositionAcquisitionOptionalLease(
CompositionAcquisitionLease? inner)
where T : class
{
public T? Resource => inner?.Resource;
public T? Transfer() => inner?.Transfer();
public T? Publish(Action publish)
{
ArgumentNullException.ThrowIfNull(publish);
if (inner is null)
{
publish(null);
return null;
}
publish(inner.Resource);
return inner.Transfer();
}
}
}
///
/// Construction failure whose incomplete rollback remains retryable by the
/// process lifetime cleanup ledger.
///
internal sealed class CompositionAcquisitionException : AggregateException,
IRetryableResourceCleanup
{
private readonly IRetryableResourceCleanup _cleanup;
public CompositionAcquisitionException(
string message,
IRetryableResourceCleanup cleanup,
IEnumerable failures)
: base(message, failures)
{
_cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup));
}
public bool IsCleanupComplete => _cleanup.IsCleanupComplete;
public void RetryCleanup() => _cleanup.RetryCleanup();
}