/*
* Copyright (c) 2023 Vaughn Nugent
*
* Library: VNLib
* Package: VNLib.Plugins.Sessions.Cache.Client
* File: SessionStore.cs
*
* SessionStore.cs is part of VNLib.Plugins.Sessions.Cache.Client which is part of the larger
* VNLib collection of libraries and utilities.
*
* VNLib.Plugins.Sessions.Cache.Client is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* VNLib.Plugins.Sessions.Cache.Client is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see https://www.gnu.org/licenses/.
*/
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Generic;
using VNLib.Net.Http;
using VNLib.Utils.Logging;
using VNLib.Plugins.Essentials.Sessions;
using VNLib.Plugins.Sessions.Cache.Client.Exceptions;
namespace VNLib.Plugins.Sessions.Cache.Client
{
///
/// Provides an abstract for managing sessions for HTTP connections
/// remote cache backed sessions
///
///
public abstract class SessionStore : ISessionStore where TSession: IRemoteSession
{
///
/// Used to serialize access to session instances. Unless overridden, uses a
/// implementation.
///
protected virtual ISessionSerialzer Serializer { get; } = new SessionSerializer(100);
///
/// The that provides session ids for connections
///
protected abstract ISessionIdFactory IdFactory { get; }
///
/// The backing cache store
///
protected abstract IRemoteCacheStore Cache { get; }
///
/// The session factory, produces sessions from their initial data and session-id
///
protected abstract ISessionFactory SessionFactory { get; }
///
/// The log provider for writing background update exceptions to
///
protected abstract ILogProvider Log { get; }
///
public virtual async ValueTask GetSessionAsync(IHttpEvent entity, CancellationToken cancellationToken)
{
//Wrap exceptions in session exceptions
try
{
if (!IdFactory.CanService(entity))
{
return default;
}
//Get the session id from the entity
string? sessionId = IdFactory.TryGetSessionId(entity);
//If regeneration is not supported, return since id is null
if (sessionId == null && !IdFactory.RegenerationSupported)
{
return default;
}
if (sessionId == null)
{
//Get new sessionid
sessionId = IdFactory.RegenerateId(entity);
//Create a new session
TSession? session = SessionFactory.GetNewSession(entity, sessionId, null);
if (session != null)
{
//Enter wait for the new session, this call should not block or yield
await Serializer.WaitAsync(session, cancellationToken);
}
return session;
}
else
{
cancellationToken.ThrowIfCancellationRequested();
Dictionary? sessionData = null;
//See if the session is currently in use, if so wait for access to it
if (Serializer.TryGetSession(sessionId, out TSession? activeSession))
{
//reuse the session
await Serializer.WaitAsync(activeSession, cancellationToken);
//after wait has been completed, check the status of the session
if (activeSession.IsValid(out Exception? cause))
{
//Session is valid, we can use it
return activeSession;
}
//release the wait, no use for the session in invalid state
Serializer.Release(activeSession);
//Rethrow exception that cause invalidation
if (cause != null)
{
throw cause;
}
//Regen id and continue loading
}
else
{
//Session cannot be found local, so we need to retrieve it from cache
sessionData = await Cache.GetObjectAsync>(sessionId, cancellationToken);
}
//If the cache entry is null, we may choose to regenrate the session id
if (sessionData == null && IdFactory.RegenIdOnEmptyEntry)
{
sessionId = IdFactory.RegenerateId(entity);
}
//Create a new session
TSession? session = SessionFactory.GetNewSession(entity, sessionId, sessionData);
if(session != null)
{
//Enter wait for the new session
await Serializer.WaitAsync(session, cancellationToken);
}
return session;
}
}
catch(OperationCanceledException)
{
throw;
}
catch (TimeoutException)
{
throw;
}
catch (SessionException)
{
throw;
}
catch(Exception ex)
{
throw new SessionException("An exception occured during session processing", ex);
}
}
///
public virtual ValueTask ReleaseSessionAsync(TSession session, IHttpEvent entity)
{
//Get status on release
SessionStatus status = session.GetStatus();
//confirm the cache client is connected
if(status != SessionStatus.None && !Cache.IsConnected)
{
throw new SessionUpdateFailedException("The required session operation cannot be completed because the cache is not connected");
}
//Delete status is required
if (status.HasFlag(SessionStatus.Delete))
{
//Delete the session
_ = DeleteSessionAsync(session);
}
else if (status.HasFlag(SessionStatus.RegenId))
{
if (!IdFactory.RegenerationSupported)
{
throw new SessionException("Session id regeneration is not supported by this store");
}
//Get new id for session
string newId = IdFactory.RegenerateId(entity);
//Update data and id
_ = UpdateSessionAndIdAsync(session, newId);
}
else if(status.HasFlag(SessionStatus.Detach))
{
/*
* Special case. We are regenerating the session id, but we are not updating the session.
* This will cause the client's session id to detach from the current session.
*
* All other updates will be persisted to the cache.
*
* The id should require regeneration on the user's next request then attach a new session.
*
* The session is still valid, however the current connection should effectivly be 'detatched'
* from it.
*/
if (!IdFactory.RegenerationSupported)
{
throw new SessionException("Session id regeneration is not supported by this store");
}
_ = IdFactory.RegenerateId(entity);
_ = UpdateSessionAndIdAsync(session, null);
}
else if (status.HasFlag(SessionStatus.UpdateOnly))
{
//Just run update
_ = UpdateSessionAndIdAsync(session, null);
}
else
{
//Always release the session after update
Serializer.Release(session);
}
return ValueTask.CompletedTask;
}
///
/// Updates a mondified session on release, and optionally updates
/// its id
///
///
/// The session's new id
/// A task that completes when the session update is complete
///
/// Unless overridden
///
protected virtual async Task UpdateSessionAndIdAsync(TSession session, string? newId)
{
try
{
//Get the session's data
IDictionary sessionData = session.GetSessionData();
//Update the session's data async
await Cache.AddOrUpdateObjectAsync(session.SessionID, newId, sessionData);
/*
* If the session id changes, the old sesion can be invalidated
* and the session will be recovered from cache on load
*/
if(newId != null)
{
session.Destroy(null);
}
else
{
session.SessionUpdateComplete();
}
}
catch (Exception ex)
{
Log.Error("Exception raised during session update, ID {id} NewID {nid}\n{ex}", session.SessionID, newId, ex);
//Destroy the session with an error
session.Destroy(ex);
}
finally
{
//Always release the session after update
Serializer.Release(session);
}
}
///
/// Delets a session when the session has the
/// flag set
///
/// The session to delete
/// A task that completes when the session is destroyed
protected virtual async Task DeleteSessionAsync(TSession session)
{
Exception? cause = null;
try
{
//Update the session's data async
_ = await Cache.DeleteObjectAsync(session.SessionID);
}
catch (Exception ex)
{
Log.Error("Exception raised during session delete, ID {id}\n{ex}", session.SessionID, ex);
cause = ex;
}
finally
{
//Always destroy the session
session.Destroy(cause);
//Release the session now that delete has been set
Serializer.Release(session);
}
}
}
}