diff --git a/Source/Collections/AsyncVirtualObservableReadOnlyList.cs b/Source/Collections/AsyncVirtualObservableReadOnlyList.cs index 3c8c042..4b96843 100644 --- a/Source/Collections/AsyncVirtualObservableReadOnlyList.cs +++ b/Source/Collections/AsyncVirtualObservableReadOnlyList.cs @@ -24,6 +24,8 @@ using System.Diagnostics; using System.Threading.Tasks; using System.Diagnostics.CodeAnalysis; using System.Threading; +using System.Collections.Concurrent; + #if !NO_SPECIALIZED_COLLECTIONS using System.Collections.Specialized; @@ -45,7 +47,8 @@ namespace Nuclex.Avalonia.Collections { #if !NO_SPECIALIZED_COLLECTIONS INotifyCollectionChanged, #endif - IObservableCollection { + IObservableCollection, + IDisposable { #region class Enumerator @@ -191,6 +194,17 @@ namespace Nuclex.Avalonia.Collections { this.objectList = (IList)this.typedList; this.pageSize = pageSize; this.fetchedPages = new bool[0]; + this.requestedPages = new ConcurrentQueue(); + this.cancellationTokenSource = new CancellationTokenSource(); + } + + /// Stops all queued fetches and frees the collection's resources + public void Dispose() { + if(this.cancellationTokenSource != null) { + this.cancellationTokenSource.Cancel(); + this.cancellationTokenSource.Dispose(); + this.cancellationTokenSource = null!; + } } /// @@ -708,6 +722,8 @@ namespace Nuclex.Avalonia.Collections { return; } itemCount = this.assumedCount.Value; + + //if(thi) } // If the page is already fetched (or in flight), do nothing @@ -730,70 +746,103 @@ namespace Nuclex.Avalonia.Collections { Interlocked.MemoryBarrier(); this.fetchedPages[pageIndex] = true; // Prevent double-fetch - // Remember the placeholder items that are going to be replaced by - // the fetch operation below,allowing us to report these in our change notification - var placeholderItems = new List(capacity: count); - for(int index = 0; index < count; ++index) { - placeholderItems.Add(this.typedList[index + offset]); - - // We act as if the whole list was filled with place holders from the start, - // but only realize these as needed (so no change notification for these). - // If we did it right, the user will not be able to ever see the uninitialized - // items, yet we only create placeholders when they are really needed. - //OnReplaced(default(TItem), this.typedList[index + offset], index); - } - - // Now request the items on the page. This request will run asynchronously, - // but we'll use an await to get to deliver change notifications once - // the page has been fetched and the items are there. - int fetchedItemCount; - try { -#if DEBUG - System.Diagnostics.Trace.WriteLine( - $"avor: background-fetching items ${offset} +${count}" - ); -#endif - fetchedItemCount = await FetchItemsAsync(this.typedList, offset, count); -#if DEBUG - System.Diagnostics.Trace.WriteLine( - $"avor: finished background-fetching items ${offset} +${count}" - ); -#endif - } - catch(Exception error) { - - // Do not let the error bubble up into what is likely an unsuspecting - // data grid control or virtualized list widget. Let the user decide what - // to do about the error - i.e. set an error flag in their view model. - HandleFetchError( - error, $"Failed to fetch list items {offset} to {offset + count}" - ); - this.fetchedPages[pageIndex] = false; // We failed! - return; // Leave the placeholder items in - - } - if(fetchedItemCount < this.pageSize) { - itemCount = offset + fetchedItemCount; - lock(this) { - this.assumedCount = itemCount; + // If another thread is already fetching pages, just schedule this additional page + // instead of kicking off another async fetch which could potentially lead to + // hundreds of async tasks dog-piling on a database connection. + lock(this) { + if(this.isFetching) { + this.requestedPages.TryAdd(pageIndex); + return; + } else { + this.isFetching = true; } -#if DEBUG - ++this.version; -#endif } - // The count may have been adjusted if this truncated the list, - // so recalculate the actual number of items. Then send out change - // notifications for the items that have now been fetched. - count = Math.Min(itemCount - offset, this.pageSize); -#if DEBUG - System.Diagnostics.Trace.WriteLine( - $"avor: sending 'replace' notifications for items ${offset} +${count}" - ); -#endif - for(int index = 0; index < count; ++index) { - OnReplaced(placeholderItems[index], this.typedList[index + offset], index + offset); - } + { + CancellationToken canceller = this.cancellationTokenSource.Token; + + // Fetch items from the database. While we do this, other requests may come in + // and will see that one thread is already doing a fetch, then queue their page index + // on the list of requested pages list. This loop will keep running until either + // cancellation occurs or until there are no more requested pages. + while(!canceller.IsCancellationRequested) { + offset = pageIndex * this.pageSize; + count = Math.Min(itemCount - offset, this.pageSize); + + // Remember the previous items that are going to be replaced by + // the fetch operation below,allowing us to report these in our change notification + var previousItems = new List(capacity: count); + for(int index = 0; index < count; ++index) { + previousItems.Add(this.typedList[index + offset]); + } + + // Now request the items on the page. This request will run asynchronously, + // but we'll use an await to get to deliver change notifications once + // the page has been fetched and the items are there. + int fetchedItemCount; + try { + #if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: background-fetching items ${offset} +${count}" + ); + #endif + fetchedItemCount = await FetchItemsAsync(this.typedList, offset, count); + #if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: finished background-fetching items ${offset} +${count}" + ); + #endif + } + catch(Exception error) { + + // Do not let the error bubble up into what is likely an unsuspecting + // data grid control or virtualized list widget. Let the user decide what + // to do about the error - i.e. set an error flag in their view model. + HandleFetchError( + error, $"Failed to fetch list items {offset} to {offset + count}" + ); + this.fetchedPages[pageIndex] = false; // We failed! + lock(this) { + this.isFetching = false; + } + return; // Leave the placeholder items in, do not process the queue further + + } + if(fetchedItemCount < this.pageSize) { + itemCount = offset + fetchedItemCount; + lock(this) { + this.assumedCount = itemCount; + } + #if DEBUG + ++this.version; + #endif + } + + // The count may have been adjusted if this truncated the list, + // so recalculate the actual number of items. Then send out change + // notifications for the items that have now been fetched. + count = Math.Min(itemCount - offset, this.pageSize); + #if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: sending 'replace' notifications for items ${offset} +${count}" + ); + #endif + for(int index = 0; index < count; ++index) { + OnReplaced(previousItems[index], this.typedList[index + offset], index + offset); + } + + // See if there is another page we need to fetch. If there is, continue + // with that page, otherwise, we're done, so we clear the isFetching flag + // to make the next caller that wants items fetch them rather than queue them. + lock(this) { + if(!this.requestedPages.TryTake(out pageIndex)) { + this.isFetching = false; + break; + } + } + + } // while not cancellation requested + } // beauty scope } /// Number of items the collection believes it has @@ -806,6 +855,17 @@ namespace Nuclex.Avalonia.Collections { private IList typedList; /// The wrapped list under its object interface private IList objectList; + /// Pages still waiting to be fetched + private IProducerConsumerCollection requestedPages; + /// Whether the collection is already fetching pages + /// + /// This is set while a page is being fetched. If it is set, additional + /// requested pages will be queued in the + /// queue to avoid dog-piling on an SQL connection. + /// + private bool isFetching; // Could be improved to an int to allow N simultaneous fetches + /// Allows for cancellation of the whole lazy-loaded collection + private CancellationTokenSource cancellationTokenSource; #if DEBUG /// Used to detect when enumerators go out of sync private int version; diff --git a/Source/Collections/AsyncVirtualObservableReadOnlyList2.cs b/Source/Collections/AsyncVirtualObservableReadOnlyList2.cs new file mode 100644 index 0000000..e859161 --- /dev/null +++ b/Source/Collections/AsyncVirtualObservableReadOnlyList2.cs @@ -0,0 +1,816 @@ +#region Apache License 2.0 +/* +Nuclex Foundation libraries for .NET +Copyright (C) 2002-2025 Markus Ewald / Nuclex Development Labs + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +#endregion // Apache License 2.0 + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; +using System.Threading; + +#if !NO_SPECIALIZED_COLLECTIONS +using System.Collections.Specialized; +#endif + +using Nuclex.Support.Collections; + +namespace Nuclex.Avalonia.Collections { + + /// + /// List which fires events when items are added or removed, whilst also + /// lazily fetching items as needed (for example from a socket or database) + /// + /// Type of items the list manages + public abstract class AsyncVirtualObservableReadOnlyList2 : + IList, + IList, + ICollection, +#if !NO_SPECIALIZED_COLLECTIONS + INotifyCollectionChanged, +#endif + IObservableCollection { + + #region class Enumerator + + /// Enumerates over the items in a virtual list + private class Enumerator : IEnumerator, IEnumerator { + + /// Initializes a new virtual list enumerator + /// List whose items will be enumerated + public Enumerator(AsyncVirtualObservableReadOnlyList2 virtualList) { + this.virtualList = virtualList; + this.currentItemIndex = -1; + this.lastMoveNextResult = false; + + Reset(); + } + + /// Immediately releases all resources owned by the instance + public void Dispose() { + this.virtualList = null!; // Only to decouple and make the GC's work easier + } + + /// The item at the enumerator's current position + public TItem Current { + get { +#if DEBUG + checkVersion(); +#endif + + // If the most recent call to MoveNext() returned false, it means that + // the enumerator has reached the end of the list, so in that + if(this.lastMoveNextResult == false) { + throw new InvalidOperationException("Enumerator is not on a valid position"); + } + + this.virtualList.requireCount(); + this.virtualList.requirePage(this.currentItemIndex / this.virtualList.pageSize); + + return this.virtualList.typedList[this.currentItemIndex]; + } + } + + /// Advances the enumerator to the next item + /// True if there was a next item + public bool MoveNext() { +#if DEBUG + checkVersion(); +#endif + + this.virtualList.requireCount(); + + // Go forward if there potentially are items remaining. The count may still be + // unreliable at this point (due to the uncertain count mechanism that truncates + // the list when fetching items finds an earlier end of the list) + if(this.currentItemIndex < this.virtualList.assumedCount.Value) { + + // If the enumerator's 'Current' property is never used an the virtual list + // uses the dynamic truncation features (for unknown list sizes), then this + // enumerator could be moved way past the last element via 'MoveNext()'. + this.virtualList.requirePage(this.currentItemIndex / this.virtualList.pageSize); + ++this.currentItemIndex; // Accept potentially advancing past the end here + + } + + // Are we on a valid item? If so, return true to indicate the list continued, + // otherwise we must have hit the end (or are already past it). + return this.lastMoveNextResult = ( + (this.currentItemIndex < this.virtualList.assumedCount.Value) + ); + } + + /// Resets the enumerator to its initial position + public void Reset() { + this.virtualList.requireCount(); // to fix version + + this.currentItemIndex = -1; +#if DEBUG + this.expectedVersion = this.virtualList.version; +#endif + } + + /// The item at the enumerator's current position + object? IEnumerator.Current { + get { return Current; } + } + +#if DEBUG + /// Ensures that the virtual list has not changed + private void checkVersion() { + if(this.expectedVersion != this.virtualList.version) + throw new InvalidOperationException("Virtual list has been modified"); + } +#endif + + /// Virtual list the enumerator belongs to + private AsyncVirtualObservableReadOnlyList2 virtualList; + /// Index of the item the enumerator currently is in + private int currentItemIndex; + /// The most recent result returned from MoveNext() + private bool lastMoveNextResult; +#if DEBUG + /// Version the virtual list is expected to have + private int expectedVersion; +#endif + } + + #endregion // class Enumerator + + /// Raised when an item has been added to the collection + public event EventHandler>? ItemAdded { add {} remove{} } + /// Raised when an item is removed from the collection + public event EventHandler>? ItemRemoved { add {} remove{} } + /// Raised when an item is replaced in the collection + public event EventHandler>? ItemReplaced; + /// Raised when the collection is about to be cleared + /// + /// This could be covered by calling ItemRemoved for each item currently + /// contained in the collection, but it is often simpler and more efficient + /// to process the clearing of the entire collection as a special operation. + /// + public event EventHandler? Clearing { add {} remove{} } + /// Raised when the collection has been cleared + public event EventHandler? Cleared { add {} remove{} } + +#if !NO_SPECIALIZED_COLLECTIONS + /// Called when the collection has changed + public event NotifyCollectionChangedEventHandler? CollectionChanged; +#endif + + /// + /// Initializes a new instance of the ObservableList class that is empty. + /// + /// + /// How many items to download in one batch + /// + /// + /// The can be set to one to request items + /// individually or to a larger value in order to improve efficiency when + /// the source of the items is a database or similar source that gains + /// performance from requesting multiple items at once. + /// + public AsyncVirtualObservableReadOnlyList2(int pageSize = 32) { + this.typedList = new List(); + this.objectList = (IList)this.typedList; + this.pageSize = pageSize; + this.fetchedPages = new bool[0]; + } + + /// + /// Marks all items as non-fetched, causing them to be requested again + /// + /// + /// Whether to also clear the items that may already be in memory but would + /// get overwritten on the next fetch. If items consume a lot of memory, this + /// will make them available for garbage collection. + /// + public void InvalidateAll(bool purgeItems = false) { + int itemCount; + lock(this) { + if(!this.assumedCount.HasValue) { + return; // If not fetched before, no action is needed + } + + itemCount = this.assumedCount.Value; + } + + // Mark the pages as un-fetched + int pageCount = this.fetchedPages.Length; + if(purgeItems) { + var oldItemList = new List(capacity: this.pageSize); + + for(int pageIndex = 0; pageIndex < pageCount; ++pageIndex) { + if(this.fetchedPages[pageIndex]) { + this.fetchedPages[pageIndex] = false; + + // Figure out the page's item start index and how big the page is + int offset = pageIndex * this.pageSize; + int count = Math.Min(itemCount - offset, this.pageSize); + + // Make a backup of the old item values so we can provide them + // in the ItemReplaced change notification we'll send out. + for(int index = 0; index < count; ++index) { + oldItemList[index] = this.typedList[offset + index]; + } + + // Replace the loaded items with placeholder items +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: setting up placeholders for items ${offset} +${count}" + ); +#endif + CreatePlaceholderItems(this.typedList, offset, count); + + // Send out change notifications for all items we just replace with placeholders +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: sending 'replace' notifications for items ${offset} +${count}" + ); +#endif + for(int index = 0; index < count; ++index) { + OnReplaced(oldItemList[index], this.typedList[offset + index], offset + index); + } + + // Make sure the old items aren't lingering around + oldItemList.Clear(); + } + } + + } else { + for(int pageIndex = 0; pageIndex < pageCount; ++pageIndex) { + this.fetchedPages[pageIndex] = false; + } + } + } + + /// + /// Marks an items as non-fetched, causing it to be requested again on access + /// + /// + /// Index of the item that will be marked as non-fetched + /// + /// + /// Whether to also clear the items that may already be in memory but would + /// get overwritten on the next fetch. If items consume a lot of memory, this + /// will make them available for garbage collection. + /// + /// + /// Since the list works in pages, this will actually mark the whole page as + /// non-fetched, causing all items in the same page to be requested again + /// when any of them are next accessed. + /// + public void Invalidate(int itemIndex, bool purgeItems = false) { + lock(this) { + if(!this.assumedCount.HasValue) { + return; // If not fetched before, no action is needed + } + } + + // Mark the entie page as needing re-fetching (we've got no other choice) + int pageIndex = itemIndex / this.pageSize; + this.fetchedPages[pageIndex] = false; + + // If we're asked to also purge the item, replace the item with + // a placeholder item and trigger the change notification + if(purgeItems) { + TItem oldItem = this.typedList[itemIndex]; +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: setting up placeholder for item ${itemIndex}" + ); +#endif + CreatePlaceholderItems(this.typedList, itemIndex, 1); +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: sending 'replace' notifications for item ${itemIndex}" + ); +#endif + OnReplaced(oldItem, this.typedList[itemIndex], itemIndex); + } + } + + /// Determines the index of the specified item in the list + /// Item whose index will be determined + /// The index of the item in the list or -1 if not found + public int IndexOf(TItem item) { + requireCount(); + requireAllPages(); + + // TODO: this won't work, it will compare the placeholder items :-/ + // (because the pages are still being fetched at this point) + + IComparer itemComparer = Comparer.Default; + for(int index = 0; index < this.assumedCount.Value; ++index) { + if(itemComparer.Compare(this.typedList[index], item) == 0) { + return index; + } + } + + return -1; + } + + /// Inserts an item into the list at the specified index + /// Index the item will be inserted at + /// Item that will be inserted into the list + public void Insert(int index, TItem item) { + throw new NotSupportedException("Cannot insert items into a read-only list"); + } + + /// Removes the item at the specified index from the list + /// Index at which the item will be removed + public void RemoveAt(int index) { + throw new NotSupportedException("Cannot remove items from a read-only list"); + } + + /// Accesses the item at the specified index in the list + /// Index of the item that will be accessed + /// The item at the specified index + public TItem this[int index] { + get { + requireCount(); + requirePage(index / this.pageSize); + + return this.typedList[index]; + } + set { + throw new NotSupportedException("Cannot replace items in a read-only list"); + } + } + + /// Adds an item to the end of the list + /// Item that will be added to the list + public void Add(TItem item) { + throw new NotSupportedException("Cannot add items to a read-only list"); + } + + /// Removes all items from the list + public void Clear() { + throw new NotSupportedException("Cannot clear a read-only list"); + } + + /// Checks whether the list contains the specified item + /// Item the list will be checked for + /// True if the list contains the specified items + public bool Contains(TItem item) { + // TODO: this won't work, it will compare the placeholder items :-/ + return (IndexOf(item) != -1); + } + + /// Copies the contents of the list into an array + /// Array the list will be copied into + /// + /// Index in the target array where the first item will be copied to + /// + public void CopyTo(TItem[] array, int arrayIndex) { + requireCount(); + requireAllPages(); + + // TODO: this won't work, it will copy the placeholder items :-/ + this.typedList.CopyTo(array, arrayIndex); + } + + /// Total number of items in the list + public int Count { + get { return requireCount(); } + } + + /// Whether the list is a read-only list + public bool IsReadOnly { + get { + //return this.typedList.IsReadOnly; + return true; + } + } + + /// Removes the specified item from the list + /// Item that will be removed from the list + /// + /// True if the item was found and removed from the list, false otherwise + /// + public bool Remove(TItem item) { + throw new NotSupportedException("Cannot remove items from a read-only list"); + } + + /// Returns an enumerator for the items in the list + /// An enumerator for the list's items + public IEnumerator GetEnumerator() { + return new Enumerator(this); + } + + #region IEnumerable implementation + + /// Returns an enumerator for the items in the list + /// An enumerator for the list's items + IEnumerator IEnumerable.GetEnumerator() { + return new Enumerator(this); + } + + #endregion // IEnumerable implementation + + #region ICollection implementation + + /// Copies the contents of the list into an array + /// Array the list will be copied into + /// + /// Index in the target array where the first item will be copied to + /// + void ICollection.CopyTo(Array array, int arrayIndex) { + requireCount(); + requireAllPages(); + + this.objectList.CopyTo(array, arrayIndex); + } + + /// Whether this list performs thread synchronization + bool ICollection.IsSynchronized { + get { return this.objectList.IsSynchronized; } + } + + /// Synchronization root used by the list to synchronize threads + object ICollection.SyncRoot { + get { return this.objectList.SyncRoot; } + } + + #endregion // ICollection implementation + + #region IList implementation + + /// Adds an item to the list + /// Item that will be added to the list + /// + /// The position at which the item has been inserted or -1 if the item was not inserted + /// + int IList.Add(object? value) { + throw new NotSupportedException("Cannot add items into a read-only list"); + } + + /// Checks whether the list contains the specified item + /// Item the list will be checked for + /// True if the list contains the specified items + bool IList.Contains(object? item) { + requireCount(); + requireAllPages(); + return this.objectList.Contains(item); + } + + /// Determines the index of the specified item in the list + /// Item whose index will be determined + /// The index of the item in the list or -1 if not found + int IList.IndexOf(object? item) { + requireCount(); + requireAllPages(); + return this.objectList.IndexOf(item); + } + + /// Inserts an item into the list at the specified index + /// Index the item will be inserted at + /// Item that will be inserted into the list + void IList.Insert(int index, object? item) { + throw new NotSupportedException("Cannot insert items into a read-only list"); + } + + /// Whether the list is of a fixed size + bool IList.IsFixedSize { + get { return this.objectList.IsFixedSize; } + } + + /// Removes the specified item from the list + /// Item that will be removed from the list + void IList.Remove(object? item) { + throw new NotSupportedException("Cannot remove items from a read-only list"); + } + + /// Accesses the item at the specified index in the list + /// Index of the item that will be accessed + /// The item at the specified index + object? IList.this[int index] { + get { + requireCount(); + requirePage(index / this.pageSize); + + return this.objectList[index]; + } + set { + throw new NotSupportedException("Cannot assign items into a read-only list"); + } + } + + #endregion // IList implementation + + /// Fires the 'ItemReplaced' event + /// Item that has been replaced + /// New item the original item was replaced with + /// Index of the replaced item + protected virtual void OnReplaced(TItem oldItem, TItem newItem, int index) { + if(ItemReplaced != null) { + ItemReplaced(this, new ItemReplaceEventArgs(oldItem, newItem)); + } +#if !NO_SPECIALIZED_COLLECTIONS + if(CollectionChanged != null) { + CollectionChanged( + this, + new NotifyCollectionChangedEventArgs( + NotifyCollectionChangedAction.Replace, newItem, oldItem, index + ) + ); + } +#endif + } + +#if false + /// Forces the items list to be allocated + /// + /// If your item count is already known by the time the list is constructed, + /// you can call this method in your constructor and avoid potential enumerator + /// version exceptions due to the underlying list changing between enumerator + /// creation and enumerating the first item. + /// + protected void ForceItemAllocation() { + requireCount(); + } + + protected bool IsFetched(int itemIndex) { + requireCount(); + return this.fetchedPages[itemIndex / this.pageSize]; + } +#endif + + /// Retrieves an item by index without triggering a fetch + /// Index of the item that will be retrieved + /// The item at the specified index + /// + /// You can use this method if your lazy-loaded collected has, for example, an extra + /// IsSelected column which the user can toggle on or off. By checking + /// the IsSelected state via this method, you avoid fetching any pages + /// merely to check if the user selected them. You will be exposed to placeholder + /// items (and even null items until I fix this...) + /// + protected TItem GetAtIndexWithoutFetching(int index) { + requireCount(); + return this.typedList[index]; + } + + /// Counts the total number of items in the virtual collection + /// The total number of items + protected abstract int CountItems(); + + /// Fetches a page required by the collection + /// List into which the items should be fetched + /// + /// Index of the first item to fetch. This is both the start index in + /// the actual data and the element index at which to write into the list. + /// + /// Number of items that should be fetched + /// The number of items that were actually fetched + /// + /// If you fetch fewer than the requested number of items here, you will immediately + /// truncate the entire list (it will assume that the end was reached, a means + /// to support cases where the total number is not known). Fetching more than + /// the requested number of items will just put items in memory that the list will + /// continue to think are empty and fetch again if they are actually accessed. + /// + protected abstract Task FetchItemsAsync( + IList target, int startIndex, int count + ); + + /// Requests placeholder items to be created + /// List into which the items should be created + /// + /// Index of the first item to create a placeholder for. This is both the start index + /// in the actual data and the element index at which to write into the list. + /// + /// Number of items that should be crated + /// + /// When you requests items that have not been fetched yet, this method will be + /// called synchronously to create placeholder items (these could be empty items + /// or items with a 'StillLoading' flag set. This allows any UI controls showing + /// the collection's contents to immediately display the items while fetching + /// happens in the background and will replace the placeholder once complete. + /// + protected abstract void CreatePlaceholderItems( + IList target, int startIndex, int count + ); + + /// Reports errors when fetching data + /// Exception that has occured + /// Describes the action during which the error happened + protected abstract void HandleFetchError(Exception error, string action); + + /// Ensures that the total number of items is known + /// The item count + [MemberNotNull(nameof(assumedCount))] + private int requireCount() { + lock(this) { + if(this.assumedCount.HasValue) { + return this.assumedCount.Value; + } + } + + int itemCount; + try { +#if DEBUG + System.Diagnostics.Trace.WriteLine("avor: counting items synchronously"); +#endif + itemCount = CountItems(); + } + catch(Exception error) { + lock(this) { + this.assumedCount = 0; // Act as if the collection has zero items + } + + // Do not let the error bubble up into what is likely an unsuspecting + // data grid control or virtualized list widget. Let the user decide what + // to do about the error - i.e. set an error flag in their view model. + HandleFetchError( + error, "Failed to determine the number of items in the collection" + ); + return 0; + } // try CountItems() catch + + // Items were counted successfully, so adopt the count and initialize our + // internal arrays to avoid having to resize the arrays later on (which + // saves us a few lock statements) + lock(this) { + this.assumedCount = itemCount; + + int pageCount = (itemCount + this.pageSize - 1) / this.pageSize; + this.fetchedPages = new bool[pageCount]; + + // Resize the list (so we don't have to mutex-lock the list itself), + // but put default items in there. We'll create placeholders when they + // are accessed, not before (ideally, I'd want uninitialized instances + // here, but that would need very thorough verification + while(this.typedList.Count < itemCount) { + this.typedList.Add(default(TItem)!); + } + } // lock this + +#if DEBUG + ++this.version; +#endif + + return itemCount; + } + + /// Ensures that all items have fetched + /// + /// Avoid if possible. + /// + private void requireAllPages() { + lock(this) { + Debug.Assert( + this.assumedCount.HasValue, + "This method should only be called when item count is already known" + ); + } + +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: warning - a scanning operation had to request all available pages" + ); +#endif + + int pageCount = this.fetchedPages.Length; + for(int index = 0; index < pageCount; ++index) { + requirePage(index); + // CHECK: Should we throttle this by constructing a clever chain of + // ContinueWith() tasks so we don't cause a flood of async requests? + } + } + + /// Ensures that the specified page has been fetched + /// Index of the page that needs to be fetched + private async void requirePage(int pageIndex) { + int itemCount; + lock(this) { + if(!this.assumedCount.HasValue) { + Debug.Assert( + this.assumedCount.HasValue, + "This method should only be called when item count is already known" + ); + return; + } + itemCount = this.assumedCount.Value; + } + + // If the page is already fetched (or in flight), do nothing + if((pageIndex >= this.fetchedPages.Length) || this.fetchedPages[pageIndex]) { + return; + } + + // The page was not fetched, so synchronously fill it with place holder items + // as a first step (their creation should be fast and immediate). + int offset = pageIndex * this.pageSize; + int count = Math.Min(itemCount - offset, this.pageSize); +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: setting up placeholders for items ${offset} +${count}" + ); +#endif + CreatePlaceholderItems(this.typedList, offset, count); + + // Ensure the items are present before marking the page as fetched + Interlocked.MemoryBarrier(); + this.fetchedPages[pageIndex] = true; // Prevent double-fetch + + // Remember the placeholder items that are going to be replaced by + // the fetch operation below,allowing us to report these in our change notification + var placeholderItems = new List(capacity: count); + for(int index = 0; index < count; ++index) { + placeholderItems.Add(this.typedList[index + offset]); + + // We act as if the whole list was filled with place holders from the start, + // but only realize these as needed (so no change notification for these). + // If we did it right, the user will not be able to ever see the uninitialized + // items, yet we only create placeholders when they are really needed. + //OnReplaced(default(TItem), this.typedList[index + offset], index); + } + + // Now request the items on the page. This request will run asynchronously, + // but we'll use an await to get to deliver change notifications once + // the page has been fetched and the items are there. + int fetchedItemCount; + try { +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: background-fetching items ${offset} +${count}" + ); +#endif + fetchedItemCount = await FetchItemsAsync(this.typedList, offset, count); +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: finished background-fetching items ${offset} +${count}" + ); +#endif + } + catch(Exception error) { + + // Do not let the error bubble up into what is likely an unsuspecting + // data grid control or virtualized list widget. Let the user decide what + // to do about the error - i.e. set an error flag in their view model. + HandleFetchError( + error, $"Failed to fetch list items {offset} to {offset + count}" + ); + this.fetchedPages[pageIndex] = false; // We failed! + return; // Leave the placeholder items in + + } + if(fetchedItemCount < this.pageSize) { + itemCount = offset + fetchedItemCount; + lock(this) { + this.assumedCount = itemCount; + } +#if DEBUG + ++this.version; +#endif + } + + // The count may have been adjusted if this truncated the list, + // so recalculate the actual number of items. Then send out change + // notifications for the items that have now been fetched. + count = Math.Min(itemCount - offset, this.pageSize); +#if DEBUG + System.Diagnostics.Trace.WriteLine( + $"avor: sending 'replace' notifications for items ${offset} +${count}" + ); +#endif + for(int index = 0; index < count; ++index) { + OnReplaced(placeholderItems[index], this.typedList[index + offset], index + offset); + } + } + + /// Number of items the collection believes it has + private int? assumedCount; + /// Number of items to fetch in a single request + private readonly int pageSize; + /// Tracks which pages have been requested so far + private bool[] fetchedPages; + /// The wrapped list under its type-safe interface + private IList typedList; + /// The wrapped list under its object interface + private IList objectList; +#if DEBUG + /// Used to detect when enumerators go out of sync + private int version; +#endif + + } + +} // namespace Nuclex.Avalonia.Collections