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(); }