Skip to content

Commit

Permalink
[2/4] Added: Caching system for updates. (#2092)
Browse files Browse the repository at this point in the history
* WIP: Documentation for Update Detection

* Added: Additional Edge Cases to Update Logic Docs

* Added: Extra case of `Archived in the Middle`

* Fixed: Indentation for `file_updates` field.

* Finalized 'Updating Mods' doc with simplified Implementation requested.

* Fixed: Minor Notes from older Research Doc

* Fixed: Added Missing 'Updating Mods' mkdocs sidebar item.

* [Working WIP] Added: Initial Implementation of Generic Page Caching System used by Mod Pages

* Removed: Unused Tests.cs file
  • Loading branch information
Sewer56 authored Oct 3, 2024
1 parent 6abc7ca commit d0a202b
Show file tree
Hide file tree
Showing 17 changed files with 883 additions and 0 deletions.
14 changes: 14 additions & 0 deletions NexusMods.App.sln
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Collections.Tests
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian", "src\Games\NexusMods.Games.Larian\NexusMods.Games.Larian.csproj", "{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.ModUpdates", "src\Networking\NexusMods.Networking.ModUpdates\NexusMods.Networking.ModUpdates.csproj", "{8B246C04-F372-47F6-9397-F658915429A8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Networking.ModUpdates.Tests", "tests\Networking\NexusMods.Networking.ModUpdates.Tests\NexusMods.Networking.ModUpdates.Tests.csproj", "{CDA2C52B-A9A7-446B-9D2F-D7B75C1905EF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Games.Larian.Tests", "tests\Games\NexusMods.Games.Larian.Tests\NexusMods.Games.Larian.Tests.csproj", "{425F7A13-99A2-4231-B0C1-C56EB819C174}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NexusMods.Media", "src\NexusMods.Media\NexusMods.Media.csproj", "{CEC177AB-4FF0-4F8A-81B8-1E756D892416}"
Expand Down Expand Up @@ -676,6 +680,14 @@ Global
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28}.Release|Any CPU.Build.0 = Release|Any CPU
{8B246C04-F372-47F6-9397-F658915429A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8B246C04-F372-47F6-9397-F658915429A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8B246C04-F372-47F6-9397-F658915429A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8B246C04-F372-47F6-9397-F658915429A8}.Release|Any CPU.Build.0 = Release|Any CPU
{CDA2C52B-A9A7-446B-9D2F-D7B75C1905EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CDA2C52B-A9A7-446B-9D2F-D7B75C1905EF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CDA2C52B-A9A7-446B-9D2F-D7B75C1905EF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CDA2C52B-A9A7-446B-9D2F-D7B75C1905EF}.Release|Any CPU.Build.0 = Release|Any CPU
{425F7A13-99A2-4231-B0C1-C56EB819C174}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{425F7A13-99A2-4231-B0C1-C56EB819C174}.Debug|Any CPU.Build.0 = Debug|Any CPU
{425F7A13-99A2-4231-B0C1-C56EB819C174}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand Down Expand Up @@ -822,6 +834,8 @@ Global
{A9FD538A-E101-4AEA-A98E-35DCED950AEE} = {E7BAE287-D505-4D6D-A090-665A64309B2D}
{8C817874-7A88-450E-B216-851A1B03684C} = {52AF9D62-7D5B-4AD0-BA12-86F2AA67428B}
{2A35EBB5-1CA6-4F5D-8CE8-352146C82C28} = {70D38D24-79AE-4600-8E83-17F3C11BA81F}
{8B246C04-F372-47F6-9397-F658915429A8} = {D7E9D8F5-8AC8-4ADA-B219-C549084AD84C}
{CDA2C52B-A9A7-446B-9D2F-D7B75C1905EF} = {897C4198-884F-448A-B0B0-C2A6D971EAE0}
{425F7A13-99A2-4231-B0C1-C56EB819C174} = {05B06AC1-7F2B-492F-983E-5BC63CDBF20D}
{CEC177AB-4FF0-4F8A-81B8-1E756D892416} = {E7BAE287-D505-4D6D-A090-665A64309B2D}
{8744F914-BF51-4276-AFDA-9CBD750B8187} = {0CB73565-1207-4A56-A79F-6A8E9BBD795C}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using NexusMods.Networking.ModUpdates.Structures;
using NexusMods.Networking.ModUpdates.Traits;
namespace NexusMods.Networking.ModUpdates;

/// <summary>
/// This is a helper struct which combines multiple <see cref="PerFeedCacheUpdater{TUpdateableItem}"/>
/// instances into a single interface. This allows for a cache update operation across
/// multiple mod feeds (games).
///
/// For usage instructions, see <see cref="PerFeedCacheUpdater{TUpdateableItem}"/>; the API and concepts
/// here are similar, except for the difference that this class' public API allows
/// you to use mods and API responses which are sourced from multiple feeds (games),
/// as opposed to a single feed.
/// </summary>
public class MultiFeedCacheUpdater<TUpdateableItem> where TUpdateableItem : ICanGetLastUpdatedTimestamp, ICanGetUid
{
private readonly Dictionary<GameId, PerFeedCacheUpdater<TUpdateableItem>> _updaters;

/// <summary>
/// Creates a cache updater from a given list of items for which we want to
/// check for updates.
/// </summary>
/// <param name="items">The items to check for updates.</param>
/// <param name="expiry">
/// Maximum age before an item has to be re-checked for updates.
/// The max for Nexus Mods API is 1 month.
/// </param>
/// <remarks>
/// In order to ensure accuracy, the age field should include the lifespan of
/// the <see cref="PerFeedCacheUpdater{TUpdateableItem}"/> as well as the cache
/// time on the server's end. That should prevent any technical possible
/// eventual consistency errors due to race conditions. Although
/// </remarks>
public MultiFeedCacheUpdater(TUpdateableItem[] items, TimeSpan expiry)
{
_updaters = new Dictionary<GameId, PerFeedCacheUpdater<TUpdateableItem>>();

// First group the items by their GameId
// We will use a list of groups, the assumption being that the number
// of feeds is low (usually less than 5)
var groupedList = new List<(GameId, List<TUpdateableItem>)>();
foreach (var item in items)
{
var gameId = item.GetUniqueId().GameId;

// Get or Update List for this GameId.
var found = false;
foreach (var (key, value) in groupedList)
{
if (key != gameId)
continue;

value.Add(item);
found = true;
break;
}

if (!found)
groupedList.Add((gameId, [item]));
}

// Create a PerFeedCacheUpdater for each group
foreach (var (key, value) in groupedList)
_updaters[key] = new PerFeedCacheUpdater<TUpdateableItem>(value.ToArray(), expiry);
}

/// <summary>
/// Updates the internal state of the <see cref="PerFeedCacheUpdater{TUpdateableItem}"/>
/// provided the results of the 'most recently updated mods for game' endpoint.
/// </summary>
/// <param name="items">
/// The items returned by the 'most recently updated mods' endpoint. This can
/// include items corresponding to multiple feeds (games); the feed source
/// is automatically detected.
///
/// Wrap elements in a struct that implements <see cref="ICanGetLastUpdatedTimestamp"/>
/// and <see cref="ICanGetUid"/> if necessary.
/// </param>
public void Update<T>(IEnumerable<T> items) where T : ICanGetLastUpdatedTimestamp, ICanGetUid
{
foreach (var item in items)
{
// Determine feed
var feed = item.GetUniqueId().GameId;

// The result may contain items from feeds which we are not tracking.
// For instance, results for other games. This is not an error, we
// just need to filter the items out.
if (!_updaters.TryGetValue(feed, out var updater))
continue;

updater.UpdateSingleItem(item);
}
}

/// <summary>
/// Determines the actions needed to taken on the items in the <see cref="MultiFeedCacheUpdater{TUpdateableItem}"/>;
/// returning the items whose actions have to be taken grouped by the action that needs performed.
///
/// The results of multiple feeds are flattened here; everything is returned as a single result.
/// </summary>
public PerFeedCacheUpdaterResult<TUpdateableItem> BuildFlattened()
{
var result = new PerFeedCacheUpdaterResult<TUpdateableItem>();
foreach (var updater in _updaters)
result.AddFrom(updater.Value.Build());

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- NuGet Package Shared Details -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('NuGet.Build.props', '$(MSBuildThisFileDirectory)../'))" />

<ItemGroup>
<PackageReference Include="TransparentValueObjects"/>
</ItemGroup>

<ItemGroup>
<Folder Include="Private\" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="NexusMods.Networking.ModUpdates.Tests"/>
</ItemGroup>
</Project>
155 changes: 155 additions & 0 deletions src/Networking/NexusMods.Networking.ModUpdates/PerFeedCacheUpdater.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System.Diagnostics;
using NexusMods.Networking.ModUpdates.Private;
using NexusMods.Networking.ModUpdates.Structures;
using NexusMods.Networking.ModUpdates.Traits;
namespace NexusMods.Networking.ModUpdates;

/// <summary>
/// This is a helper struct which helps us make use of the 'most recently updated mods for game'
/// API endpoint to use as a cache. (Where 'game' is a 'feed')
/// For full info on internals, see the NexusMods.App project documentation.
///
/// This API consists of the following:
///
/// 1. Input [Constructor]: A set of items with a 'last update time' (see <see cref="ICanGetLastUpdatedTimestamp"/>)
/// and a 'unique id' (see <see cref="ICanGetUid"/>) that are relevant to the current 'feed' (game).
///
/// 2. Update [Method]: Submit results from API endpoint returning 'most recently updated mods for game'.
/// This updates the internal state of the <see cref="MultiFeedCacheUpdater{TUpdateableItem}"/>.
///
/// 3. Output [Info]: The <see cref="MultiFeedCacheUpdater{TUpdateableItem}"/> outputs items with 2 categories:
/// - Up-to-date mods. These should have their timestamp updated.
/// - Out of date mods. These require re-querying the data from external source.
/// </summary>
/// <remarks>
/// Within the Nexus Mods App:
///
/// - 'Input' is our set of locally cached mod pages.
/// - 'Update' is our results of `updated.json` for a given game domain.
/// - 'Output' are the pages we need to update.
///
/// The 'Feed' in the context of the Nexus App is the individual game's 'updated.json' endpoint;
/// i.e. a 'Game Mod Feed'
/// </remarks>
public class PerFeedCacheUpdater<TUpdateableItem> where TUpdateableItem : ICanGetLastUpdatedTimestamp, ICanGetUid
{
private readonly TUpdateableItem[] _items;
private readonly Dictionary<ModId, int> _itemToIndex;
private readonly CacheUpdaterAction[] _actions;

/// <summary>
/// Creates a (per-feed) cache updater from a given list of items for which
/// we want to check for updates.
/// </summary>
/// <param name="items">The items to check for updates.</param>
/// <param name="expiry">
/// Maximum age before an item has to be re-checked for updates.
/// The max for Nexus Mods API is 1 month.
/// </param>
/// <remarks>
/// In order to ensure accuracy, the age field should include the lifespan of
/// the <see cref="PerFeedCacheUpdater{TUpdateableItem}"/> as well as the cache
/// time on the server's end. That should prevent any technical possible
/// eventual consistency errors due to race conditions. Although
/// </remarks>
public PerFeedCacheUpdater(TUpdateableItem[] items, TimeSpan expiry)
{
_items = items;
DebugVerifyAllItemsAreFromSameGame();

_actions = new CacheUpdaterAction[items.Length];
_itemToIndex = new Dictionary<ModId, int>(items.Length);
for (var x = 0; x < _items.Length; x++)
_itemToIndex[_items[x].GetUniqueId().ModId] = x;

// Set the action to refresh cache for any mods which exceed max age.
var utcNow = DateTime.UtcNow;
var minCachedDate = utcNow - expiry;
for (var x = 0; x < _items.Length; x++)
{
if (_items[x].GetLastUpdatedDate() < minCachedDate)
_actions[x] = CacheUpdaterAction.NeedsUpdate;
}
}

/// <summary>
/// Updates the internal state of the <see cref="PerFeedCacheUpdater{TUpdateableItem}"/>
/// provided the results of the 'most recently updated mods for game' endpoint.
/// </summary>
/// <param name="items">
/// The items returned by the 'most recently updated mods for game' endpoint.
/// Wrap elements in a struct that implements <see cref="ICanGetLastUpdatedTimestamp"/>
/// and <see cref="ICanGetUid"/> if necessary.
/// </param>
public void Update<T>(IEnumerable<T> items) where T : ICanGetLastUpdatedTimestamp, ICanGetUid
{
foreach (var item in items)
UpdateSingleItem(item);
}

internal void UpdateSingleItem<T>(T item) where T : ICanGetLastUpdatedTimestamp, ICanGetUid
{
// Try to get index of the item.
// Not all the items from the update feed are locally stored, thus we need to
// make sure we actually have this item.
if (!_itemToIndex.TryGetValue(item.GetUniqueId().ModId, out var index))
return;

var existingItem = _items[index];

// If the file timestamp is newer than our cached copy, the item needs updating.
if (item.GetLastUpdatedDate() > existingItem.GetLastUpdatedDate())
_actions[index] = CacheUpdaterAction.NeedsUpdate;
else
_actions[index] = CacheUpdaterAction.UpdateLastCheckedTimestamp;
}

/// <summary>
/// Determines the actions needed to taken on the items in the <see cref="PerFeedCacheUpdater{TUpdateableItem}"/>;
/// returning the items whose actions have to be taken grouped by the action that needs performed.
/// </summary>
public PerFeedCacheUpdaterResult<TUpdateableItem> Build()
{
// We now have files in 3 categories:
// - Up-to-date mods. (Determined in `Update` method) a.k.a. CacheUpdaterAction.UpdateLastCheckedTimestamp
// - Out of date mods. (Determined in `Update` method and constructor) a.k.a. CacheUpdaterAction.NeedsUpdate
// - Undetermined Mods. (Mods with CacheUpdaterAction.Default)
// - This is the case for mods that are not in the `updated.json` payload
// which for some reason are in our cache and are not out of date (not greater than expiry).
// - Alternatively, they are in our cache but not in the `updated.json` payload.
// - This can be the case if the expiry field is changed between calls, or the maxAge of the
// request whose results we feed to `Update` is inconsistent due to programmer error.
// - We return these in a separate category, but the consumer should treat them as 'Out of Date'
var result = new PerFeedCacheUpdaterResult<TUpdateableItem>();
for (var x = 0; x < _actions.Length; x++)
{
switch (_actions[x])
{
case CacheUpdaterAction.Default:
result.UndeterminedItems.Add(_items[x]);
break;
case CacheUpdaterAction.NeedsUpdate:
result.OutOfDateItems.Add(_items[x]);
break;
case CacheUpdaterAction.UpdateLastCheckedTimestamp:
result.UpToDateItems.Add(_items[x]);
break;
default:
throw new ArgumentOutOfRangeException();
}
}

return result;
}

[Conditional("DEBUG")]
private void DebugVerifyAllItemsAreFromSameGame()
{
if (_items.Length == 0) return;

var firstGameId = _items[0].GetUniqueId().GameId;
var allSame = _items.All(x => x.GetUniqueId().GameId == firstGameId);
if (!allSame)
throw new ArgumentException("All items must have the same game id", nameof(_items));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using NexusMods.Networking.ModUpdates.Traits;
namespace NexusMods.Networking.ModUpdates;

/// <summary>
/// Stores the result of updating the 'mod page' cache for a given feed or sets
/// of feeds.
/// </summary>
/// <typeparam name="TUpdateableItem">Wrapper for item supported by the cache updater.</typeparam>
public class PerFeedCacheUpdaterResult<TUpdateableItem> where TUpdateableItem : ICanGetLastUpdatedTimestamp, ICanGetUid
{
/// <summary>
/// This is a list of items that is 'out of date'.
/// For these items, we need to fetch updated info from the Nexus Servers and update the timestamp.
/// </summary>
public List<TUpdateableItem> OutOfDateItems { get; init; } = new();

/// <summary>
/// These are the items that are 'up-to-date'.
/// Just update the timestamp on these items and you're good.
/// </summary>
public List<TUpdateableItem> UpToDateItems { get; init; } = new();

/// <summary>
/// These are the items that are 'undetermined'.
///
/// These should be treated the same as the items in <see cref="OutOfDateItems"/>;
/// however having items here is indicative of a possible programmer error.
/// (Due to inconsistent expiry parameter between Nexus API call and cache updater).
///
/// Consider logging these items.
/// </summary>
public List<TUpdateableItem> UndeterminedItems { get; init; } = new();

/// <summary>
/// Adds items from another <see cref="PerFeedCacheUpdaterResult{TUpdateableItem}"/>
/// into the current one.
/// </summary>
public void AddFrom(PerFeedCacheUpdaterResult<TUpdateableItem> other)
{
OutOfDateItems.AddRange(other.OutOfDateItems);
UpToDateItems.AddRange(other.UpToDateItems);
UndeterminedItems.AddRange(other.UndeterminedItems);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace NexusMods.Networking.ModUpdates.Private;

/// <summary>
/// Defines the actions that need to be taken on all elements submitted to the <see cref="PerFeedCacheUpdater{TUpdateableItem}"/>.
/// </summary>
internal enum CacheUpdaterAction : byte
{
/// <summary>
/// This defaults to <see cref="UpdateLastCheckedTimestamp"/>.
/// Either the entry is missing from the remote, or a programmer error has occurred.
/// </summary>
Default = 0,

/// <summary>
/// The item needs to be updated in the local cache.
/// </summary>
NeedsUpdate = 1,

/// <summary>
/// The item's 'last checked timestamp' needs to be updated.
/// (The item is already up to date)
/// </summary>
UpdateLastCheckedTimestamp = 2,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using TransparentValueObjects;
namespace NexusMods.Networking.ModUpdates.Structures;

/// <summary>
/// Represents the unique ID of an individual game in the Nexus Backend.
/// </summary>
[ValueObject<uint>] // Do not modify. Unsafe code relies on this.
public readonly partial struct GameId { }
Loading

0 comments on commit d0a202b

Please sign in to comment.