Code/Cache/Cache.cs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace SandbankDatabase;

static internal class Cache
{
	/// <summary>
	/// Indicates that a full or partial write to disk is in progress.
	/// </summary>
	public static object WriteInProgressLock = new();

	/// <summary>
	/// All the stale documents. The key is in the format {COLLECTIONNAME}{UID}. We use a dictionary so that we
	/// don't save multiple copies of the same document twice, causing unecessary writes.
	/// </summary>
	public static ConcurrentDictionary<string, Document> StaleDocuments = new();

	private static ConcurrentDictionary<string, Collection> _collections = new();
	private static float _timeSinceLastFullWrite = 0;
	private static object _timeSinceLastFullWriteLock = new();
	private static int _staleDocumentsFoundAfterLastFullWrite;
	private static int _staleDocumentsWrittenSinceLastFullWrite;
	private static float _partialWriteInterval = 1f / ConfigController.PARTIAL_WRITES_PER_SECOND;
	private static TimeSince _timeSinceLastPartialWrite = 0;
	private static object _collectionCreationLock = new();
	private static bool _cacheWriteEnabled = true;

	public static int GetDocumentsAwaitingWriteCount()
	{
		return StaleDocuments.Count();
	}

	/// <summary>
	/// Used in the tests when we want to invalidate everything in the caches.
	/// 
	/// A bit crude and doesn't wipe everything.
	/// </summary>
	public static void WipeCaches()
	{
		if ( !TestHelpers.IsUnitTests )
			throw new SandbankException( "this can only be called during tests" );

		StaleDocuments.Clear();

		foreach ( var collection in _collections )
			collection.Value.CachedDocuments.Clear();
	}

	/// <summary>
	/// Used in the tests when we want to do writing to disk manually.
	/// </summary>
	public static void DisableCacheWriting()
	{
		if ( !TestHelpers.IsUnitTests )
			throw new SandbankException( "this can only be called during tests" );

		_cacheWriteEnabled = false;
	}

	public static void WipeStaticFields()
	{
		lock (WriteInProgressLock)
		{
			_collections.Clear();
			_timeSinceLastFullWrite = 0;
			_staleDocumentsFoundAfterLastFullWrite = 0;
			_staleDocumentsWrittenSinceLastFullWrite = 0;
			StaleDocuments.Clear();
			_partialWriteInterval = 1f / ConfigController.PARTIAL_WRITES_PER_SECOND;
			_timeSinceLastPartialWrite = 0;
		}
	}

	public static List<Collection> GetAllCollections()
	{
		return _collections.Values.ToList();
	}

	public static Collection GetCollectionByName<T>( string name, bool createIfDoesntExist )
	{
		return GetCollectionByName( name, createIfDoesntExist, typeof(T ) );
	}

	public static Collection GetCollectionByName( string name, bool createIfDoesntExist, Type documentType )
	{
		if ( !_collections.ContainsKey( name ) )
		{
			if ( createIfDoesntExist )
			{
				Logging.Log( $"creating new collection \"{name}\"" );
				CreateCollection( name, documentType );
			}
			else
			{
				return null;
			}
		}

		return _collections[name];
	}

	private static float GetTimeSinceLastFullWrite()
	{
		lock ( _timeSinceLastFullWriteLock )
		{
			return _timeSinceLastFullWrite;
		}
	}

	private static void ResetTimeSinceLastFullWrite()
	{
		lock ( _timeSinceLastFullWriteLock )
		{
			_timeSinceLastFullWrite = 0;
		}
	}

	public static void CreateCollection( string name, Type documentClassType )
	{
		// Only allow one thread to create a collection at once or this will
		// be madness.
		lock ( _collectionCreationLock )
		{
			if ( _collections.ContainsKey( name ) )
				return;

			ObjectPool.TryRegisterType( documentClassType.FullName, documentClassType );

			Collection newCollection = new()
			{
				CollectionName = name,
				DocumentClassType = documentClassType,
				DocumentClassTypeSerialized = documentClassType.FullName
			};

			FileController.CreateCollectionLock( name );
			_collections[name] = newCollection;

			int attempt = 0;
			string error = "";

			while ( true )
			{
				if ( attempt++ >= 10 )
					throw new SandbankException( $"failed to save \"{name}\" collection definition after 10 tries - is the file in use by something else?: {error}" );

				error = FileController.SaveCollectionDefinition( newCollection );

				if ( error == null )
					break;
			}
		}
	}

	public static void InsertDocumentsIntoCollection( string collection, List<Document> documents )
	{
		foreach ( var document in documents )
			_collections[collection].CachedDocuments[document.UID] = document;
	}

