#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;
using System.Collections.Concurrent;
#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 AsyncVirtualObservableReadOnlyList :
IList,
IList,
ICollection,
#if !NO_SPECIALIZED_COLLECTIONS
INotifyCollectionChanged,
#endif
IObservableCollection,
IDisposable {
#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(AsyncVirtualObservableReadOnlyList 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 AsyncVirtualObservableReadOnlyList 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 AsyncVirtualObservableReadOnlyList(int pageSize = 32) {
this.typedList = new List();
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!;
}
}
///
/// 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(thi)
}
// 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
// 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;
}
}
{
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
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;
/// 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;
#endif
}
} // namespace Nuclex.Avalonia.Collections