diff --git a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlCacheFilePersister.cs b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlCacheFilePersister.cs index 07e1f055cf..fced0ce5d7 100644 --- a/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlCacheFilePersister.cs +++ b/src/Umbraco.Web/PublishedCache/XmlPublishedCache/XmlCacheFilePersister.cs @@ -1,9 +1,6 @@ using System; -using System.Diagnostics; -using System.IO; using System.Threading; using System.Threading.Tasks; -using System.Xml; using umbraco; using Umbraco.Core; using Umbraco.Core.Logging; @@ -23,14 +20,19 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache internal class XmlCacheFilePersister : ILatchedBackgroundTask { private readonly IBackgroundTaskRunner _runner; - private readonly string _xmlFileName; - //private readonly ProfilingLogger _logger; private readonly content _content; private readonly ManualResetEventSlim _latch = new ManualResetEventSlim(false); private readonly object _locko = new object(); private bool _released; private Timer _timer; private DateTime _initialTouch; + private readonly AsyncLock _runLock = new AsyncLock(); // ensure we run once at a time + + // note: + // as long as the runner controls the runs, we know that we run once at a time, but + // when the AppDomain goes down and the runner has completed and yet the persister is + // asked to save, then we need to run immediately - but the runner may be running, so + // we need to make sure there's no collision - hence _runLock private const int WaitMilliseconds = 4000; // save the cache 4s after the last change (ie every 4s min) private const int MaxWaitMilliseconds = 30000; // save the cache after some time (ie no more than 30s of changes) @@ -38,85 +40,115 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache // save the cache when the app goes down public bool RunsOnShutdown { get { return true; } } - public XmlCacheFilePersister(IBackgroundTaskRunner runner, content content, string xmlFileName, /*ProfilingLogger logger, */bool touched = false) + // initialize the first instance, which is inactive (not touched yet) + public XmlCacheFilePersister(IBackgroundTaskRunner runner, content content) + : this(runner, content, false) + { } + + private XmlCacheFilePersister(IBackgroundTaskRunner runner, content content, bool touched) { _runner = runner; _content = content; - _xmlFileName = xmlFileName; - //_logger = logger; + + if (runner.TryAdd(this) == false) + { + _runner = null; // runner's down + _released = true; // don't mess with timer + return; + } + + // runner could decide to run it anytime now if (touched == false) return; - LogHelper.Debug("Create new touched, start."); - + LogHelper.Debug("Created, save in {0}ms.", () => WaitMilliseconds); _initialTouch = DateTime.Now; - _timer = new Timer(_ => Release()); - - LogHelper.Debug("Save in {0}ms.", () => WaitMilliseconds); + _timer = new Timer(_ => TimerRelease()); _timer.Change(WaitMilliseconds, 0); } public XmlCacheFilePersister Touch() { + // if _released is false then we're going to setup a timer + // then the runner wants to shutdown & run immediately + // this sets _released to true & the timer will trigger eventualy & who cares? + // if _released is true, either it's a normal release, or + // a runner shutdown, in which case we won't be able to + // add a new task, and so we'll run immediately + + var ret = this; + var runNow = false; + lock (_locko) { - if (_released) + if (_released) // our timer has triggered OR the runner is shutting down { - LogHelper.Debug("Touched, was released, create new."); + LogHelper.Debug("Touched, was released..."); - // released, has run or is running, too late, add & return a new task - var persister = new XmlCacheFilePersister(_runner, _content, _xmlFileName, /*_logger, */true); - _runner.Add(persister); - return persister; + // release: has run or is running, too late, return a new task (adds itself to runner) + if (_runner == null) + { + LogHelper.Debug("Runner is down, run now."); + runNow = true; + } + else + { + LogHelper.Debug("Create new..."); + ret = new XmlCacheFilePersister(_runner, _content, true); + if (ret._runner == null) + { + // could not enlist with the runner, runner is completed, must run now + LogHelper.Debug("Runner is down, run now."); + runNow = true; + } + } } - if (_timer == null) + else if (_timer == null) // we don't have a timer yet { - LogHelper.Debug("Touched, was idle, start."); - - // not started yet, start + LogHelper.Debug("Touched, was idle, start and save in {0}ms."); _initialTouch = DateTime.Now; - _timer = new Timer(_ => Release()); - LogHelper.Debug("Save in {0}ms.", () => WaitMilliseconds); - _timer.Change(WaitMilliseconds, 0); - return this; - } - - // set the timer to trigger in WaitMilliseconds unless we've been touched first more - // than MaxWaitMilliseconds ago and then release now - - if (DateTime.Now - _initialTouch < TimeSpan.FromMilliseconds(MaxWaitMilliseconds)) - { - LogHelper.Debug("Touched, was waiting, wait.", () => WaitMilliseconds); - LogHelper.Debug("Save in {0}ms.", () => WaitMilliseconds); + _timer = new Timer(_ => TimerRelease()); _timer.Change(WaitMilliseconds, 0); } - else - { - LogHelper.Debug("Save now, release."); - ReleaseLocked(); - } - return this; // still available + else // we have a timer + { + // change the timer to trigger in WaitMilliseconds unless we've been touched first more + // than MaxWaitMilliseconds ago and then leave the time unchanged + + if (DateTime.Now - _initialTouch < TimeSpan.FromMilliseconds(MaxWaitMilliseconds)) + { + LogHelper.Debug("Touched, was waiting, can delay, save in {0}ms.", () => WaitMilliseconds); + _timer.Change(WaitMilliseconds, 0); + } + else + { + LogHelper.Debug("Touched, was waiting, cannot delay."); + } + } } + + if (runNow) + Run(); + + return ret; // this, by default, unless we created a new one } - private void Release() + private void TimerRelease() { lock (_locko) { - ReleaseLocked(); - } - } + LogHelper.Debug("Timer: release."); + if (_timer != null) + _timer.Dispose(); + _timer = null; + _released = true; - private void ReleaseLocked() - { - LogHelper.Debug("Timer: save now, release."); - if (_timer != null) - _timer.Dispose(); - _timer = null; - _released = true; - _latch.Set(); + // if running (because of shutdown) this will have no effect + // else it tells the runner it is time to run the task + _latch.Set(); + } } public WaitHandle Latch @@ -131,9 +163,21 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache public async Task RunAsync(CancellationToken token) { - LogHelper.Debug("Run now."); - var doc = _content.XmlContentInternal; - await PersistXmlToFileAsync(doc).ConfigureAwait(false); + lock (_locko) + { + LogHelper.Debug("Run now (async)."); + // just make sure - in case the runner is running the task on shutdown + _released = true; + } + + // http://stackoverflow.com/questions/13489065/best-practice-to-call-configureawait-for-all-server-side-code + // http://blog.stephencleary.com/2012/07/dont-block-on-async-code.html + // do we really need that ConfigureAwait here? + + using (await _runLock.LockAsync()) + { + await _content.SaveXmlToFileAsync().ConfigureAwait(false); + } } public bool IsAsync @@ -141,62 +185,22 @@ namespace Umbraco.Web.PublishedCache.XmlPublishedCache get { return true; } } - /// - /// Persist a XmlDocument to the Disk Cache - /// - /// - internal async Task PersistXmlToFileAsync(XmlDocument xmlDoc) - { - if (xmlDoc != null) - { - //using (_logger.DebugDuration( - using(DisposableTimer.DebugDuration( - string.Format("Saving content to disk on thread '{0}' (Threadpool? {1})", Thread.CurrentThread.Name, Thread.CurrentThread.IsThreadPoolThread), - string.Format("Saved content to disk on thread '{0}' (Threadpool? {1})", Thread.CurrentThread.Name, Thread.CurrentThread.IsThreadPoolThread))) - { - try - { - // Try to create directory for cache path if it doesn't yet exist - var directoryName = Path.GetDirectoryName(_xmlFileName); - // create dir if it is not there, if it's there, this will proceed as normal - Directory.CreateDirectory(directoryName); - - await xmlDoc.SaveAsync(_xmlFileName).ConfigureAwait(false); - } - catch (Exception ee) - { - // If for whatever reason something goes wrong here, invalidate disk cache - DeleteXmlCache(); - - LogHelper.Error("Error saving content to disk", ee); - } - } - - - } - } - - private void DeleteXmlCache() - { - if (File.Exists(_xmlFileName) == false) return; - - // Reset file attributes, to make sure we can delete file - try - { - File.SetAttributes(_xmlFileName, FileAttributes.Normal); - } - finally - { - File.Delete(_xmlFileName); - } - } - public void Dispose() { } public void Run() { - throw new NotImplementedException(); + lock (_locko) + { + LogHelper.Debug("Run now (sync)."); + // not really needed but safer (it's only us invoking Run, but the method is public...) + _released = true; + } + + using (_runLock.Lock()) + { + _content.SaveXmlToFile(); + } } } } \ No newline at end of file diff --git a/src/Umbraco.Web/umbraco.presentation/content.cs b/src/Umbraco.Web/umbraco.presentation/content.cs index c71933b222..df323c8b8e 100644 --- a/src/Umbraco.Web/umbraco.presentation/content.cs +++ b/src/Umbraco.Web/umbraco.presentation/content.cs @@ -3,9 +3,11 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; +using System.Text; using System.Threading; using System.Threading.Tasks; using System.Web; +using System.Web.Hosting; using System.Xml; using Umbraco.Core; using Umbraco.Core.Cache; @@ -28,56 +30,38 @@ using File = System.IO.File; namespace umbraco { /// - /// Handles umbraco content + /// Represents the Xml storage for the Xml published cache. /// public class content { - private static readonly BackgroundTaskRunner FilePersister - = new BackgroundTaskRunner(new BackgroundTaskRunnerOptions { LongRunning = true }); - private XmlCacheFilePersister _persisterTask; + #region Constructors + private content() { - _persisterTask = new XmlCacheFilePersister(FilePersister, this, UmbracoXmlDiskCacheFileName - /*,new ProfilingLogger(LoggerResolver.Current.Logger, ProfilerResolver.Current.Profiler)*/); - FilePersister.Add(_persisterTask); - } + if (SyncToXmlFile == false) return; - #region Declarations - - // Sync access to disk file - private static readonly object ReaderWriterSyncLock = new object(); - - // Sync access to internal cache - private static readonly object XmlContentInternalSyncLock = new object(); - - // Sync access to timestamps - private static readonly object TimestampSyncLock = new object(); - - // Sync database access - private static readonly object DbReadSyncLock = new object(); - private const string XmlContextContentItemKey = "UmbracoXmlContextContent"; - private string _umbracoXmlDiskCacheFileName = string.Empty; - private volatile XmlDocument _xmlContent; - private DateTime _lastDiskCacheReadTime = DateTime.MinValue; - private DateTime _lastDiskCacheCheckTime = DateTime.MinValue; - - /// - /// Gets the path of the umbraco XML disk cache file. - /// - /// The name of the umbraco XML disk cache file. - public string UmbracoXmlDiskCacheFileName - { - get + // there's always be one task keeping a ref to the runner + // so it's safe to just create it as a local var here + var runner = new BackgroundTaskRunner(new BackgroundTaskRunnerOptions { - if (string.IsNullOrEmpty(_umbracoXmlDiskCacheFileName)) - { - _umbracoXmlDiskCacheFileName = IOHelper.MapPath(SystemFiles.ContentCacheXml); - } - return _umbracoXmlDiskCacheFileName; + LongRunning = true, + KeepAlive = true + }); + + // create (and add to runner) + _persisterTask = new XmlCacheFilePersister(runner, this); + + InitializeFileLock(); + + // initialize content - populate the cache + using (var safeXml = GetSafeXmlWriter(false)) + { + bool registerXmlChange; + LoadXmlLocked(safeXml, out registerXmlChange); + safeXml.Commit(registerXmlChange); } - set { _umbracoXmlDiskCacheFileName = value; } } #endregion @@ -96,34 +80,31 @@ namespace umbraco #endregion - #region Properties + #region Legacy & Stuff - /// - /// Get content. First call to this property will initialize xmldoc - /// subsequent calls will be blocked until initialization is done - /// Further we cache(in context) xmlContent for each request to ensure that - /// we always have the same XmlDoc throughout the whole request. - /// - public virtual XmlDocument XmlContent + // sync database access + // (not refactoring that part at the moment) + private static readonly object DbReadSyncLock = new object(); + + private const string XmlContextContentItemKey = "UmbracoXmlContextContent"; + private string _umbracoXmlDiskCacheFileName = string.Empty; + private volatile XmlDocument _xmlContent; + + /// + /// Gets the path of the umbraco XML disk cache file. + /// + /// The name of the umbraco XML disk cache file. + public string UmbracoXmlDiskCacheFileName { get { - if (UmbracoContext.Current == null || UmbracoContext.Current.HttpContext == null) - return XmlContentInternal; - var content = UmbracoContext.Current.HttpContext.Items[XmlContextContentItemKey] as XmlDocument; - if (content == null) + if (string.IsNullOrEmpty(_umbracoXmlDiskCacheFileName)) { - content = XmlContentInternal; - UmbracoContext.Current.HttpContext.Items[XmlContextContentItemKey] = content; + _umbracoXmlDiskCacheFileName = IOHelper.MapPath(SystemFiles.ContentCacheXml); } - return content; + return _umbracoXmlDiskCacheFileName; } - } - - [Obsolete("Please use: content.Instance.XmlContent")] - public static XmlDocument xmlContent - { - get { return Instance.XmlContent; } + set { _umbracoXmlDiskCacheFileName = value; } } //NOTE: We CANNOT use this for a double check lock because it is a property, not a field and to do double @@ -134,173 +115,15 @@ namespace umbraco get { return _xmlContent == null; } } - /// - /// Internal reference to XmlContent - /// - /// - /// Before returning we always check to ensure that the xml is loaded - /// - protected internal virtual XmlDocument XmlContentInternal - { - get - { - CheckXmlContentPopulation(); - - return _xmlContent; - } - set - { - lock (XmlContentInternalSyncLock) - { - _xmlContent = value; - - if (UmbracoConfig.For.UmbracoSettings().Content.XmlCacheEnabled && UmbracoConfig.For.UmbracoSettings().Content.ContinouslyUpdateXmlDiskCache) - QueueXmlForPersistence(); - else - // Clear cache... - DeleteXmlCache(); - } - } - } - - /// - /// Checks if the disk cache file has been updated and if so, clears the in-memory cache to force the file to be read. - /// - /// - /// Added to trigger updates of the in-memory cache when the disk cache file is updated. - /// - private void CheckDiskCacheForUpdate() - { - if (UmbracoConfig.For.UmbracoSettings().Content.XmlCacheEnabled == false) - return; - - lock (TimestampSyncLock) - { - if (_lastDiskCacheCheckTime > DateTime.UtcNow.AddSeconds(-1.0)) - return; - - _lastDiskCacheCheckTime = DateTime.UtcNow; - - lock (XmlContentInternalSyncLock) - { - - if (GetCacheFileUpdateTime() <= _lastDiskCacheReadTime) - return; - - _xmlContent = null; - } - } - } - - /// - /// Triggers the XML content population if necessary. - /// - /// Returns true of the XML was not populated, returns false if it was already populated - private bool CheckXmlContentPopulation() - { - if (UmbracoConfig.For.UmbracoSettings().Content.XmlContentCheckForDiskChanges) - CheckDiskCacheForUpdate(); - - if (_xmlContent == null) - { - lock (XmlContentInternalSyncLock) - { - if (_xmlContent == null) - { - LogHelper.Debug("Initializing content on thread '{0}' (Threadpool? {1})", - true, - () => Thread.CurrentThread.Name, - () => Thread.CurrentThread.IsThreadPoolThread); - - _xmlContent = LoadContent(); - LogHelper.Debug("Content initialized (loaded)", true); - - FireAfterRefreshContent(new RefreshContentEventArgs()); - - // Only save new XML cache to disk if we just repopulated it - if (UmbracoConfig.For.UmbracoSettings().Content.XmlCacheEnabled && !IsValidDiskCachePresent()) - { - QueueXmlForPersistence(); - } - return true; - } - } - } - - LogHelper.Debug(() => "Content initialized (was already in context)"); - - return false; - } - - #endregion - protected static ISqlHelper SqlHelper { get { return Application.SqlHelper; } } - protected static void ReGenerateSchema(XmlDocument xmlDoc) - { - string dtd = DocumentType.GenerateXmlDocumentType(); - - // remove current doctype - XmlNode n = xmlDoc.FirstChild; - while (n.NodeType != XmlNodeType.DocumentType && n.NextSibling != null) - { - n = n.NextSibling; - } - if (n.NodeType == XmlNodeType.DocumentType) - { - xmlDoc.RemoveChild(n); - } - XmlDocumentType docType = xmlDoc.CreateDocumentType("root", null, null, dtd); - xmlDoc.InsertAfter(docType, xmlDoc.FirstChild); - } - - protected static XmlDocument ValidateSchema(string docTypeAlias, XmlDocument xmlDoc) - { - // check if doctype is defined in schema else add it - // can't edit the doctype of an xml document, must create a new document - - var doctype = xmlDoc.DocumentType; - var subset = doctype.InternalSubset; - if (!subset.Contains(string.Format("", docTypeAlias))) - { - subset = string.Format("\r\n\r\n{1}", docTypeAlias, subset); - var xmlDoc2 = new XmlDocument(); - doctype = xmlDoc2.CreateDocumentType("root", null, null, subset); - xmlDoc2.AppendChild(doctype); - var root = xmlDoc2.ImportNode(xmlDoc.DocumentElement, true); - xmlDoc2.AppendChild(root); - - // apply - xmlDoc = xmlDoc2; - } - - return xmlDoc; - } + #endregion #region Public Methods - #region Delegates - - /// - /// Occurs when [after loading the xml string from the database]. - /// - public delegate void ContentCacheDatabaseLoadXmlStringEventHandler( - ref string xml, ContentCacheLoadNodeEventArgs e); - - /// - /// Occurs when [after loading the xml string from the database and creating the xml node]. - /// - public delegate void ContentCacheLoadNodeEventHandler(XmlNode xmlNode, ContentCacheLoadNodeEventArgs e); - - public delegate void DocumentCacheEventHandler(Document sender, DocumentCacheEventArgs e); - - public delegate void RefreshContentEventHandler(Document sender, RefreshContentEventArgs e); - - #endregion - [Obsolete("This is no longer used and will be removed in future versions, if you use this method it will not refresh 'async' it will perform the refresh on the current thread which is how it should be doing it")] public virtual void RefreshContentFromDatabaseAsync() { @@ -317,39 +140,13 @@ namespace umbraco if (!e.Cancel) { - XmlDocument xmlDoc = LoadContentFromDatabase(); - XmlContentInternal = xmlDoc; - - // It is correct to manually call PersistXmlToFile here event though the setter of XmlContentInternal - // queues this up, because it is possible that this method gets called outside of a web context and in that - // case the queue is not going to be executed by the UmbracoModule. So we'll process inline on this thread - // and clear the queue in case is this a web request, we don't want it reprocessing. - if (UmbracoConfig.For.UmbracoSettings().Content.XmlCacheEnabled && UmbracoConfig.For.UmbracoSettings().Content.ContinouslyUpdateXmlDiskCache) + using (var safeXml = GetSafeXmlWriter()) { - QueueXmlForPersistence(); + safeXml.Xml = LoadContentFromDatabase(); } } } - private static void TransferValuesFromDocumentXmlToPublishedXml(XmlNode DocumentNode, XmlNode PublishedNode) - { - // Remove all attributes and data nodes from the published node - PublishedNode.Attributes.RemoveAll(); - string xpath = UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema ? "./data" : "./* [not(@id)]"; - foreach (XmlNode n in PublishedNode.SelectNodes(xpath)) - PublishedNode.RemoveChild(n); - - // Append all attributes and datanodes from the documentnode to the publishednode - foreach (XmlAttribute att in DocumentNode.Attributes) - ((XmlElement)PublishedNode).SetAttribute(att.Name, att.Value); - - foreach (XmlElement el in DocumentNode.SelectNodes(xpath)) - { - XmlNode newDatael = PublishedNode.OwnerDocument.ImportNode(el, true); - PublishedNode.AppendChild(newDatael); - } - } - /// /// Used by all overloaded publish methods to do the actual "noderepresentation to xml" /// @@ -367,7 +164,7 @@ namespace umbraco var node = GetPreviewOrPublishedNode(d, xmlContentCopy, false); var attr = ((XmlElement)node).GetAttributeNode("sortOrder"); attr.Value = d.sortOrder.ToString(); - xmlContentCopy = AppendDocumentXml(d.Id, d.Level, parentId, node, xmlContentCopy); + AddOrUpdateXmlNode(xmlContentCopy, d.Id, d.Level, parentId, node); // update sitemapprovider if (updateSitemapProvider && SiteMap.Provider is UmbracoSiteMapProvider) @@ -395,115 +192,6 @@ namespace umbraco return xmlContentCopy; } - // appends a node (docNode) into a cache (xmlContentCopy) - // and returns a cache (not necessarily the original one) - // - internal static XmlDocument AppendDocumentXml(int id, int level, int parentId, XmlNode docNode, XmlDocument xmlContentCopy) - { - // sanity checks - if (id != docNode.AttributeValue("id")) - throw new ArgumentException("Values of id and docNode/@id are different."); - if (parentId != docNode.AttributeValue("parentID")) - throw new ArgumentException("Values of parentId and docNode/@parentID are different."); - - // find the document in the cache - XmlNode currentNode = xmlContentCopy.GetElementById(id.ToString()); - - // if the document is not there already then it's a new document - // we must make sure that its document type exists in the schema - if (currentNode == null && UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema == false) - { - // ValidateSchema looks for the doctype in the schema and if not found - // creates a new XML document with a schema containing the doctype. If - // a new cache copy is returned, must import the new node into the new - // copy. - var xmlContentCopy2 = xmlContentCopy; - xmlContentCopy = ValidateSchema(docNode.Name, xmlContentCopy); - if (xmlContentCopy != xmlContentCopy2) - docNode = xmlContentCopy.ImportNode(docNode, true); - } - - // find the parent - XmlNode parentNode = level == 1 - ? xmlContentCopy.DocumentElement - : xmlContentCopy.GetElementById(parentId.ToString()); - - // no parent = cannot do anything - if (parentNode == null) - return xmlContentCopy; - - // define xpath for getting the children nodes (not properties) of a node - var childNodesXPath = UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema - ? "./node" - : "./* [@id]"; - - // insert/move the node under the parent - if (currentNode == null) - { - // document not there, new node, append - currentNode = docNode; - parentNode.AppendChild(currentNode); - } - else - { - // document found... we could just copy the currentNode children nodes over under - // docNode, then remove currentNode and insert docNode... the code below tries to - // be clever and faster, though only benchmarking could tell whether it's worth the - // pain... - - // first copy current parent ID - so we can compare with target parent - var moving = currentNode.AttributeValue("parentID") != parentId; - - if (docNode.Name == currentNode.Name) - { - // name has not changed, safe to just update the current node - // by transfering values eg copying the attributes, and importing the data elements - TransferValuesFromDocumentXmlToPublishedXml(docNode, currentNode); - - // if moving, move the node to the new parent - // else it's already under the right parent - // (but maybe the sort order has been updated) - if (moving) - parentNode.AppendChild(currentNode); // remove then append to parentNode - } - else - { - // name has changed, must use docNode (with new name) - // move children nodes from currentNode to docNode (already has properties) - foreach (XmlNode child in currentNode.SelectNodes(childNodesXPath)) - docNode.AppendChild(child); // remove then append to docNode - - // and put docNode in the right place - if parent has not changed, then - // just replace, else remove currentNode and insert docNode under the right parent - // (but maybe not at the right position due to sort order) - if (moving) - { - currentNode.ParentNode.RemoveChild(currentNode); - parentNode.AppendChild(docNode); - } - else - { - // replacing might screw the sort order - parentNode.ReplaceChild(docNode, currentNode); - } - - currentNode = docNode; - } - } - - // if the nodes are not ordered, must sort - // (see U4-509 + has to work with ReplaceChild too) - //XmlHelper.SortNodesIfNeeded(parentNode, childNodesXPath, x => x.AttributeValue("sortOrder")); - - // but... - // if we assume that nodes are always correctly sorted - // then we just need to ensure that currentNode is at the right position. - // should be faster that moving all the nodes around. - XmlHelper.SortNode(parentNode, childNodesXPath, currentNode, x => x.AttributeValue("sortOrder")); - - return xmlContentCopy; - } - private static XmlNode GetPreviewOrPublishedNode(Document d, XmlDocument xmlContentCopy, bool isPreview) { if (isPreview) @@ -526,17 +214,11 @@ namespace umbraco ? "./node" : "./* [@id]"; - lock (XmlContentInternalSyncLock) + using (var safeXml = GetSafeXmlWriter(false)) { - // modify a clone of the cache because even though we're into the write-lock - // we may have threads reading at the same time. why is this an option? - var wip = UmbracoConfig.For.UmbracoSettings().Content.CloneXmlContent - ? CloneXmlDoc(XmlContentInternal) - : XmlContentInternal; - var parentNode = parentId == -1 - ? XmlContent.DocumentElement - : XmlContent.GetElementById(parentId.ToString(CultureInfo.InvariantCulture)); + ? safeXml.Xml.DocumentElement + : safeXml.Xml.GetElementById(parentId.ToString(CultureInfo.InvariantCulture)); if (parentNode == null) return; @@ -546,12 +228,11 @@ namespace umbraco x => x.AttributeValue("sortOrder")); if (sorted == false) return; - - XmlContentInternal = wip; + + safeXml.Commit(); } } - /// /// Updates the document cache. /// @@ -575,20 +256,13 @@ namespace umbraco { // lock the xml cache so no other thread can write to it at the same time // note that some threads could read from it while we hold the lock, though - lock (XmlContentInternalSyncLock) + using (var safeXml = GetSafeXmlWriter()) { - // modify a clone of the cache because even though we're into the write-lock - // we may have threads reading at the same time. why is this an option? - XmlDocument wip = UmbracoConfig.For.UmbracoSettings().Content.CloneXmlContent - ? CloneXmlDoc(XmlContentInternal) - : XmlContentInternal; - - wip = PublishNodeDo(d, wip, true); - XmlContentInternal = wip; - - ClearContextCache(); + safeXml.Xml = PublishNodeDo(d, safeXml.Xml, true); } + ClearContextCache(); + var cachedFieldKeyStart = string.Format("{0}{1}_", CacheKeys.ContentItemCacheKey, d.Id); ApplicationContext.Current.ApplicationCache.ClearCacheByKeySearch(cachedFieldKeyStart); @@ -622,13 +296,9 @@ namespace umbraco if (c.Published) return; - lock (XmlContentInternalSyncLock) + using (var safeXml = GetSafeXmlWriter(false)) { - var wip = UmbracoConfig.For.UmbracoSettings().Content.CloneXmlContent - ? CloneXmlDoc(XmlContentInternal) - : XmlContentInternal; - - var node = wip.GetElementById(c.Id.ToString(CultureInfo.InvariantCulture)); + var node = safeXml.Xml.GetElementById(c.Id.ToString(CultureInfo.InvariantCulture)); if (node == null) return; var attr = node.GetAttributeNode("sortOrder"); if (attr == null) return; @@ -637,9 +307,8 @@ namespace umbraco // only if node was actually modified attr.Value = sortOrder; - XmlContentInternal = wip; - // no need to clear any cache + safeXml.Commit(); } } @@ -654,18 +323,16 @@ namespace umbraco // making changes at the same time, they need to be queued int parentid = Documents[0].Id; - lock (XmlContentInternalSyncLock) + + using (var safeXml = GetSafeXmlWriter()) { - // Make copy of memory content, we cannot make changes to the same document - // the is read from elsewhere - XmlDocument xmlContentCopy = CloneXmlDoc(XmlContentInternal); foreach (Document d in Documents) { - PublishNodeDo(d, xmlContentCopy, true); + PublishNodeDo(d, safeXml.Xml, true); } - XmlContentInternal = xmlContentCopy; - ClearContextCache(); } + + ClearContextCache(); } [Obsolete("Method obsolete in version 4.1 and later, please use UpdateDocumentCache", true)] @@ -704,30 +371,29 @@ namespace umbraco // remove from xml db cache doc.XmlRemoveFromDB(); - // Check if node present, before cloning - x = XmlContentInternal.GetElementById(doc.Id.ToString()); - if (x == null) - return; - // We need to lock content cache here, because we cannot allow other threads // making changes at the same time, they need to be queued - lock (XmlContentInternalSyncLock) + using (var safeXml = GetSafeXmlReader()) { - // Make copy of memory content, we cannot make changes to the same document - // the is read from elsewhere - XmlDocument xmlContentCopy = CloneXmlDoc(XmlContentInternal); + // Check if node present, before cloning + x = safeXml.Xml.GetElementById(doc.Id.ToString()); + if (x == null) + return; + + safeXml.UpgradeToWriter(false); // Find the document in the xml cache - x = xmlContentCopy.GetElementById(doc.Id.ToString()); + x = safeXml.Xml.GetElementById(doc.Id.ToString()); if (x != null) { // The document already exists in cache, so repopulate it x.ParentNode.RemoveChild(x); - XmlContentInternal = xmlContentCopy; - ClearContextCache(); + safeXml.Commit(); } } + ClearContextCache(); + //SD: changed to fire event BEFORE running the sitemap!! argh. FireAfterClearDocumentCache(doc, e); @@ -740,7 +406,6 @@ namespace umbraco } } - /// /// Unpublishes the node. /// @@ -751,6 +416,955 @@ namespace umbraco ClearDocumentCache(documentId); } + #endregion + + #region Protected & Private methods + + /// + /// Clear HTTPContext cache if any + /// + private void ClearContextCache() + { + // If running in a context very important to reset context cache orelse new nodes are missing + if (UmbracoContext.Current != null && UmbracoContext.Current.HttpContext != null && UmbracoContext.Current.HttpContext.Items.Contains(XmlContextContentItemKey)) + UmbracoContext.Current.HttpContext.Items.Remove(XmlContextContentItemKey); + } + + /// + /// Load content from database + /// + private XmlDocument LoadContentFromDatabase() + { + try + { + // Try to log to the DB + LogHelper.Info("Loading content from database..."); + + var hierarchy = new Dictionary>(); + var nodeIndex = new Dictionary(); + + try + { + LogHelper.Debug("Republishing starting"); + + lock (DbReadSyncLock) + { + + // Lets cache the DTD to save on the DB hit on the subsequent use + string dtd = DocumentType.GenerateDtd(); + + // Prepare an XmlDocument with an appropriate inline DTD to match + // the expected content + var xmlDoc = new XmlDocument(); + InitializeXml(xmlDoc, dtd); + + // Esben Carlsen: At some point we really need to put all data access into to a tier of its own. + // CLN - added checks that document xml is for a document that is actually published. + string sql = + @"select umbracoNode.id, umbracoNode.parentId, umbracoNode.sortOrder, cmsContentXml.xml from umbracoNode +inner join cmsContentXml on cmsContentXml.nodeId = umbracoNode.id and umbracoNode.nodeObjectType = @type +where umbracoNode.id in (select cmsDocument.nodeId from cmsDocument where cmsDocument.published = 1) +order by umbracoNode.level, umbracoNode.sortOrder"; + + + + using ( + IRecordsReader dr = SqlHelper.ExecuteReader(sql, + SqlHelper.CreateParameter("@type", + new Guid( + Constants.ObjectTypes.Document))) + ) + { + while (dr.Read()) + { + int currentId = dr.GetInt("id"); + int parentId = dr.GetInt("parentId"); + string xml = dr.GetString("xml"); + + // fix sortOrder - see notes in UpdateSortOrder + var tmp = new XmlDocument(); + tmp.LoadXml(xml); + var attr = tmp.DocumentElement.GetAttributeNode("sortOrder"); + attr.Value = dr.GetInt("sortOrder").ToString(); + xml = tmp.InnerXml; + + // Call the eventhandler to allow modification of the string + var e1 = new ContentCacheLoadNodeEventArgs(); + FireAfterContentCacheDatabaseLoadXmlString(ref xml, e1); + // check if a listener has canceled the event + if (!e1.Cancel) + { + // and parse it into a DOM node + xmlDoc.LoadXml(xml); + XmlNode node = xmlDoc.FirstChild; + // same event handler loader form the xml node + var e2 = new ContentCacheLoadNodeEventArgs(); + FireAfterContentCacheLoadNodeFromDatabase(node, e2); + // and checking if it was canceled again + if (!e1.Cancel) + { + nodeIndex.Add(currentId, node); + + // verify if either of the handlers canceled the children to load + if (!e1.CancelChildren && !e2.CancelChildren) + { + // Build the content hierarchy + List children; + if (!hierarchy.TryGetValue(parentId, out children)) + { + // No children for this parent, so add one + children = new List(); + hierarchy.Add(parentId, children); + } + children.Add(currentId); + } + } + } + } + } + + LogHelper.Debug("Xml Pages loaded"); + + try + { + // If we got to here we must have successfully retrieved the content from the DB so + // we can safely initialise and compose the final content DOM. + // Note: We are reusing the XmlDocument used to create the xml nodes above so + // we don't have to import them into a new XmlDocument + + // Initialise the document ready for the final composition of content + InitializeXml(xmlDoc, dtd); + + // Start building the content tree recursively from the root (-1) node + GenerateXmlDocument(hierarchy, nodeIndex, -1, xmlDoc.DocumentElement); + + LogHelper.Debug("Done republishing Xml Index"); + + return xmlDoc; + } + catch (Exception ee) + { + LogHelper.Error("Error while generating XmlDocument from database", ee); + } + } + } + catch (OutOfMemoryException ee) + { + LogHelper.Error(string.Format("Error Republishing: Out Of Memory. Parents: {0}, Nodes: {1}", hierarchy.Count, nodeIndex.Count), ee); + } + catch (Exception ee) + { + LogHelper.Error("Error Republishing", ee); + } + } + catch (Exception ee) + { + LogHelper.Error("Error Republishing", ee); + } + + // An error of some sort must have stopped us from successfully generating + // the content tree, so lets return null signifying there is no content available + return null; + } + + private static void GenerateXmlDocument(IDictionary> hierarchy, + IDictionary nodeIndex, int parentId, XmlNode parentNode) + { + List children; + + if (hierarchy.TryGetValue(parentId, out children)) + { + XmlNode childContainer = UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema || + String.IsNullOrEmpty(UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME) + ? parentNode + : parentNode.SelectSingleNode( + UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME); + + if (!UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema && + !String.IsNullOrEmpty(UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME)) + { + if (childContainer == null) + { + childContainer = xmlHelper.addTextNode(parentNode.OwnerDocument, + UmbracoSettings. + TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME, ""); + parentNode.AppendChild(childContainer); + } + } + + foreach (int childId in children) + { + XmlNode childNode = nodeIndex[childId]; + + if (UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema || + String.IsNullOrEmpty(UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME)) + { + parentNode.AppendChild(childNode); + } + else + { + childContainer.AppendChild(childNode); + } + + // Recursively build the content tree under the current child + GenerateXmlDocument(hierarchy, nodeIndex, childId, childNode); + } + } + } + + [Obsolete("This method should not be used and does nothing, xml file persistence is done in a queue using a BackgroundTaskRunner")] + public void PersistXmlToFile() + { + } + + /// + /// Adds a task to the xml cache file persister + /// + //private void QueueXmlForPersistence() + //{ + // _persisterTask = _persisterTask.Touch(); + //} + + internal DateTime GetCacheFileUpdateTime() + { + //TODO: Should there be a try/catch here in case the file is being written to while this is trying to be executed? + + if (File.Exists(UmbracoXmlDiskCacheFileName)) + { + return new FileInfo(UmbracoXmlDiskCacheFileName).LastWriteTimeUtc; + } + + return DateTime.MinValue; + } + + #endregion + + #region Configuration + + // gathering configuration options here to document what they mean + + private readonly bool _xmlFileEnabled = true; + + // whether the disk cache is enabled + private bool XmlFileEnabled + { + get { return _xmlFileEnabled && UmbracoConfig.For.UmbracoSettings().Content.XmlCacheEnabled; } + } + + // whether the disk cache is enabled and to update the disk cache when xml changes + private bool SyncToXmlFile + { + get { return XmlFileEnabled && UmbracoConfig.For.UmbracoSettings().Content.ContinouslyUpdateXmlDiskCache; } + } + + // whether the disk cache is enabled and to reload from disk cache if it changes + private bool SyncFromXmlFile + { + get { return XmlFileEnabled && UmbracoConfig.For.UmbracoSettings().Content.XmlContentCheckForDiskChanges; } + } + + // whether _xml is immutable or not (achieved by cloning before changing anything) + private static bool XmlIsImmutable + { + get { return UmbracoConfig.For.UmbracoSettings().Content.CloneXmlContent; } + } + + // whether to use the legacy schema + private static bool UseLegacySchema + { + get { return UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema; } + } + + // whether to keep version of everything (incl. medias & members) in cmsPreviewXml + // for audit purposes - false by default, not in umbracoSettings.config + // whether to... no idea what that one does + // it is false by default and not in UmbracoSettings.config anymore - ignoring + /* + private static bool GlobalPreviewStorageEnabled + { + get { return UmbracoConfig.For.UmbracoSettings().Content.GlobalPreviewStorageEnabled; } + } + */ + + // ensures config is valid + private void EnsureConfigurationIsValid() + { + if (SyncToXmlFile && SyncFromXmlFile) + throw new Exception("Cannot run with both ContinouslyUpdateXmlDiskCache and XmlContentCheckForDiskChanges being true."); + + if (XmlIsImmutable == false) + //LogHelper.Warn("Running with CloneXmlContent being false is a bad idea."); + LogHelper.Warn("CloneXmlContent is false - ignored, we always clone."); + + // note: if SyncFromXmlFile then we should also disable / warn that local edits are going to cause issues... + } + + #endregion + + #region Xml + + private readonly AsyncLock _xmlLock = new AsyncLock(); // protects _xml + + /// + /// Get content. First call to this property will initialize xmldoc + /// subsequent calls will be blocked until initialization is done + /// Further we cache (in context) xmlContent for each request to ensure that + /// we always have the same XmlDoc throughout the whole request. + /// + public virtual XmlDocument XmlContent + { + get + { + if (UmbracoContext.Current == null || UmbracoContext.Current.HttpContext == null) + return XmlContentInternal; + var content = UmbracoContext.Current.HttpContext.Items[XmlContextContentItemKey] as XmlDocument; + if (content == null) + { + content = XmlContentInternal; + UmbracoContext.Current.HttpContext.Items[XmlContextContentItemKey] = content; + } + return content; + } + } + + [Obsolete("Please use: content.Instance.XmlContent")] + public static XmlDocument xmlContent + { + get { return Instance.XmlContent; } + } + + // to be used by content.Instance + protected internal virtual XmlDocument XmlContentInternal + { + get + { + ReloadXmlFromFileIfChanged(); + return _xmlContent; + } + } + + // assumes xml lock + private void SetXmlLocked(XmlDocument xml, bool registerXmlChange) + { + // this is the ONLY place where we write to _xmlContent + _xmlContent = xml; + + if (registerXmlChange == false || SyncToXmlFile == false) + return; + + //_lastXmlChange = DateTime.UtcNow; + _persisterTask = _persisterTask.Touch(); // _persisterTask != null because SyncToXmlFile == true + } + + private static XmlDocument Clone(XmlDocument xmlDoc) + { + return xmlDoc == null ? null : (XmlDocument) xmlDoc.CloneNode(true); + } + + private static void EnsureSchema(string contentTypeAlias, XmlDocument xml) + { + string subset = null; + + // get current doctype + var n = xml.FirstChild; + while (n.NodeType != XmlNodeType.DocumentType && n.NextSibling != null) + n = n.NextSibling; + if (n.NodeType == XmlNodeType.DocumentType) + subset = ((XmlDocumentType)n).InternalSubset; + + // ensure it contains the content type + if (subset != null && subset.Contains(string.Format("", contentTypeAlias))) + return; + + // remove current doctype + xml.RemoveChild(n); + + // set new doctype + subset = string.Format("{0}{0}{2}", Environment.NewLine, contentTypeAlias, subset); + var doctype = xml.CreateDocumentType("root", null, null, subset); + xml.InsertAfter(doctype, xml.FirstChild); + } + + private static void InitializeXml(XmlDocument xml, string dtd) + { + // prime the xml document with an inline dtd and a root element + xml.LoadXml(String.Format("{0}{1}{0}", + Environment.NewLine, dtd)); + } + + // try to load from file, otherwise database + // assumes xml lock (file is always locked) + private void LoadXmlLocked(SafeXmlReaderWriter safeXml, out bool registerXmlChange) + { + LogHelper.Debug("Loading Xml..."); + + // try to get it from the file + if (XmlFileEnabled && (safeXml.Xml = LoadXmlFromFile()) != null) + { + registerXmlChange = false; // loaded from disk, do NOT write back to disk! + return; + } + + // get it from the database, and register + LoadContentFromDatabase(); + registerXmlChange = true; + } + + // NOTE + // - this is NOT a reader/writer lock and each lock is exclusive + // - these locks are NOT reentrant / recursive + + // gets a locked safe read access to the main xml + private SafeXmlReaderWriter GetSafeXmlReader() + { + var releaser = _xmlLock.Lock(); + return SafeXmlReaderWriter.GetReader(this, releaser); + } + + // gets a locked safe read accses to the main xml + private async Task GetSafeXmlReaderAsync() + { + var releaser = await _xmlLock.LockAsync(); + return SafeXmlReaderWriter.GetReader(this, releaser); + } + + // gets a locked safe write access to the main xml (cloned) + private SafeXmlReaderWriter GetSafeXmlWriter(bool auto = true) + { + var releaser = _xmlLock.Lock(); + return SafeXmlReaderWriter.GetWriter(this, releaser, auto); + } + + private class SafeXmlReaderWriter : IDisposable + { + private readonly content _instance; + private IDisposable _releaser; + private bool _isWriter; + private bool _auto; + private bool _committed; + private XmlDocument _xml; + + private SafeXmlReaderWriter(content instance, IDisposable releaser, bool isWriter, bool auto) + { + _instance = instance; + _releaser = releaser; + _isWriter = isWriter; + _auto = auto; + + // cloning for writer is not an option anymore (see XmlIsImmutable) + _xml = _isWriter ? Clone(instance._xmlContent) : instance._xmlContent; + } + + public static SafeXmlReaderWriter GetReader(content instance, IDisposable releaser) + { + return new SafeXmlReaderWriter(instance, releaser, false, false); + } + + public static SafeXmlReaderWriter GetWriter(content instance, IDisposable releaser, bool auto) + { + return new SafeXmlReaderWriter(instance, releaser, true, auto); + } + + public void UpgradeToWriter(bool auto) + { + if (_isWriter) + throw new InvalidOperationException("Already writing."); + _isWriter = true; + _auto = auto; + _xml = Clone(_xml); // cloning for writer is not an option anymore (see XmlIsImmutable) + } + + public XmlDocument Xml + { + get + { + return _xml; + } + set + { + if (_isWriter == false) + throw new InvalidOperationException("Not writing."); + _xml = value; + } + } + + // registerXmlChange indicates whether to do what should be done when Xml changes, + // that is, to request that the file be written to disk - something we don't want + // to do if we're committing Xml precisely after we've read from disk! + public void Commit(bool registerXmlChange = true) + { + if (_isWriter == false) + throw new InvalidOperationException("Not writing."); + _instance.SetXmlLocked(Xml, registerXmlChange); + _committed = true; + } + + public void Dispose() + { + if (_releaser == null) + return; + if (_isWriter && _auto && _committed == false) + Commit(); + _releaser.Dispose(); + _releaser = null; + } + } + + private static string ChildNodesXPath + { + get + { + return UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema + ? "./node" + : "./* [@id]"; + } + } + + private static string DataNodesXPath + { + get + { + return UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema + ? "./data" + : "./* [not(@id)]"; + } + } + + #endregion + + #region File + + private readonly string _xmlFileName = IOHelper.MapPath(SystemFiles.ContentCacheXml); + private DateTime _lastFileRead; // last time the file was read + private DateTime _nextFileCheck; // last time we checked whether the file was changed + private AsyncLock _fileLock; // protects the file + private IDisposable _fileLocked; // protects the file + + private const int FileLockTimeoutMilliseconds = 4*60*1000; // 4' + + private void InitializeFileLock() + { + // initialize file lock + // ApplicationId will look like "/LM/W3SVC/1/Root/AppName" + // name is system-wide and must be less than 260 chars + // + // From MSDN C++ CreateSemaphore doc: + // "The name can have a "Global\" or "Local\" prefix to explicitly create the object in + // the global or session namespace. The remainder of the name can contain any character + // except the backslash character (\). For more information, see Kernel Object Namespaces." + // + // From MSDN "Kernel object namespaces" doc: + // "The separate client session namespaces enable multiple clients to run the same + // applications without interfering with each other. For processes started under + // a client session, the system uses the session namespace by default. However, these + // processes can use the global namespace by prepending the "Global\" prefix to the object name." + // + // just use "default" (whatever it is) for now - ie, no prefix + // + var name = HostingEnvironment.ApplicationID + "/XmlStore/XmlFile"; + _fileLock = new AsyncLock(name); + + // the file lock works with a shared, system-wide, semaphore - and we don't want + // to leak a count on that semaphore else the whole process will hang - so we have + // to ensure we dispose of the locker when the domain goes down - in theory the + // async lock should do it via its finalizer, but then there are some weird cases + // where the semaphore has been disposed of before it's been released, and then + // we'd need to GC-pin the semaphore... better dispose the locker explicitely + // when the app domain unloads. + + if (AppDomain.CurrentDomain.IsDefaultAppDomain()) + { + LogHelper.Debug("Registering Unload handler for default app domain."); + AppDomain.CurrentDomain.ProcessExit += OnDomainUnloadReleaseFileLock; + } + else + { + LogHelper.Debug("Registering Unload handler for non-default app domain."); + AppDomain.CurrentDomain.DomainUnload += OnDomainUnloadReleaseFileLock; + } + } + + private void EnsureFileLock() + { + if (_fileLock == null) return; // not locking (testing?) + if (_fileLocked != null) return; // locked already + + // thread-safety, acquire lock only once! + // lock something that's readonly and not null.. + lock (_xmlFileName) + { + // double-check + if (_fileLock == null) return; + if (_fileLocked != null) return; + + // don't hang forever, throws if it cannot lock within the timeout + LogHelper.Debug("Acquiring exclusive access to file for this AppDomain..."); + _fileLocked = _fileLock.Lock(FileLockTimeoutMilliseconds); + LogHelper.Debug("Acquired exclusive access to file for this AppDomain."); + } + } + + private void OnDomainUnloadReleaseFileLock(object sender, EventArgs args) + { + // the unload event triggers AFTER all hosted objects (eg the file persister + // background task runner) have been stopped, so we should NOT be accessing + // the file from now one - release the lock + + // NOTE + // trying to write to the log via LogHelper at that point is a BAD idea + // it can lead to ugly deadlocks with the named semaphore - DONT do it + + if (_fileLock == null) return; // not locking (testing?) + if (_fileLocked == null) return; // not locked + + // thread-safety + // lock something that's readonly and not null.. + lock (_xmlFileName) + { + // double-check + if (_fileLocked == null) return; + + // in case you really need to debug... that should be safe... + //System.IO.File.AppendAllText(HostingEnvironment.MapPath("~/App_Data/log.txt"), string.Format("{0} {1} unlock", DateTime.Now, AppDomain.CurrentDomain.Id)); + _fileLocked.Dispose(); + + _fileLock = null; // ensure we don't lock again + } + } + + // not used - just try to read the file + //private bool XmlFileExists + //{ + // get + // { + // // check that the file exists and has content (is not empty) + // var fileInfo = new FileInfo(_xmlFileName); + // return fileInfo.Exists && fileInfo.Length > 0; + // } + //} + + private DateTime XmlFileLastWriteTime + { + get + { + var fileInfo = new FileInfo(_xmlFileName); + return fileInfo.Exists ? fileInfo.LastWriteTimeUtc : DateTime.MinValue; + } + } + + // assumes file lock + internal void SaveXmlToFile() + { + LogHelper.Info("Save Xml to file..."); + EnsureFileLock(); + + var xml = _xmlContent; // capture (atomic + volatile), immutable anyway + + try + { + // delete existing file, if any + DeleteXmlFile(); + + // ensure cache directory exists + var directoryName = Path.GetDirectoryName(_xmlFileName); + if (directoryName == null) + throw new Exception(string.Format("Invalid XmlFileName \"{0}\".", _xmlFileName)); + if (System.IO.File.Exists(_xmlFileName) == false && Directory.Exists(directoryName) == false) + Directory.CreateDirectory(directoryName); + + // save + using (var fs = new FileStream(_xmlFileName, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true)) + { + var bytes = Encoding.UTF8.GetBytes(SaveXmlToString(xml)); + fs.Write(bytes, 0, bytes.Length); + } + + LogHelper.Debug("Saved Xml to file."); + } + catch (Exception e) + { + // if something goes wrong remove the file + DeleteXmlFile(); + + LogHelper.Error("Failed to save Xml to file.", e); + } + } + + // assumes file lock + internal async System.Threading.Tasks.Task SaveXmlToFileAsync() + { + LogHelper.Info("Save Xml to file..."); + EnsureFileLock(); + + var xml = _xmlContent; // capture (atomic + volatile), immutable anyway + + try + { + // delete existing file, if any + DeleteXmlFile(); + + // ensure cache directory exists + var directoryName = Path.GetDirectoryName(_xmlFileName); + if (directoryName == null) + throw new Exception(string.Format("Invalid XmlFileName \"{0}\".", _xmlFileName)); + if (System.IO.File.Exists(_xmlFileName) == false && Directory.Exists(directoryName) == false) + Directory.CreateDirectory(directoryName); + + // save + using (var fs = new FileStream(_xmlFileName, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true)) + { + var bytes = Encoding.UTF8.GetBytes(SaveXmlToString(xml)); + await fs.WriteAsync(bytes, 0, bytes.Length); + } + + LogHelper.Debug("Saved Xml to file."); + } + catch (Exception e) + { + // if something goes wrong remove the file + DeleteXmlFile(); + + LogHelper.Error("Failed to save Xml to file.", e); + } + } + + private string SaveXmlToString(XmlDocument xml) + { + // using that one method because we want to have proper indent + // and in addition, writing async is never fully async because + // althouth the writer is async, xml.WriteTo() will not async + + // that one almost works but... "The elements are indented as long as the element + // does not contain mixed content. Once the WriteString or WriteWhitespace method + // is called to write out a mixed element content, the XmlWriter stops indenting. + // The indenting resumes once the mixed content element is closed." - says MSDN + // about XmlWriterSettings.Indent + + // so ImportContent must also make sure of ignoring whitespaces! + + var sb = new StringBuilder(); + using (var xmlWriter = XmlWriter.Create(sb, new XmlWriterSettings + { + Indent = true, + Encoding = Encoding.UTF8, + //OmitXmlDeclaration = true + })) + { + //xmlWriter.WriteProcessingInstruction("xml", "version=\"1.0\" encoding=\"utf-8\""); + xml.WriteTo(xmlWriter); // already contains the xml declaration + } + return sb.ToString(); + } + + // assumes file lock + private XmlDocument LoadXmlFromFile() + { + LogHelper.Info("Load Xml from file..."); + EnsureFileLock(); + + try + { + var xml = new XmlDocument(); + using (var fs = new FileStream(_xmlFileName, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + xml.Load(fs); + } + _lastFileRead = DateTime.UtcNow; + LogHelper.Info("Successfully loaded Xml from file."); + return xml; + } + catch (Exception e) + { + LogHelper.Error("Failed to load Xml from file.", e); + DeleteXmlFile(); + return null; + } + } + + // (files is always locked) + private void DeleteXmlFile() + { + if (System.IO.File.Exists(_xmlFileName) == false) return; + System.IO.File.SetAttributes(_xmlFileName, FileAttributes.Normal); + System.IO.File.Delete(_xmlFileName); + } + + private void ReloadXmlFromFileIfChanged() + { + if (SyncFromXmlFile == false) return; + + var now = DateTime.UtcNow; + if (now < _nextFileCheck) return; + + // time to check + _nextFileCheck = now.AddSeconds(1); // check every 1s + if (XmlFileLastWriteTime <= _lastFileRead) return; + + LogHelper.Debug("Xml file change detected, reloading."); + + // time to read + + using (var safeXml = GetSafeXmlWriter(false)) + { + bool registerXmlChange; + LoadXmlLocked(safeXml, out registerXmlChange); // updates _lastFileRead + safeXml.Commit(registerXmlChange); + } + } + + #endregion + + #region Manage change + + // adds or updates a node (docNode) into a cache (xml) + public static void AddOrUpdateXmlNode(XmlDocument xml, int id, int level, int parentId, XmlNode docNode) + { + // sanity checks + if (id != docNode.AttributeValue("id")) + throw new ArgumentException("Values of id and docNode/@id are different."); + if (parentId != docNode.AttributeValue("parentID")) + throw new ArgumentException("Values of parentId and docNode/@parentID are different."); + + // find the document in the cache + XmlNode currentNode = xml.GetElementById(id.ToInvariantString()); + + // if the document is not there already then it's a new document + // we must make sure that its document type exists in the schema + if (currentNode == null && UseLegacySchema == false) + EnsureSchema(docNode.Name, xml); + + // find the parent + XmlNode parentNode = level == 1 + ? xml.DocumentElement + : xml.GetElementById(parentId.ToInvariantString()); + + // no parent = cannot do anything + if (parentNode == null) + return; + + // insert/move the node under the parent + if (currentNode == null) + { + // document not there, new node, append + currentNode = docNode; + parentNode.AppendChild(currentNode); + } + else + { + // document found... we could just copy the currentNode children nodes over under + // docNode, then remove currentNode and insert docNode... the code below tries to + // be clever and faster, though only benchmarking could tell whether it's worth the + // pain... + + // first copy current parent ID - so we can compare with target parent + var moving = currentNode.AttributeValue("parentID") != parentId; + + if (docNode.Name == currentNode.Name) + { + // name has not changed, safe to just update the current node + // by transfering values eg copying the attributes, and importing the data elements + TransferValuesFromDocumentXmlToPublishedXml(docNode, currentNode); + + // if moving, move the node to the new parent + // else it's already under the right parent + // (but maybe the sort order has been updated) + if (moving) + parentNode.AppendChild(currentNode); // remove then append to parentNode + } + else + { + // name has changed, must use docNode (with new name) + // move children nodes from currentNode to docNode (already has properties) + var children = currentNode.SelectNodes(ChildNodesXPath); + if (children == null) throw new Exception("oops"); + foreach (XmlNode child in children) + docNode.AppendChild(child); // remove then append to docNode + + // and put docNode in the right place - if parent has not changed, then + // just replace, else remove currentNode and insert docNode under the right parent + // (but maybe not at the right position due to sort order) + if (moving) + { + if (currentNode.ParentNode == null) throw new Exception("oops"); + currentNode.ParentNode.RemoveChild(currentNode); + parentNode.AppendChild(docNode); + } + else + { + // replacing might screw the sort order + parentNode.ReplaceChild(docNode, currentNode); + } + + currentNode = docNode; + } + } + + // if the nodes are not ordered, must sort + // (see U4-509 + has to work with ReplaceChild too) + //XmlHelper.SortNodesIfNeeded(parentNode, childNodesXPath, x => x.AttributeValue("sortOrder")); + + // but... + // if we assume that nodes are always correctly sorted + // then we just need to ensure that currentNode is at the right position. + // should be faster that moving all the nodes around. + XmlHelper.SortNode(parentNode, ChildNodesXPath, currentNode, x => x.AttributeValue("sortOrder")); + } + + private static void TransferValuesFromDocumentXmlToPublishedXml(XmlNode documentNode, XmlNode publishedNode) + { + // remove all attributes from the published node + if (publishedNode.Attributes == null) throw new Exception("oops"); + publishedNode.Attributes.RemoveAll(); + + // remove all data nodes from the published node + var dataNodes = publishedNode.SelectNodes(DataNodesXPath); + if (dataNodes == null) throw new Exception("oops"); + foreach (XmlNode n in dataNodes) + publishedNode.RemoveChild(n); + + // append all attributes from the document node to the published node + if (documentNode.Attributes == null) throw new Exception("oops"); + foreach (XmlAttribute att in documentNode.Attributes) + ((XmlElement) publishedNode).SetAttribute(att.Name, att.Value); + + // find the first child node, if any + var childNodes = publishedNode.SelectNodes(ChildNodesXPath); + if (childNodes == null) throw new Exception("oops"); + var firstChildNode = childNodes.Count == 0 ? null : childNodes[0]; + + // append all data nodes from the document node to the published node + dataNodes = documentNode.SelectNodes(DataNodesXPath); + if (dataNodes == null) throw new Exception("oops"); + foreach (XmlNode n in dataNodes) + { + if (publishedNode.OwnerDocument == null) throw new Exception("oops"); + var imported = publishedNode.OwnerDocument.ImportNode(n, true); + if (firstChildNode == null) + publishedNode.AppendChild(imported); + else + publishedNode.InsertBefore(imported, firstChildNode); + } + } + + #endregion + + #region Events + + /// + /// Occurs when [after loading the xml string from the database]. + /// + public delegate void ContentCacheDatabaseLoadXmlStringEventHandler( + ref string xml, ContentCacheLoadNodeEventArgs e); + + /// + /// Occurs when [after loading the xml string from the database and creating the xml node]. + /// + public delegate void ContentCacheLoadNodeEventHandler(XmlNode xmlNode, ContentCacheLoadNodeEventArgs e); + + public delegate void DocumentCacheEventHandler(Document sender, DocumentCacheEventArgs e); + + public delegate void RefreshContentEventHandler(Document sender, RefreshContentEventArgs e); + /// /// Occurs when [before document cache update]. /// @@ -769,7 +1383,6 @@ namespace umbraco } } - /// /// Occurs when [after document cache update]. /// @@ -857,7 +1470,6 @@ namespace umbraco } } - /// /// Occurs when [after loading the xml string from the database]. /// @@ -931,319 +1543,5 @@ namespace umbraco } #endregion - - #region Protected & Private methods - - /// - /// Invalidates the disk content cache file. Effectively just deletes it. - /// - private void DeleteXmlCache() - { - lock (ReaderWriterSyncLock) - { - if (File.Exists(UmbracoXmlDiskCacheFileName)) - { - // Reset file attributes, to make sure we can delete file - try - { - File.SetAttributes(UmbracoXmlDiskCacheFileName, FileAttributes.Normal); - } - finally - { - File.Delete(UmbracoXmlDiskCacheFileName); - } - } - } - } - - /// - /// Clear HTTPContext cache if any - /// - private void ClearContextCache() - { - // If running in a context very important to reset context cache orelse new nodes are missing - if (UmbracoContext.Current != null && UmbracoContext.Current.HttpContext != null && UmbracoContext.Current.HttpContext.Items.Contains(XmlContextContentItemKey)) - UmbracoContext.Current.HttpContext.Items.Remove(XmlContextContentItemKey); - } - - /// - /// Load content from either disk or database - /// - /// - private XmlDocument LoadContent() - { - if (UmbracoConfig.For.UmbracoSettings().Content.XmlCacheEnabled && IsValidDiskCachePresent()) - { - try - { - return LoadContentFromDiskCache(); - } - catch (Exception e) - { - // This is really bad, loading from cache file failed for some reason, now fallback to loading from database - LogHelper.Error("Content file cache load failed", e); - DeleteXmlCache(); - } - } - return LoadContentFromDatabase(); - } - - private bool IsValidDiskCachePresent() - { - if (File.Exists(UmbracoXmlDiskCacheFileName)) - { - // Only return true if we don't have a zero-byte file - var f = new FileInfo(UmbracoXmlDiskCacheFileName); - if (f.Length > 0) - return true; - } - return false; - } - - /// - /// Load content from cache file - /// - private XmlDocument LoadContentFromDiskCache() - { - lock (ReaderWriterSyncLock) - { - var xmlDoc = new XmlDocument(); - LogHelper.Info("Loading content from disk cache..."); - xmlDoc.Load(UmbracoXmlDiskCacheFileName); - _lastDiskCacheReadTime = DateTime.UtcNow; - return xmlDoc; - } - } - - private static void InitContentDocument(XmlDocument xmlDoc, string dtd) - { - // Prime the xml document with an inline dtd and a root element - xmlDoc.LoadXml(String.Format("{0}{1}{0}", - Environment.NewLine, - dtd)); - } - - /// - /// Load content from database - /// - private XmlDocument LoadContentFromDatabase() - { - try - { - // Try to log to the DB - LogHelper.Info("Loading content from database..."); - - var hierarchy = new Dictionary>(); - var nodeIndex = new Dictionary(); - - try - { - LogHelper.Debug("Republishing starting"); - - lock (DbReadSyncLock) - { - - // Lets cache the DTD to save on the DB hit on the subsequent use - string dtd = DocumentType.GenerateDtd(); - - // Prepare an XmlDocument with an appropriate inline DTD to match - // the expected content - var xmlDoc = new XmlDocument(); - InitContentDocument(xmlDoc, dtd); - - // Esben Carlsen: At some point we really need to put all data access into to a tier of its own. - // CLN - added checks that document xml is for a document that is actually published. - string sql = - @"select umbracoNode.id, umbracoNode.parentId, umbracoNode.sortOrder, cmsContentXml.xml from umbracoNode -inner join cmsContentXml on cmsContentXml.nodeId = umbracoNode.id and umbracoNode.nodeObjectType = @type -where umbracoNode.id in (select cmsDocument.nodeId from cmsDocument where cmsDocument.published = 1) -order by umbracoNode.level, umbracoNode.sortOrder"; - - - - using ( - IRecordsReader dr = SqlHelper.ExecuteReader(sql, - SqlHelper.CreateParameter("@type", - new Guid( - Constants.ObjectTypes.Document))) - ) - { - while (dr.Read()) - { - int currentId = dr.GetInt("id"); - int parentId = dr.GetInt("parentId"); - string xml = dr.GetString("xml"); - - // fix sortOrder - see notes in UpdateSortOrder - var tmp = new XmlDocument(); - tmp.LoadXml(xml); - var attr = tmp.DocumentElement.GetAttributeNode("sortOrder"); - attr.Value = dr.GetInt("sortOrder").ToString(); - xml = tmp.InnerXml; - - // Call the eventhandler to allow modification of the string - var e1 = new ContentCacheLoadNodeEventArgs(); - FireAfterContentCacheDatabaseLoadXmlString(ref xml, e1); - // check if a listener has canceled the event - if (!e1.Cancel) - { - // and parse it into a DOM node - xmlDoc.LoadXml(xml); - XmlNode node = xmlDoc.FirstChild; - // same event handler loader form the xml node - var e2 = new ContentCacheLoadNodeEventArgs(); - FireAfterContentCacheLoadNodeFromDatabase(node, e2); - // and checking if it was canceled again - if (!e1.Cancel) - { - nodeIndex.Add(currentId, node); - - // verify if either of the handlers canceled the children to load - if (!e1.CancelChildren && !e2.CancelChildren) - { - // Build the content hierarchy - List children; - if (!hierarchy.TryGetValue(parentId, out children)) - { - // No children for this parent, so add one - children = new List(); - hierarchy.Add(parentId, children); - } - children.Add(currentId); - } - } - } - } - } - - LogHelper.Debug("Xml Pages loaded"); - - try - { - // If we got to here we must have successfully retrieved the content from the DB so - // we can safely initialise and compose the final content DOM. - // Note: We are reusing the XmlDocument used to create the xml nodes above so - // we don't have to import them into a new XmlDocument - - // Initialise the document ready for the final composition of content - InitContentDocument(xmlDoc, dtd); - - // Start building the content tree recursively from the root (-1) node - GenerateXmlDocument(hierarchy, nodeIndex, -1, xmlDoc.DocumentElement); - - LogHelper.Debug("Done republishing Xml Index"); - - return xmlDoc; - } - catch (Exception ee) - { - LogHelper.Error("Error while generating XmlDocument from database", ee); - } - } - } - catch (OutOfMemoryException ee) - { - LogHelper.Error(string.Format("Error Republishing: Out Of Memory. Parents: {0}, Nodes: {1}", hierarchy.Count, nodeIndex.Count), ee); - } - catch (Exception ee) - { - LogHelper.Error("Error Republishing", ee); - } - } - catch (Exception ee) - { - LogHelper.Error("Error Republishing", ee); - } - - // An error of some sort must have stopped us from successfully generating - // the content tree, so lets return null signifying there is no content available - return null; - } - - private static void GenerateXmlDocument(IDictionary> hierarchy, - IDictionary nodeIndex, int parentId, XmlNode parentNode) - { - List children; - - if (hierarchy.TryGetValue(parentId, out children)) - { - XmlNode childContainer = UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema || - String.IsNullOrEmpty(UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME) - ? parentNode - : parentNode.SelectSingleNode( - UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME); - - if (!UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema && - !String.IsNullOrEmpty(UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME)) - { - if (childContainer == null) - { - childContainer = xmlHelper.addTextNode(parentNode.OwnerDocument, - UmbracoSettings. - TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME, ""); - parentNode.AppendChild(childContainer); - } - } - - foreach (int childId in children) - { - XmlNode childNode = nodeIndex[childId]; - - if (UmbracoConfig.For.UmbracoSettings().Content.UseLegacyXmlSchema || - String.IsNullOrEmpty(UmbracoSettings.TEMP_FRIENDLY_XML_CHILD_CONTAINER_NODENAME)) - { - parentNode.AppendChild(childNode); - } - else - { - childContainer.AppendChild(childNode); - } - - // Recursively build the content tree under the current child - GenerateXmlDocument(hierarchy, nodeIndex, childId, childNode); - } - } - } - - [Obsolete("This method should not be used and does nothing, xml file persistence is done in a queue using a BackgroundTaskRunner")] - public void PersistXmlToFile() - { - } - - /// - /// Adds a task to the xml cache file persister - /// - private void QueueXmlForPersistence() - { - _persisterTask = _persisterTask.Touch(); - } - - internal DateTime GetCacheFileUpdateTime() - { - //TODO: Should there be a try/catch here in case the file is being written to while this is trying to be executed? - - if (File.Exists(UmbracoXmlDiskCacheFileName)) - { - return new FileInfo(UmbracoXmlDiskCacheFileName).LastWriteTimeUtc; - } - - return DateTime.MinValue; - } - - /// - /// Make a copy of a XmlDocument - /// - /// - /// - private static XmlDocument CloneXmlDoc(XmlDocument xmlDoc) - { - if (xmlDoc == null) return null; - - // Save copy of content - var xmlCopy = (XmlDocument)xmlDoc.CloneNode(true); - return xmlCopy; - } - - #endregion } } \ No newline at end of file diff --git a/src/Umbraco.Web/umbraco.presentation/umbraco/preview/PreviewContent.cs b/src/Umbraco.Web/umbraco.presentation/umbraco/preview/PreviewContent.cs index 74040695ef..6a117e732c 100644 --- a/src/Umbraco.Web/umbraco.presentation/umbraco/preview/PreviewContent.cs +++ b/src/Umbraco.Web/umbraco.presentation/umbraco/preview/PreviewContent.cs @@ -102,7 +102,7 @@ namespace umbraco.presentation.preview if (document.Content.Published == false && ApplicationContext.Current.Services.ContentService.HasPublishedVersion(document.Id)) previewXml.Attributes.Append(XmlContent.CreateAttribute("isDraft")); - content.AppendDocumentXml(document.Id, document.Level, parentId, previewXml, XmlContent); + content.AddOrUpdateXmlNode(XmlContent, document.Id, document.Level, parentId, previewXml); } if (includeSubs) @@ -112,7 +112,7 @@ namespace umbraco.presentation.preview var previewXml = XmlContent.ReadNode(XmlReader.Create(new StringReader(prevNode.Xml))); if (prevNode.IsDraft) previewXml.Attributes.Append(XmlContent.CreateAttribute("isDraft")); - XmlContent = content.AppendDocumentXml(prevNode.NodeId, prevNode.Level, prevNode.ParentId, previewXml, XmlContent); + content.AddOrUpdateXmlNode(XmlContent, prevNode.NodeId, prevNode.Level, prevNode.ParentId, previewXml); } }