using System; using System.Collections.Generic; using System.Data; using System.Text; using System.Threading; using Microsoft.Extensions.Logging; using Umbraco.Extensions; using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.IO; using Umbraco.Cms.Infrastructure.Persistence; using CoreDebugSettings = Umbraco.Cms.Core.Configuration.Models.CoreDebugSettings; namespace Umbraco.Cms.Core.Scoping { /// /// Implements . /// /// Not thread-safe obviously. internal class Scope : IScope { private readonly ScopeProvider _scopeProvider; private readonly CoreDebugSettings _coreDebugSettings; private readonly MediaFileManager _mediaFileManager; private readonly IEventAggregator _eventAggregator; private readonly ILogger _logger; private readonly IsolationLevel _isolationLevel; private readonly RepositoryCacheMode _repositoryCacheMode; private readonly bool? _scopeFileSystem; private readonly bool _autoComplete; private bool _callContext; private bool _disposed; private bool? _completed; private IsolatedCaches _isolatedCaches; private IUmbracoDatabase _database; private EventMessages _messages; private ICompletable _fscope; private IEventDispatcher _eventDispatcher; private IScopedNotificationPublisher _notificationPublisher; private readonly object _dictionaryLocker; private HashSet _readLocks; private HashSet _writeLocks; private Dictionary> _readLockDictionary; private Dictionary> _writeLockDictionary; internal Dictionary> ReadLocks => _readLockDictionary; internal Dictionary> WriteLocks => _writeLockDictionary; // initializes a new scope private Scope( ScopeProvider scopeProvider, CoreDebugSettings coreDebugSettings, MediaFileManager mediaFileManager, IEventAggregator eventAggregator, ILogger logger, FileSystems fileSystems, Scope parent, IScopeContext scopeContext, bool detachable, IsolationLevel isolationLevel = IsolationLevel.Unspecified, RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified, IEventDispatcher eventDispatcher = null, IScopedNotificationPublisher notificationPublisher = null, bool? scopeFileSystems = null, bool callContext = false, bool autoComplete = false) { _scopeProvider = scopeProvider; _coreDebugSettings = coreDebugSettings; _mediaFileManager = mediaFileManager; _eventAggregator = eventAggregator; _logger = logger; Context = scopeContext; _isolationLevel = isolationLevel; _repositoryCacheMode = repositoryCacheMode; _eventDispatcher = eventDispatcher; _notificationPublisher = notificationPublisher; _scopeFileSystem = scopeFileSystems; _callContext = callContext; _autoComplete = autoComplete; Detachable = detachable; _dictionaryLocker = new object(); #if DEBUG_SCOPES _scopeProvider.RegisterScope(this); #endif logger.LogTrace("Create {InstanceId} on thread {ThreadId}", InstanceId.ToString("N").Substring(0, 8), Thread.CurrentThread.ManagedThreadId); if (detachable) { if (parent != null) { throw new ArgumentException("Cannot set parent on detachable scope.", nameof(parent)); } if (scopeContext != null) { throw new ArgumentException("Cannot set context on detachable scope.", nameof(scopeContext)); } if (autoComplete) { throw new ArgumentException("Cannot auto-complete a detachable scope.", nameof(autoComplete)); } // detachable creates its own scope context Context = new ScopeContext(); // see note below if (scopeFileSystems == true) { _fscope = fileSystems.Shadow(); } return; } if (parent != null) { ParentScope = parent; // cannot specify a different mode! // TODO: means that it's OK to go from L2 to None for reading purposes, but writing would be BAD! // this is for XmlStore that wants to bypass caches when rebuilding XML (same for NuCache) if (repositoryCacheMode != RepositoryCacheMode.Unspecified && parent.RepositoryCacheMode > repositoryCacheMode) { throw new ArgumentException($"Value '{repositoryCacheMode}' cannot be lower than parent value '{parent.RepositoryCacheMode}'.", nameof(repositoryCacheMode)); } // cannot specify a dispatcher! if (_eventDispatcher != null) { throw new ArgumentException("Value cannot be specified on nested scope.", nameof(eventDispatcher)); } // Only the outermost scope can specify the notification publisher if (_notificationPublisher != null) { throw new ArgumentException("Value cannot be specified on nested scope.", nameof(notificationPublisher)); } // cannot specify a different fs scope! // can be 'true' only on outer scope (and false does not make much sense) if (scopeFileSystems != null && parent._scopeFileSystem != scopeFileSystems) { throw new ArgumentException($"Value '{scopeFileSystems.Value}' be different from parent value '{parent._scopeFileSystem}'.", nameof(scopeFileSystems)); } } else { // the FS scope cannot be "on demand" like the rest, because we would need to hook into // every scoped FS to trigger the creation of shadow FS "on demand", and that would be // pretty pointless since if scopeFileSystems is true, we *know* we want to shadow if (scopeFileSystems == true) { _fscope = fileSystems.Shadow(); } } } // initializes a new scope public Scope( ScopeProvider scopeProvider, CoreDebugSettings coreDebugSettings, MediaFileManager mediaFileManager, IEventAggregator eventAggregator, ILogger logger, FileSystems fileSystems, bool detachable, IScopeContext scopeContext, IsolationLevel isolationLevel = IsolationLevel.Unspecified, RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified, IEventDispatcher eventDispatcher = null, IScopedNotificationPublisher scopedNotificationPublisher = null, bool? scopeFileSystems = null, bool callContext = false, bool autoComplete = false) : this(scopeProvider, coreDebugSettings, mediaFileManager, eventAggregator, logger, fileSystems, null, scopeContext, detachable, isolationLevel, repositoryCacheMode, eventDispatcher, scopedNotificationPublisher, scopeFileSystems, callContext, autoComplete) { } // initializes a new scope in a nested scopes chain, with its parent public Scope( ScopeProvider scopeProvider, CoreDebugSettings coreDebugSettings, MediaFileManager mediaFileManager, IEventAggregator eventAggregator, ILogger logger, FileSystems fileSystems, Scope parent, IsolationLevel isolationLevel = IsolationLevel.Unspecified, RepositoryCacheMode repositoryCacheMode = RepositoryCacheMode.Unspecified, IEventDispatcher eventDispatcher = null, IScopedNotificationPublisher notificationPublisher = null, bool? scopeFileSystems = null, bool callContext = false, bool autoComplete = false) : this(scopeProvider, coreDebugSettings, mediaFileManager, eventAggregator, logger, fileSystems, parent, null, false, isolationLevel, repositoryCacheMode, eventDispatcher, notificationPublisher, scopeFileSystems, callContext, autoComplete) { } public Guid InstanceId { get; } = Guid.NewGuid(); public int CreatedThreadId { get; } = Thread.CurrentThread.ManagedThreadId; public ISqlContext SqlContext => _scopeProvider.SqlContext; // a value indicating whether to force call-context public bool CallContext { get { if (_callContext) { return true; } if (ParentScope != null) { return ParentScope.CallContext; } return false; } set => _callContext = value; } public bool ScopedFileSystems { get { if (ParentScope != null) { return ParentScope.ScopedFileSystems; } return _fscope != null; } } /// public RepositoryCacheMode RepositoryCacheMode { get { if (_repositoryCacheMode != RepositoryCacheMode.Unspecified) { return _repositoryCacheMode; } if (ParentScope != null) { return ParentScope.RepositoryCacheMode; } return RepositoryCacheMode.Default; } } /// public IsolatedCaches IsolatedCaches { get { if (ParentScope != null) { return ParentScope.IsolatedCaches; } return _isolatedCaches ??= new IsolatedCaches(type => new DeepCloneAppCache(new ObjectCacheAppCache())); } } // a value indicating whether the scope is detachable // ie whether it was created by CreateDetachedScope public bool Detachable { get; } // the parent scope (in a nested scopes chain) public Scope ParentScope { get; set; } public bool Attached { get; set; } // the original scope (when attaching a detachable scope) public Scope OrigScope { get; set; } // the original context (when attaching a detachable scope) public IScopeContext OrigContext { get; set; } // the context (for attaching & detaching only) public IScopeContext Context { get; } public IsolationLevel IsolationLevel { get { if (_isolationLevel != IsolationLevel.Unspecified) { return _isolationLevel; } if (ParentScope != null) { return ParentScope.IsolationLevel; } return Database.SqlContext.SqlSyntax.DefaultIsolationLevel; } } /// public IUmbracoDatabase Database { get { EnsureNotDisposed(); if (_database != null) { return _database; } if (ParentScope != null) { IUmbracoDatabase database = ParentScope.Database; IsolationLevel currentLevel = database.GetCurrentTransactionIsolationLevel(); if (_isolationLevel > IsolationLevel.Unspecified && currentLevel < _isolationLevel) { throw new Exception("Scope requires isolation level " + _isolationLevel + ", but got " + currentLevel + " from parent."); } return _database = database; } // create a new database _database = _scopeProvider.DatabaseFactory.CreateDatabase(); // enter a transaction, as a scope implies a transaction, always try { _database.BeginTransaction(IsolationLevel); return _database; } catch { _database.Dispose(); _database = null; throw; } } } public IUmbracoDatabase DatabaseOrNull { get { EnsureNotDisposed(); return ParentScope == null ? _database : ParentScope.DatabaseOrNull; } } /// public EventMessages Messages { get { EnsureNotDisposed(); if (ParentScope != null) { return ParentScope.Messages; } return _messages ??= new EventMessages(); // TODO: event messages? // this may be a problem: the messages collection will be cleared at the end of the scope // how shall we process it in controllers etc? if we don't want the global factory from v7? // it'd need to be captured by the controller // // + rename // EventMessages = ServiceMessages or something } } public EventMessages MessagesOrNull { get { EnsureNotDisposed(); return ParentScope == null ? _messages : ParentScope.MessagesOrNull; } } /// public IEventDispatcher Events { get { EnsureNotDisposed(); if (ParentScope != null) { return ParentScope.Events; } return _eventDispatcher ??= new QueuingEventDispatcher(_mediaFileManager); } } public IScopedNotificationPublisher Notifications { get { EnsureNotDisposed(); if (ParentScope != null) { return ParentScope.Notifications; } return _notificationPublisher ?? (_notificationPublisher = new ScopedNotificationPublisher(_eventAggregator)); } } /// public bool Complete() { if (_completed.HasValue == false) { _completed = true; } return _completed.Value; } public void Reset() => _completed = null; public void ChildCompleted(bool? completed) { // if child did not complete we cannot complete if (completed.HasValue == false || completed.Value == false) { if (LogUncompletedScopes) { _logger.LogWarning("Uncompleted Child Scope at\r\n {StackTrace}", Environment.StackTrace); } _completed = false; } } private void EnsureNotDisposed() { // We can't be disposed if (_disposed) { throw new ObjectDisposedException($"The {nameof(Scope)} ({this.GetDebugInfo()}) is already disposed"); } // And neither can our ancestors if we're trying to be disposed since // a child must always be disposed before it's parent. // This is a safety check, it's actually not entirely possible that a parent can be // disposed before the child since that will end up with a "not the Ambient" exception. ParentScope?.EnsureNotDisposed(); // TODO: safer? //if (Interlocked.CompareExchange(ref _disposed, 1, 0) != 0) // throw new ObjectDisposedException(GetType().FullName); } public void Dispose() { EnsureNotDisposed(); if (this != _scopeProvider.AmbientScope) { var failedMessage = $"The {nameof(Scope)} {this.InstanceId} being disposed is not the Ambient {nameof(Scope)} {(_scopeProvider.AmbientScope?.InstanceId.ToString() ?? "NULL")}. This typically indicates that a child {nameof(Scope)} was not disposed, or flowed to a child thread that was not awaited, or concurrent threads are accessing the same {nameof(Scope)} (Ambient context) which is not supported. If using Task.Run (or similar) as a fire and forget tasks or to run threads in parallel you must suppress execution context flow with ExecutionContext.SuppressFlow() and ExecutionContext.RestoreFlow()."; #if DEBUG_SCOPES Scope ambient = _scopeProvider.AmbientScope; _logger.LogWarning("Dispose error (" + (ambient == null ? "no" : "other") + " ambient)"); if (ambient == null) { throw new InvalidOperationException("Not the ambient scope (no ambient scope)."); } ScopeInfo ambientInfos = _scopeProvider.GetScopeInfo(ambient); ScopeInfo disposeInfos = _scopeProvider.GetScopeInfo(this); throw new InvalidOperationException($"{failedMessage} (see ctor stack traces).\r\n" + "- ambient ->\r\n" + ambientInfos.ToString() + "\r\n" + "- dispose ->\r\n" + disposeInfos.ToString() + "\r\n"); #else throw new InvalidOperationException(failedMessage); #endif } // Decrement the lock counters on the parent if any. ClearLocks(InstanceId); if (ParentScope is null) { // We're the parent scope, make sure that locks of all scopes has been cleared // Since we're only reading we don't have to be in a lock if (ReadLocks?.Count > 0 || WriteLocks?.Count > 0) { var exception = new InvalidOperationException($"All scopes has not been disposed from parent scope: {InstanceId}, see log for more details."); _logger.LogError(exception, GenerateUnclearedScopesLogMessage()); throw exception; } } _scopeProvider.PopAmbientScope(this); // might be null = this is how scopes are removed from context objects #if DEBUG_SCOPES _scopeProvider.Disposed(this); #endif if (_autoComplete && _completed == null) { _completed = true; } if (ParentScope != null) { ParentScope.ChildCompleted(_completed); } else { DisposeLastScope(); } _disposed = true; } /// /// Generates a log message with all scopes that hasn't cleared their locks, including how many, and what locks they have requested. /// /// Log message. private string GenerateUnclearedScopesLogMessage() { // Dump the dicts into a message for the locks. StringBuilder builder = new StringBuilder(); builder.AppendLine($"Lock counters aren't empty, suggesting a scope hasn't been properly disposed, parent id: {InstanceId}"); WriteLockDictionaryToString(ReadLocks, builder, "read locks"); WriteLockDictionaryToString(WriteLocks, builder, "write locks"); return builder.ToString(); } /// /// Writes a locks dictionary to a for logging purposes. /// /// Lock dictionary to report on. /// String builder to write to. /// The name to report the dictionary as. private void WriteLockDictionaryToString(Dictionary> dict, StringBuilder builder, string dictName) { if (dict?.Count > 0) { builder.AppendLine($"Remaining {dictName}:"); foreach (var instance in dict) { builder.AppendLine($"Scope {instance.Key}"); foreach (var lockCounter in instance.Value) { builder.AppendLine($"\tLock ID: {lockCounter.Key} - times requested: {lockCounter.Value}"); } } } } private void DisposeLastScope() { // figure out completed var completed = _completed.HasValue && _completed.Value; // deal with database var databaseException = false; if (_database != null) { try { if (completed) { _database.CompleteTransaction(); } else { _database.AbortTransaction(); } } catch { databaseException = true; throw; } finally { _database.Dispose(); _database = null; if (databaseException) { RobustExit(false, true); } } } RobustExit(completed, false); } // this chains some try/finally blocks to // - complete and dispose the scoped filesystems // - deal with events if appropriate // - remove the scope context if it belongs to this scope // - deal with detachable scopes // here, // - completed indicates whether the scope has been completed // can be true or false, but in both cases the scope is exiting // in a normal way // - onException indicates whether completing/aborting the database // transaction threw an exception, in which case 'completed' has // to be false + events don't trigger and we just to some cleanup // to ensure we don't leave a scope around, etc private void RobustExit(bool completed, bool onException) { if (onException) { completed = false; } TryFinally(() => { if (_scopeFileSystem == true) { if (completed) { _fscope.Complete(); } _fscope.Dispose(); _fscope = null; } }, () => { // deal with events if (onException == false) { _eventDispatcher?.ScopeExit(completed); _notificationPublisher?.ScopeExit(completed); } }, () => { // if *we* created it, then get rid of it if (_scopeProvider.AmbientContext == Context) { try { _scopeProvider.AmbientContext.ScopeExit(completed); } finally { // removes the ambient context (ambient scope already gone) _scopeProvider.PopAmbientScopeContext(); } } }, () => { if (Detachable) { // get out of the way, restore original // TODO: Difficult to know if this is correct since this is all required // by Deploy which I don't fully understand since there is limited tests on this in the CMS if (OrigScope != _scopeProvider.AmbientScope) { _scopeProvider.PopAmbientScope(_scopeProvider.AmbientScope); } if (OrigContext != _scopeProvider.AmbientContext) { _scopeProvider.PopAmbientScopeContext(); } Attached = false; OrigScope = null; OrigContext = null; } }); } private static void TryFinally(params Action[] actions) => TryFinally(0, actions); private static void TryFinally(int index, Action[] actions) { if (index == actions.Length) { return; } try { actions[index](); } finally { TryFinally(index + 1, actions); } } // true if Umbraco.CoreDebugSettings.LogUncompletedScope appSetting is set to "true" private bool LogUncompletedScopes => _coreDebugSettings.LogIncompletedScopes; /// /// Increment the counter of a locks dictionary, either ReadLocks or WriteLocks, /// for a specific scope instance and lock identifier. Must be called within a lock. /// /// Lock ID to increment. /// Instance ID of the scope requesting the lock. /// Reference to the dictionary to increment on private void IncrementLock(int lockId, Guid instanceId, ref Dictionary> locks) { // Since we've already checked that we're the parent in the WriteLockInner method, we don't need to check again. // If it's the very first time a lock has been requested the WriteLocks dict hasn't been instantiated yet. locks ??= new Dictionary>(); // Try and get the dict associated with the scope id. var locksDictFound = locks.TryGetValue(instanceId, out var locksDict); if (locksDictFound) { locksDict.TryGetValue(lockId, out var value); locksDict[lockId] = value + 1; } else { // The scope hasn't requested a lock yet, so we have to create a dict for it. locks.Add(instanceId, new Dictionary()); locks[instanceId][lockId] = 1; } } /// /// Clears all lock counters for a given scope instance, signalling that the scope has been disposed. /// /// Instance ID of the scope to clear. private void ClearLocks(Guid instanceId) { if (ParentScope is not null) { ParentScope.ClearLocks(instanceId); } else { lock (_dictionaryLocker) { ReadLocks?.Remove(instanceId); WriteLocks?.Remove(instanceId); } } } /// public void ReadLock(int lockId) => ReadLockInner(InstanceId, null, lockId); /// public void ReadLock(TimeSpan timeout, int lockId) => ReadLockInner(InstanceId, timeout, lockId); /// public void WriteLock(int lockId) => WriteLockInner(InstanceId, null, lockId); /// public void WriteLock(TimeSpan timeout, int lockId) => WriteLockInner(InstanceId, timeout, lockId); /// /// Handles acquiring a read lock, will delegate it to the parent if there are any. /// /// Instance ID of the requesting scope. /// Optional database timeout in milliseconds. /// Array of lock object identifiers. private void ReadLockInner(Guid instanceId, TimeSpan? timeout, int lockId) { if (ParentScope is not null) { // If we have a parent we delegate lock creation to parent. ParentScope.ReadLockInner(instanceId, timeout, lockId); } else { // We are the outermost scope, handle the lock request. LockInner(instanceId, ref _readLockDictionary, ref _readLocks, ObtainReadLock, ObtainTimeoutReadLock, timeout, lockId); } } /// /// Handles acquiring a write lock with a specified timeout, will delegate it to the parent if there are any. /// /// Instance ID of the requesting scope. /// Optional database timeout in milliseconds. /// Array of lock object identifiers. private void WriteLockInner(Guid instanceId, TimeSpan? timeout, int lockId) { if (ParentScope is not null) { // If we have a parent we delegate lock creation to parent. ParentScope.WriteLockInner(instanceId, timeout, lockId); } else { // We are the outermost scope, handle the lock request. LockInner(instanceId, ref _writeLockDictionary, ref _writeLocks, ObtainWriteLock, ObtainTimeoutWriteLock, timeout, lockId); } } /// /// Handles acquiring a lock, this should only be called from the outermost scope. /// /// Instance ID of the scope requesting the lock. /// Reference to the applicable locks dictionary (ReadLocks or WriteLocks). /// Reference to the applicable locks hashset (_readLocks or _writeLocks). /// Delegate used to request the lock from the database without a timeout. /// Delegate used to request the lock from the database with a timeout. /// Optional timeout parameter to specify a timeout. /// Lock identifiers to lock on. private void LockInner(Guid instanceId, ref Dictionary> locks, ref HashSet locksSet, Action obtainLock, Action obtainLockTimeout, TimeSpan? timeout, int lockId) { lock (_dictionaryLocker) { locksSet ??= new HashSet(); // Only acquire the lock if we haven't done so yet. if (!locksSet.Contains(lockId)) { IncrementLock(lockId, instanceId, ref locks); locksSet.Add(lockId); try { if (timeout is null) { // We just want an ordinary lock. obtainLock(lockId); } else { // We want a lock with a custom timeout obtainLockTimeout(lockId, timeout.Value); } } catch { // Something went wrong and we didn't get the lock // Since we at this point have determined that we haven't got any lock with an ID of LockID, it's safe to completely remove it instead of decrementing. locks[instanceId].Remove(lockId); // It needs to be removed from the HashSet as well, because that's how we determine to acquire a lock. locksSet.Remove(lockId); throw; } } else { // We already have a lock, but need to update the dictionary for debugging purposes. IncrementLock(lockId, instanceId, ref locks); } } } /// /// Obtains an ordinary read lock. /// /// Lock object identifier to lock. private void ObtainReadLock(int lockId) { Database.SqlContext.SqlSyntax.ReadLock(Database, lockId); } /// /// Obtains a read lock with a custom timeout. /// /// Lock object identifier to lock. /// TimeSpan specifying the timout period. private void ObtainTimeoutReadLock(int lockId, TimeSpan timeout) { Database.SqlContext.SqlSyntax.ReadLock(Database, timeout, lockId); } /// /// Obtains an ordinary write lock. /// /// Lock object identifier to lock. private void ObtainWriteLock(int lockId) { Database.SqlContext.SqlSyntax.WriteLock(Database, lockId); } /// /// Obtains a write lock with a custom timeout. /// /// Lock object identifier to lock. /// TimeSpan specifying the timout period. private void ObtainTimeoutWriteLock(int lockId, TimeSpan timeout) { Database.SqlContext.SqlSyntax.WriteLock(Database, timeout, lockId); } } }