From b3505599390c9997936d2bc80510ce0424bcd9b2 Mon Sep 17 00:00:00 2001 From: Kevin Jump Date: Sat, 11 Jul 2026 19:50:18 +0100 Subject: [PATCH] Stream item keys for clean operations instead of full XML parse The 'clean' import path needs only the Key attribute for every item in a folder, but was fully parsing each .config file (XElement.LoadAsync with PreserveWhitespace) to read it. On large content/media trees this is a second full file-IO + parse pass over the folder during every import that contains clean markers. Add ISyncFileService.LoadKeyFromFileAsync which streams the file with an XmlReader, reads the Key attribute off the root element and stops - no DOM allocation, no reading the rest of the document. Wire it into the clean path: - SyncHandlerRoot.GetFolderKeysAsync (the per-folder key scan) - SyncHandlerRoot.GetCleanParentAsync - SyncHandlerBase.GetCleanParentKeyAsync, which also now reuses the key for the parent lookup instead of loading the clean file twice. Behaviour is unchanged: the root Key attribute is present on both normal and 'empty' (delete/rename/clean) nodes, files without a Key still yield Guid.Empty, and read errors still throw so a corrupt file aborts the clean rather than silently allowing deletes. Co-Authored-By: Claude Opus 4.8 --- uSync.BackOffice/Services/ISyncFileService.cs | 10 +++++ uSync.BackOffice/Services/SyncFileService.cs | 45 +++++++++++++++++++ .../SyncHandlers/SyncHandlerBase.cs | 8 ++-- .../SyncHandlers/SyncHandlerRoot.cs | 8 ++-- 4 files changed, 64 insertions(+), 7 deletions(-) diff --git a/uSync.BackOffice/Services/ISyncFileService.cs b/uSync.BackOffice/Services/ISyncFileService.cs index 1cafae4b..3a56d435 100644 --- a/uSync.BackOffice/Services/ISyncFileService.cs +++ b/uSync.BackOffice/Services/ISyncFileService.cs @@ -136,6 +136,16 @@ public interface ISyncFileService /// Task LoadXElementAsync(string file); + /// + /// load just the item key (the Key attribute on the root element) from a file. + /// + /// + /// This streams the file and stops at the root element, so we don't pay the cost + /// of parsing the whole document when all we need is the key (e.g. when working out + /// which items live in a folder for a 'clean' operation). + /// + Task LoadKeyFromFileAsync(string file); + /// /// merge all the files in the given folders into a single xml node, that can be bulk imported /// diff --git a/uSync.BackOffice/Services/SyncFileService.cs b/uSync.BackOffice/Services/SyncFileService.cs index ec556441..415a962e 100644 --- a/uSync.BackOffice/Services/SyncFileService.cs +++ b/uSync.BackOffice/Services/SyncFileService.cs @@ -211,6 +211,51 @@ public async Task LoadXElementAsync(string file) } } + private static readonly XmlReaderSettings _keyReaderSettings = new() + { + CheckCharacters = false, + Async = true, + IgnoreWhitespace = true, + IgnoreComments = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit, + }; + + /// + public async Task LoadKeyFromFileAsync(string file) + { + EnsureFileExists(file); + + try + { + using (var stream = OpenRead(file)) + { + if (stream is null) + throw new FileNotFoundException($"Cannot create stream for {file}"); + + using (var reader = XmlReader.Create(stream, _keyReaderSettings.Clone())) + { + // move to the first (root) element and read its Key attribute, + // we don't need to read any further into the document. + while (await reader.ReadAsync()) + { + if (reader.NodeType != XmlNodeType.Element) continue; + + var key = reader.GetAttribute(global::uSync.Core.uSyncConstants.Xml.Key); + return Guid.TryParse(key, out var guid) ? guid : Guid.Empty; + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning("Error while reading key from {file} {message}", file, ex.Message); + throw new Exception($"Error while reading key from {file}", ex); + } + + return Guid.Empty; + } + /// public async Task SaveFileAsync(string filename, Stream stream) { diff --git a/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs b/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs index b5a7d7f6..ebe560d8 100644 --- a/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs +++ b/uSync.BackOffice/SyncHandlers/SyncHandlerBase.cs @@ -99,9 +99,11 @@ protected override async Task> CleanFolderAsync(string private async Task GetCleanParentKeyAsync(string cleanFile) { - var node = await syncFileService.LoadXElementAsync(cleanFile); - if (node.GetKey() == Guid.Empty) return Guid.Empty; - return (await GetCleanParentAsync(cleanFile))?.Key; + // stream the key rather than parsing the whole file, and reuse it for the + // parent lookup so we don't read the clean file a second time. + var key = await syncFileService.LoadKeyFromFileAsync(cleanFile); + if (key == Guid.Empty) return Guid.Empty; + return (await GetFromServiceAsync(key))?.Key; } /// diff --git a/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs b/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs index 61f36530..c34b6907 100644 --- a/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs +++ b/uSync.BackOffice/SyncHandlers/SyncHandlerRoot.cs @@ -697,8 +697,8 @@ protected async Task> GetFolderKeysAsync(string folder, bool flat) foreach (var file in files) { - var node = await syncFileService.LoadXElementAsync(file); - var key = node.GetKey(); + // we only need the key here, so stream it rather than parsing the whole file. + var key = await syncFileService.LoadKeyFromFileAsync(file); if (key != Guid.Empty) { keySet.Add(key); @@ -719,8 +719,8 @@ protected async Task> GetFolderKeysAsync(string folder, bool flat) /// protected async Task GetCleanParentAsync(string file) { - var node = await syncFileService.LoadXElementAsync(file); - var key = node.GetKey(); + // we only need the key to find the parent, so stream it rather than parsing the whole file. + var key = await syncFileService.LoadKeyFromFileAsync(file); if (key == Guid.Empty) return default; return await GetFromServiceAsync(key); }