Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Assets/Plugins/StreamChat/Core/Configs/IStreamClientConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,13 @@ public interface IStreamClientConfig
/// ordering matters more than instant local feedback (e.g. a shared, broadcast-ordered feed).
/// </summary>
bool OptimisticMessageInsert { get; set; }

/// <summary>
/// Default local message cache limit for all channels. <c>null</c> (default) = unlimited.
/// Use <see cref="MessageCacheWindow.Recommended"/> for livestream-style channels.
/// Per-channel overrides: <see cref="StatefulModels.IStreamChannel.OverrideMessageCacheWindow"/>.
/// Does not change server history. See <see cref="StatefulModels.IStreamChannel.MessageCacheWindow"/>.
/// </summary>
MessageCacheWindow DefaultMessageCacheWindow { get; set; }
}
}
101 changes: 101 additions & 0 deletions Assets/Plugins/StreamChat/Core/Configs/MessageCacheWindow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using System;

namespace StreamChat.Core.Configs
{
/// <summary>
/// Limits how many messages a channel keeps in the local cache.
/// Assign to <see cref="IStreamClientConfig.DefaultMessageCacheWindow"/> or
/// <see cref="StatefulModels.IStreamChannel.OverrideMessageCacheWindow"/>.
/// Trimming runs in batches of <see cref="DiscardBatchSize"/> once the count exceeds
/// <see cref="MaxMessages"/>. Nothing is ever removed while
/// <see cref="StatefulModels.IStreamChannel.IsMessageCacheTrimmingPaused"/> is <c>true</c> - growth is
/// bounded by <see cref="MaxHistoryMessages"/> instead.
/// </summary>
public sealed class MessageCacheWindow
{
/// <summary>Keep up to 500 messages; remove 100 at a time when over the limit; stop paging in history at 2000.</summary>
public static readonly MessageCacheWindow Recommended = new MessageCacheWindow(500, 100, 2000);

/// <summary>Trimming starts when <see cref="StatefulModels.IStreamChannel.Messages"/> exceeds this count.</summary>
public int MaxMessages { get; }

/// <summary>How many messages to remove per trim. Must be less than <see cref="MaxMessages"/>.</summary>
public int DiscardBatchSize { get; }

/// <summary>
/// How large <see cref="StatefulModels.IStreamChannel.Messages"/> may grow before
/// <see cref="StatefulModels.IStreamChannel.LoadOlderMessagesAsync"/> stops paging in history. This is
/// the total message count, not a separate budget for paged-in messages.
/// <para>It only comes into play while
/// <see cref="StatefulModels.IStreamChannel.IsMessageCacheTrimmingPaused"/> is <c>true</c> - which
/// <see cref="StatefulModels.IStreamChannel.LoadOlderMessagesAsync"/> sets automatically - because
/// otherwise <see cref="MaxMessages"/> already keeps the channel smaller than this.</para>
/// <para>Reaching it never removes anything: paged-in history is exactly what a trim would delete.
/// Loading simply stops until
/// <see cref="StatefulModels.IStreamChannel.ResumeMessageCacheTrimming"/> is called. Live messages are
/// still appended, so a channel that is never resumed can grow past this value; the SDK logs a warning
/// once when that happens.</para>
/// Must be greater than or equal to <see cref="MaxMessages"/>. Defaults to 4x <see cref="MaxMessages"/>.
/// Setting it equal to <see cref="MaxMessages"/> means "never page in history beyond the normal limit".
/// </summary>
public int MaxHistoryMessages { get; }

public MessageCacheWindow(int maxMessages, int discardBatchSize)
: this(maxMessages, discardBatchSize, GetDefaultMaxHistoryMessages(maxMessages))
{
}

public MessageCacheWindow(int maxMessages, int discardBatchSize, int maxHistoryMessages)
{
if (maxMessages <= 0)
{
throw new ArgumentOutOfRangeException(nameof(maxMessages), maxMessages,
$"{nameof(maxMessages)} must be greater than zero.");
}

if (discardBatchSize <= 0)
{
throw new ArgumentOutOfRangeException(nameof(discardBatchSize), discardBatchSize,
$"{nameof(discardBatchSize)} must be greater than zero.");
}

if (discardBatchSize >= maxMessages)
{
throw new ArgumentOutOfRangeException(nameof(discardBatchSize), discardBatchSize,
$"{nameof(discardBatchSize)} must be smaller than {nameof(maxMessages)} ({maxMessages}), "
+ "otherwise a single trim would remove every message.");
}

if (maxHistoryMessages < maxMessages)
{
throw new ArgumentOutOfRangeException(nameof(maxHistoryMessages), maxHistoryMessages,
$"{nameof(maxHistoryMessages)} must be greater than or equal to {nameof(maxMessages)} "
+ $"({maxMessages}).");
}

MaxMessages = maxMessages;
DiscardBatchSize = discardBatchSize;
MaxHistoryMessages = maxHistoryMessages;
}

public override string ToString()
=> $"MessageCacheWindow - MaxMessages: {MaxMessages}, DiscardBatchSize: {DiscardBatchSize}, "
+ $"MaxHistoryMessages: {MaxHistoryMessages}";

private const int DefaultMaxHistoryMessagesMultiplier = 4;

// Invalid values are passed through so the constructor reports the real problem instead of
// a derived one.
private static int GetDefaultMaxHistoryMessages(int maxMessages)
{
if (maxMessages <= 0)
{
return maxMessages;
}

return maxMessages > int.MaxValue / DefaultMaxHistoryMessagesMultiplier
? int.MaxValue
: maxMessages * DefaultMaxHistoryMessagesMultiplier;
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Assets/Plugins/StreamChat/Core/Configs/StreamClientConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ public class StreamClientConfig : IStreamClientConfig
public StreamLogLevel LogLevel { get; set; } = StreamLogLevel.FailureOnly;

public bool OptimisticMessageInsert { get; set; } = true;

public MessageCacheWindow DefaultMessageCacheWindow { get; set; } = null;
}
}
2 changes: 2 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/FilteredList.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ public void Insert(int index, T item)

public void RemoveAt(int index) => _internalList.RemoveAt(index);

public void RemoveRange(int index, int count) => _internalList.RemoveRange(index, count);

public T this[int index]
{
get => _internalList[index];
Expand Down
41 changes: 41 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/HashSetPool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;

namespace StreamChat.Core.Helpers
{
internal static class HashSetPool<T>
{
public static HashSet<T> Rent()
{
if (Pool.Count > 0)
{
return Pool.Pop();
}

return new HashSet<T>();
}

public static void Release(HashSet<T> set)
{
if (set == null)
{
throw new ArgumentNullException(nameof(set));
}

// HashSet has no Capacity accessor, so the pre-clear count stands in for how large the
// buckets grew. See the same guard in ListPool.
var isOversized = set.Count > MaxRetainedCount;

set.Clear();

if (!isOversized && Pool.Count < MaxPoolSize)
{
Pool.Push(set);
}
}

private const int MaxPoolSize = 128;
private const int MaxRetainedCount = 4096;
private static readonly Stack<HashSet<T>> Pool = new Stack<HashSet<T>>();
}
}
3 changes: 3 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/HashSetPool.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/HashSetPoolScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;

namespace StreamChat.Core.Helpers
{
internal readonly struct HashSetPoolScope<T> : IDisposable
{
public HashSetPoolScope(out HashSet<T> set)
{
_set = HashSetPool<T>.Rent();
set = _set;
}

public void Dispose()
{
if (_set != null)
{
HashSetPool<T>.Release(_set);
}
}

private readonly HashSet<T> _set;
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/ListPool.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Collections.Generic;

namespace StreamChat.Core.Helpers
{
internal static class ListPool<T>
{
public static List<T> Rent()
{
if (Pool.Count > 0)
{
return Pool.Pop();
}

return new List<T>();
}

public static void Release(List<T> list)
{
if (list == null)
{
throw new ArgumentNullException(nameof(list));
}

// Clear() keeps the backing array, so a one-off bulk operation would otherwise retain an
// oversized buffer for the rest of the session. Dropping it costs one allocation later.
var isOversized = list.Capacity > MaxRetainedCapacity;

list.Clear();

if (!isOversized && Pool.Count < MaxPoolSize)
{
Pool.Push(list);
}
}

private const int MaxPoolSize = 128;
private const int MaxRetainedCapacity = 4096;
private static readonly Stack<List<T>> Pool = new Stack<List<T>>();
}
}
3 changes: 3 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/ListPool.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/ListPoolScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;

namespace StreamChat.Core.Helpers
{
internal readonly struct ListPoolScope<T> : IDisposable
{
public ListPoolScope(out List<T> list)
{
_list = ListPool<T>.Rent();
list = _list;
}

public void Dispose()
{
if (_list != null)
{
ListPool<T>.Release(_list);
}
}

private readonly List<T> _list;
}
}
3 changes: 3 additions & 0 deletions Assets/Plugins/StreamChat/Core/Helpers/ListPoolScope.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 51 additions & 0 deletions Assets/Plugins/StreamChat/Core/State/Caches/CacheRepository.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using StreamChat.Core.Helpers;
using StreamChat.Libs.Utils;

namespace StreamChat.Core.State.Caches
Expand Down Expand Up @@ -160,6 +161,56 @@ public void Remove(TStatefulModel trackedObject)
Untracked?.Invoke(trackedObject);
}

public void RemoveMany(IReadOnlyList<TStatefulModel> trackedObjects)
{
if (trackedObjects == null)
{
throw new ArgumentNullException(nameof(trackedObjects));
}

if (trackedObjects.Count == 0)
{
return;
}

using (new HashSetPoolScope<string>(out var tempRemovedIds))
using (new ListPoolScope<TStatefulModel>(out var tempRemoved))
{
for (var i = 0; i < trackedObjects.Count; i++)
{
var trackedObject = trackedObjects[i];
if (trackedObject.UniqueId.IsNullOrEmpty())
{
throw new ArgumentException($"{trackedObject.UniqueId} cannot be empty");
}

// Only untrack the exact instance this repository holds. A newer instance for the
// same id must survive, and duplicates in the input must not raise Untracked twice.
if (!_statefulModelById.TryGetValue(trackedObject.UniqueId, out var tracked)
|| !ReferenceEquals(tracked, trackedObject))
{
continue;
}

_statefulModelById.Remove(trackedObject.UniqueId);
tempRemovedIds.Add(trackedObject.UniqueId);
tempRemoved.Add(trackedObject);
}

if (tempRemoved.Count == 0)
{
return;
}

_statefulModels.RemoveAll(_ => tempRemovedIds.Contains(_.UniqueId));

for (var i = 0; i < tempRemoved.Count; i++)
{
Untracked?.Invoke(tempRemoved[i]);
}
}
}

internal delegate TStatefulModel ConstructorHandler(string uniqueId);

internal CacheRepository(ConstructorHandler constructor, ICache cache)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,11 @@ TType CreateOrUpdate5<TType, TDto>(TDto dto, out bool wasCreated)
where TType : class, TTrackedObject, IStreamStatefulModel, IUpdateableFrom5<TDto, TType>;

void Remove(TTrackedObject trackedObject);

/// <summary>
/// Removes multiple tracked objects in a single pass. Prefer this over calling
/// <see cref="Remove"/> in a loop - removing N objects one by one is O(N * repository size).
/// </summary>
void RemoveMany(IReadOnlyList<TTrackedObject> trackedObjects);
}
}
Loading
Loading