	public static void Tick()
	{
		if ( InitialisationController.CurrentDatabaseState != DatabaseState.Initialised || !_cacheWriteEnabled)
			return;

		lock ( _timeSinceLastFullWriteLock )
		{
			_timeSinceLastFullWrite += ConfigController.TICK_DELTA;
		}

		if ( GetTimeSinceLastFullWrite() >= ConfigController.PERSIST_EVERY_N_SECONDS )
		{
			// Do this immediately otherwise when the server is stuttering it can spam
			// full writes.
			ResetTimeSinceLastFullWrite();

			lock ( WriteInProgressLock )
			{
				FullWrite();
			}
		}
		else if ( _timeSinceLastPartialWrite > _partialWriteInterval )
		{
			PartialWrite();
			_timeSinceLastPartialWrite = 0;
		}
	}

	/// <summary>
	/// Force the cache to perform a full-write of all stale entries.
	/// </summary>
	public static void ForceFullWrite()
	{
		lock ( WriteInProgressLock )
		{
			Logging.Log( "beginning forced full-write..." );

			ReevaluateStaleDocuments();
			FullWrite();

			Logging.Log( "finished forced full-write..." );
		}
	}

	/// <summary>
	/// Figure out how many documents we should write for our next partial write.
	/// </summary>
	private static int GetNumberOfDocumentsToWrite()
	{
		float progressToNextWrite = GetTimeSinceLastFullWrite() / ConfigController.PERSIST_EVERY_N_SECONDS;
		int documentsWeShouldHaveWrittenByNow = (int)(_staleDocumentsFoundAfterLastFullWrite * progressToNextWrite);
		int numberToWrite = documentsWeShouldHaveWrittenByNow - _staleDocumentsWrittenSinceLastFullWrite;

		if ( numberToWrite <= 0 )
			return 0;

		return numberToWrite;
	}

	/// <summary>
	/// Write some (but probably not all) of the stale documents to disk. The longer
	/// it's been since our last partial write, the more documents we will write.
	/// </summary>
	private static void PartialWrite()
	{
		try
		{
			lock ( WriteInProgressLock )
			{
				var numberOfDocumentsToWrite = GetNumberOfDocumentsToWrite();

				if ( numberOfDocumentsToWrite > 0 )
				{
					Logging.Log( "performing partial write..." );

					PersistStaleDocuments( numberOfDocumentsToWrite );
				}
			}
		}
		catch ( Exception e )
		{
			throw new SandbankException( "partial write failed: " + Logging.ExtractExceptionString( e ) );
		}
	}

	/// <summary>
	/// Perform a full-write to (maybe) guarantee we meet our write deadline target.
	/// Also, re-evaluate cache to determine what is now stale.
	/// </summary>
	private static void FullWrite()
	{
		try
		{
			Logging.Log( "performing full write..." );

			// Persist any remaining items first.
			PersistStaleDocuments();
			_staleDocumentsWrittenSinceLastFullWrite = 0;

			ReevaluateStaleDocuments();
		}
		catch ( Exception e )
		{
			throw new SandbankException( "full write failed: " + Logging.ExtractExceptionString( e ) );
		}
	}

	/// <summary>
	/// Persist some of the stale documents to disk. We generally don't want to persist
	/// them all at once, as this can cause lag spikes.
	/// </summary>
	private static void PersistStaleDocuments( int numberToWrite = int.MaxValue )
	{
		int remainingDocumentCount = _staleDocumentsFoundAfterLastFullWrite - _staleDocumentsWrittenSinceLastFullWrite;

		Logging.Log( $"remaining documents left to write: {remainingDocumentCount}" );

		if ( numberToWrite > remainingDocumentCount )
			numberToWrite = remainingDocumentCount;

		int realCount = StaleDocuments.Count();

		if ( numberToWrite > realCount )
			numberToWrite = realCount;

		Logging.Log( $"we are persisting {numberToWrite} documents to disk now" );

		_staleDocumentsWrittenSinceLastFullWrite += numberToWrite;

		int failures = 0;
		int i = 0;

		foreach ( var item in StaleDocuments )
		{
			if ( i >= numberToWrite )
				break;

			if ( !PersistDocumentToDisk( item.Value ) )
				failures++;
			else
				StaleDocuments.TryRemove( item ); // If we fail to remove it, it's probably not a big deal.

			i++;
		}

		_staleDocumentsWrittenSinceLastFullWrite -= failures;
	}

	/// <summary>
	/// Returns true on success, false otherwise.
	/// </summary>
	private static bool PersistDocumentToDisk( Document document  )
	{
		int attempt = 0;
		string error = "";

		while ( true )
		{
			if ( attempt++ >= 3 )
			{
				Logging.Error( $"failed to persist document \"{document.UID}\" from collection \"{document.CollectionName}\" to disk after 3 tries: " + error );
				return false;
			}

			error = FileController.SaveDocument( document );

			if ( error == null )
				return true;
		}
	}

	/// <summary>
	/// Re-examine the cache and figure out what's stale and so what needs writing to
	/// disk.
	/// </summary>
	private static void ReevaluateStaleDocuments()
	{
		Logging.Log( "re-evaluating stale documents..." );

		_staleDocumentsFoundAfterLastFullWrite = StaleDocuments.Count();

		Logging.Log( $"found {_staleDocumentsFoundAfterLastFullWrite} stale documents" );
	}
}