293 lines
7.2 KiB
TypeScript
293 lines
7.2 KiB
TypeScript
|
|
import * as plugins from '../tsmdb.plugins.js';
|
||
|
|
import type { TransactionEngine } from './TransactionEngine.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Session state
|
||
|
|
*/
|
||
|
|
export interface ISession {
|
||
|
|
/** Session ID (UUID) */
|
||
|
|
id: string;
|
||
|
|
/** Timestamp when the session was created */
|
||
|
|
createdAt: number;
|
||
|
|
/** Timestamp of the last activity */
|
||
|
|
lastActivityAt: number;
|
||
|
|
/** Current transaction ID if any */
|
||
|
|
txnId?: string;
|
||
|
|
/** Transaction number for ordering */
|
||
|
|
txnNumber?: number;
|
||
|
|
/** Whether the session is in a transaction */
|
||
|
|
inTransaction: boolean;
|
||
|
|
/** Session metadata */
|
||
|
|
metadata?: Record<string, any>;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Session engine options
|
||
|
|
*/
|
||
|
|
export interface ISessionEngineOptions {
|
||
|
|
/** Session timeout in milliseconds (default: 30 minutes) */
|
||
|
|
sessionTimeoutMs?: number;
|
||
|
|
/** Interval to check for expired sessions in ms (default: 60 seconds) */
|
||
|
|
cleanupIntervalMs?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Session engine for managing client sessions
|
||
|
|
* - Tracks session lifecycle (create, touch, end)
|
||
|
|
* - Links sessions to transactions
|
||
|
|
* - Auto-aborts transactions on session expiry
|
||
|
|
*/
|
||
|
|
export class SessionEngine {
|
||
|
|
private sessions: Map<string, ISession> = new Map();
|
||
|
|
private sessionTimeoutMs: number;
|
||
|
|
private cleanupInterval?: ReturnType<typeof setInterval>;
|
||
|
|
private transactionEngine?: TransactionEngine;
|
||
|
|
|
||
|
|
constructor(options?: ISessionEngineOptions) {
|
||
|
|
this.sessionTimeoutMs = options?.sessionTimeoutMs ?? 30 * 60 * 1000; // 30 minutes default
|
||
|
|
const cleanupIntervalMs = options?.cleanupIntervalMs ?? 60 * 1000; // 1 minute default
|
||
|
|
|
||
|
|
// Start cleanup interval
|
||
|
|
this.cleanupInterval = setInterval(() => {
|
||
|
|
this.cleanupExpiredSessions();
|
||
|
|
}, cleanupIntervalMs);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set the transaction engine to use for auto-abort
|
||
|
|
*/
|
||
|
|
setTransactionEngine(engine: TransactionEngine): void {
|
||
|
|
this.transactionEngine = engine;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Start a new session
|
||
|
|
*/
|
||
|
|
startSession(sessionId?: string, metadata?: Record<string, any>): ISession {
|
||
|
|
const id = sessionId ?? new plugins.bson.UUID().toHexString();
|
||
|
|
const now = Date.now();
|
||
|
|
|
||
|
|
const session: ISession = {
|
||
|
|
id,
|
||
|
|
createdAt: now,
|
||
|
|
lastActivityAt: now,
|
||
|
|
inTransaction: false,
|
||
|
|
metadata,
|
||
|
|
};
|
||
|
|
|
||
|
|
this.sessions.set(id, session);
|
||
|
|
return session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get a session by ID
|
||
|
|
*/
|
||
|
|
getSession(sessionId: string): ISession | undefined {
|
||
|
|
const session = this.sessions.get(sessionId);
|
||
|
|
if (session && this.isSessionExpired(session)) {
|
||
|
|
// Session expired, clean it up
|
||
|
|
this.endSession(sessionId);
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
return session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Touch a session to update last activity time
|
||
|
|
*/
|
||
|
|
touchSession(sessionId: string): boolean {
|
||
|
|
const session = this.sessions.get(sessionId);
|
||
|
|
if (!session) return false;
|
||
|
|
|
||
|
|
if (this.isSessionExpired(session)) {
|
||
|
|
this.endSession(sessionId);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
session.lastActivityAt = Date.now();
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* End a session explicitly
|
||
|
|
* This will also abort any active transaction
|
||
|
|
*/
|
||
|
|
async endSession(sessionId: string): Promise<boolean> {
|
||
|
|
const session = this.sessions.get(sessionId);
|
||
|
|
if (!session) return false;
|
||
|
|
|
||
|
|
// If session has an active transaction, abort it
|
||
|
|
if (session.inTransaction && session.txnId && this.transactionEngine) {
|
||
|
|
try {
|
||
|
|
await this.transactionEngine.abortTransaction(session.txnId);
|
||
|
|
} catch (e) {
|
||
|
|
// Ignore abort errors during cleanup
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
this.sessions.delete(sessionId);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Start a transaction in a session
|
||
|
|
*/
|
||
|
|
startTransaction(sessionId: string, txnId: string, txnNumber?: number): boolean {
|
||
|
|
const session = this.sessions.get(sessionId);
|
||
|
|
if (!session) return false;
|
||
|
|
|
||
|
|
if (this.isSessionExpired(session)) {
|
||
|
|
this.endSession(sessionId);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
session.txnId = txnId;
|
||
|
|
session.txnNumber = txnNumber;
|
||
|
|
session.inTransaction = true;
|
||
|
|
session.lastActivityAt = Date.now();
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* End a transaction in a session (commit or abort)
|
||
|
|
*/
|
||
|
|
endTransaction(sessionId: string): boolean {
|
||
|
|
const session = this.sessions.get(sessionId);
|
||
|
|
if (!session) return false;
|
||
|
|
|
||
|
|
session.txnId = undefined;
|
||
|
|
session.txnNumber = undefined;
|
||
|
|
session.inTransaction = false;
|
||
|
|
session.lastActivityAt = Date.now();
|
||
|
|
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get transaction ID for a session
|
||
|
|
*/
|
||
|
|
getTransactionId(sessionId: string): string | undefined {
|
||
|
|
const session = this.sessions.get(sessionId);
|
||
|
|
return session?.txnId;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if session is in a transaction
|
||
|
|
*/
|
||
|
|
isInTransaction(sessionId: string): boolean {
|
||
|
|
const session = this.sessions.get(sessionId);
|
||
|
|
return session?.inTransaction ?? false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Check if a session is expired
|
||
|
|
*/
|
||
|
|
isSessionExpired(session: ISession): boolean {
|
||
|
|
return Date.now() - session.lastActivityAt > this.sessionTimeoutMs;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Cleanup expired sessions
|
||
|
|
* This is called periodically by the cleanup interval
|
||
|
|
*/
|
||
|
|
private async cleanupExpiredSessions(): Promise<void> {
|
||
|
|
const expiredSessions: string[] = [];
|
||
|
|
|
||
|
|
for (const [id, session] of this.sessions) {
|
||
|
|
if (this.isSessionExpired(session)) {
|
||
|
|
expiredSessions.push(id);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// End all expired sessions (this will also abort their transactions)
|
||
|
|
for (const sessionId of expiredSessions) {
|
||
|
|
await this.endSession(sessionId);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get all active sessions
|
||
|
|
*/
|
||
|
|
listSessions(): ISession[] {
|
||
|
|
const activeSessions: ISession[] = [];
|
||
|
|
for (const session of this.sessions.values()) {
|
||
|
|
if (!this.isSessionExpired(session)) {
|
||
|
|
activeSessions.push(session);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return activeSessions;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get session count
|
||
|
|
*/
|
||
|
|
getSessionCount(): number {
|
||
|
|
return this.sessions.size;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get sessions with active transactions
|
||
|
|
*/
|
||
|
|
getSessionsWithTransactions(): ISession[] {
|
||
|
|
return this.listSessions().filter(s => s.inTransaction);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Refresh session timeout
|
||
|
|
*/
|
||
|
|
refreshSession(sessionId: string): boolean {
|
||
|
|
return this.touchSession(sessionId);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Close the session engine and cleanup
|
||
|
|
*/
|
||
|
|
close(): void {
|
||
|
|
if (this.cleanupInterval) {
|
||
|
|
clearInterval(this.cleanupInterval);
|
||
|
|
this.cleanupInterval = undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear all sessions
|
||
|
|
this.sessions.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get or create a session for a given session ID
|
||
|
|
* Useful for handling MongoDB driver session requests
|
||
|
|
*/
|
||
|
|
getOrCreateSession(sessionId: string): ISession {
|
||
|
|
let session = this.getSession(sessionId);
|
||
|
|
if (!session) {
|
||
|
|
session = this.startSession(sessionId);
|
||
|
|
} else {
|
||
|
|
this.touchSession(sessionId);
|
||
|
|
}
|
||
|
|
return session;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Extract session ID from MongoDB lsid (logical session ID)
|
||
|
|
*/
|
||
|
|
static extractSessionId(lsid: any): string | undefined {
|
||
|
|
if (!lsid) return undefined;
|
||
|
|
|
||
|
|
// MongoDB session ID format: { id: UUID }
|
||
|
|
if (lsid.id) {
|
||
|
|
if (lsid.id instanceof plugins.bson.UUID) {
|
||
|
|
return lsid.id.toHexString();
|
||
|
|
}
|
||
|
|
if (typeof lsid.id === 'string') {
|
||
|
|
return lsid.id;
|
||
|
|
}
|
||
|
|
if (lsid.id.$binary?.base64) {
|
||
|
|
// Binary UUID format
|
||
|
|
return Buffer.from(lsid.id.$binary.base64, 'base64').toString('hex');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return undefined;
|
||
|
|
}
|
||
|
|
}
|