feat(framework): server testing utils, transaction storage simplify, pathfinding tests (#384)

## Server Testing Utils
- Add TestServer, TestClient, MockRoom for unit testing
- Export testing utilities from @esengine/server/testing

## Transaction Storage (BREAKING)
- Simplify RedisStorage/MongoStorage to factory pattern only
- Remove DI client injection option
- Add lazy connection and Symbol.asyncDispose support
- Add 161 unit tests with full coverage

## Pathfinding Tests
- Add 150 unit tests covering all components
- BinaryHeap, Heuristics, AStarPathfinder, GridMap, NavMesh, PathSmoother

## Docs
- Update storage.md for new factory pattern API
This commit is contained in:
YHH
2025-12-29 15:02:13 +08:00
committed by GitHub
parent 10c3891abd
commit 3b978384c7
50 changed files with 7591 additions and 660 deletions

View File

@@ -12,15 +12,15 @@ import type {
TransactionOptions,
TransactionLog,
OperationLog,
OperationResult,
} from './types.js'
OperationResult
} from './types.js';
/**
* @zh 生成唯一 ID
* @en Generate unique ID
*/
function generateId(): string {
return `tx_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`
return `tx_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 11)}`;
}
/**
@@ -39,22 +39,22 @@ function generateId(): string {
* ```
*/
export class TransactionContext implements ITransactionContext {
private _id: string
private _state: TransactionState = 'pending'
private _timeout: number
private _operations: ITransactionOperation[] = []
private _storage: ITransactionStorage | null
private _metadata: Record<string, unknown>
private _contextData: Map<string, unknown> = new Map()
private _startTime: number = 0
private _distributed: boolean
private _id: string;
private _state: TransactionState = 'pending';
private _timeout: number;
private _operations: ITransactionOperation[] = [];
private _storage: ITransactionStorage | null;
private _metadata: Record<string, unknown>;
private _contextData: Map<string, unknown> = new Map();
private _startTime: number = 0;
private _distributed: boolean;
constructor(options: TransactionOptions & { storage?: ITransactionStorage } = {}) {
this._id = generateId()
this._timeout = options.timeout ?? 30000
this._storage = options.storage ?? null
this._metadata = options.metadata ?? {}
this._distributed = options.distributed ?? false
this._id = generateId();
this._timeout = options.timeout ?? 30000;
this._storage = options.storage ?? null;
this._metadata = options.metadata ?? {};
this._distributed = options.distributed ?? false;
}
// =========================================================================
@@ -62,27 +62,27 @@ export class TransactionContext implements ITransactionContext {
// =========================================================================
get id(): string {
return this._id
return this._id;
}
get state(): TransactionState {
return this._state
return this._state;
}
get timeout(): number {
return this._timeout
return this._timeout;
}
get operations(): ReadonlyArray<ITransactionOperation> {
return this._operations
return this._operations;
}
get storage(): ITransactionStorage | null {
return this._storage
return this._storage;
}
get metadata(): Record<string, unknown> {
return this._metadata
return this._metadata;
}
// =========================================================================
@@ -95,10 +95,10 @@ export class TransactionContext implements ITransactionContext {
*/
addOperation<T extends ITransactionOperation>(operation: T): this {
if (this._state !== 'pending') {
throw new Error(`Cannot add operation to transaction in state: ${this._state}`)
throw new Error(`Cannot add operation to transaction in state: ${this._state}`);
}
this._operations.push(operation)
return this
this._operations.push(operation);
return this;
}
/**
@@ -112,64 +112,64 @@ export class TransactionContext implements ITransactionContext {
transactionId: this._id,
results: [],
error: `Transaction already in state: ${this._state}`,
duration: 0,
}
duration: 0
};
}
this._startTime = Date.now()
this._state = 'executing'
this._startTime = Date.now();
this._state = 'executing';
const results: OperationResult[] = []
let executedCount = 0
const results: OperationResult[] = [];
let executedCount = 0;
try {
await this._saveLog()
await this._saveLog();
for (let i = 0; i < this._operations.length; i++) {
if (this._isTimedOut()) {
throw new Error('Transaction timed out')
throw new Error('Transaction timed out');
}
const op = this._operations[i]
const op = this._operations[i];
const isValid = await op.validate(this)
const isValid = await op.validate(this);
if (!isValid) {
throw new Error(`Validation failed for operation: ${op.name}`)
throw new Error(`Validation failed for operation: ${op.name}`);
}
const result = await op.execute(this)
results.push(result)
executedCount++
const result = await op.execute(this);
results.push(result);
executedCount++;
await this._updateOperationLog(i, 'executed')
await this._updateOperationLog(i, 'executed');
if (!result.success) {
throw new Error(result.error ?? `Operation ${op.name} failed`)
throw new Error(result.error ?? `Operation ${op.name} failed`);
}
}
this._state = 'committed'
await this._updateTransactionState('committed')
this._state = 'committed';
await this._updateTransactionState('committed');
return {
success: true,
transactionId: this._id,
results,
data: this._collectResultData(results) as T,
duration: Date.now() - this._startTime,
}
duration: Date.now() - this._startTime
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
const errorMessage = error instanceof Error ? error.message : String(error);
await this._compensate(executedCount - 1)
await this._compensate(executedCount - 1);
return {
success: false,
transactionId: this._id,
results,
error: errorMessage,
duration: Date.now() - this._startTime,
}
duration: Date.now() - this._startTime
};
}
}
@@ -179,10 +179,10 @@ export class TransactionContext implements ITransactionContext {
*/
async rollback(): Promise<void> {
if (this._state === 'committed' || this._state === 'rolledback') {
return
return;
}
await this._compensate(this._operations.length - 1)
await this._compensate(this._operations.length - 1);
}
/**
@@ -190,7 +190,7 @@ export class TransactionContext implements ITransactionContext {
* @en Get context data
*/
get<T>(key: string): T | undefined {
return this._contextData.get(key) as T | undefined
return this._contextData.get(key) as T | undefined;
}
/**
@@ -198,7 +198,7 @@ export class TransactionContext implements ITransactionContext {
* @en Set context data
*/
set<T>(key: string, value: T): void {
this._contextData.set(key, value)
this._contextData.set(key, value);
}
// =========================================================================
@@ -206,28 +206,28 @@ export class TransactionContext implements ITransactionContext {
// =========================================================================
private _isTimedOut(): boolean {
return Date.now() - this._startTime > this._timeout
return Date.now() - this._startTime > this._timeout;
}
private async _compensate(fromIndex: number): Promise<void> {
this._state = 'rolledback'
this._state = 'rolledback';
for (let i = fromIndex; i >= 0; i--) {
const op = this._operations[i]
const op = this._operations[i];
try {
await op.compensate(this)
await this._updateOperationLog(i, 'compensated')
await op.compensate(this);
await this._updateOperationLog(i, 'compensated');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
await this._updateOperationLog(i, 'failed', errorMessage)
const errorMessage = error instanceof Error ? error.message : String(error);
await this._updateOperationLog(i, 'failed', errorMessage);
}
}
await this._updateTransactionState('rolledback')
await this._updateTransactionState('rolledback');
}
private async _saveLog(): Promise<void> {
if (!this._storage) return
if (!this._storage) return;
const log: TransactionLog = {
id: this._id,
@@ -238,19 +238,19 @@ export class TransactionContext implements ITransactionContext {
operations: this._operations.map((op) => ({
name: op.name,
data: op.data,
state: 'pending' as const,
state: 'pending' as const
})),
metadata: this._metadata,
distributed: this._distributed,
}
distributed: this._distributed
};
await this._storage.saveTransaction(log)
await this._storage.saveTransaction(log);
}
private async _updateTransactionState(state: TransactionState): Promise<void> {
this._state = state
this._state = state;
if (this._storage) {
await this._storage.updateTransactionState(this._id, state)
await this._storage.updateTransactionState(this._id, state);
}
}
@@ -260,18 +260,18 @@ export class TransactionContext implements ITransactionContext {
error?: string
): Promise<void> {
if (this._storage) {
await this._storage.updateOperationState(this._id, index, state, error)
await this._storage.updateOperationState(this._id, index, state, error);
}
}
private _collectResultData(results: OperationResult[]): unknown {
const data: Record<string, unknown> = {}
const data: Record<string, unknown> = {};
for (const result of results) {
if (result.data !== undefined) {
Object.assign(data, result.data)
Object.assign(data, result.data);
}
}
return Object.keys(data).length > 0 ? data : undefined
return Object.keys(data).length > 0 ? data : undefined;
}
}
@@ -282,5 +282,5 @@ export class TransactionContext implements ITransactionContext {
export function createTransactionContext(
options: TransactionOptions & { storage?: ITransactionStorage } = {}
): ITransactionContext {
return new TransactionContext(options)
return new TransactionContext(options);
}

View File

@@ -9,9 +9,9 @@ import type {
TransactionManagerConfig,
TransactionOptions,
TransactionLog,
TransactionResult,
} from './types.js'
import { TransactionContext } from './TransactionContext.js'
TransactionResult
} from './types.js';
import { TransactionContext } from './TransactionContext.js';
/**
* @zh 事务管理器
@@ -35,17 +35,17 @@ import { TransactionContext } from './TransactionContext.js'
* ```
*/
export class TransactionManager {
private _storage: ITransactionStorage | null
private _defaultTimeout: number
private _serverId: string
private _autoRecover: boolean
private _activeTransactions: Map<string, ITransactionContext> = new Map()
private _storage: ITransactionStorage | null;
private _defaultTimeout: number;
private _serverId: string;
private _autoRecover: boolean;
private _activeTransactions: Map<string, ITransactionContext> = new Map();
constructor(config: TransactionManagerConfig = {}) {
this._storage = config.storage ?? null
this._defaultTimeout = config.defaultTimeout ?? 30000
this._serverId = config.serverId ?? this._generateServerId()
this._autoRecover = config.autoRecover ?? true
this._storage = config.storage ?? null;
this._defaultTimeout = config.defaultTimeout ?? 30000;
this._serverId = config.serverId ?? this._generateServerId();
this._autoRecover = config.autoRecover ?? true;
}
// =========================================================================
@@ -57,7 +57,7 @@ export class TransactionManager {
* @en Server ID
*/
get serverId(): string {
return this._serverId
return this._serverId;
}
/**
@@ -65,7 +65,7 @@ export class TransactionManager {
* @en Storage instance
*/
get storage(): ITransactionStorage | null {
return this._storage
return this._storage;
}
/**
@@ -73,7 +73,7 @@ export class TransactionManager {
* @en Active transaction count
*/
get activeCount(): number {
return this._activeTransactions.size
return this._activeTransactions.size;
}
// =========================================================================
@@ -93,14 +93,14 @@ export class TransactionManager {
storage: this._storage ?? undefined,
metadata: {
...options.metadata,
serverId: this._serverId,
serverId: this._serverId
},
distributed: options.distributed,
})
distributed: options.distributed
});
this._activeTransactions.set(ctx.id, ctx)
this._activeTransactions.set(ctx.id, ctx);
return ctx
return ctx;
}
/**
@@ -115,14 +115,14 @@ export class TransactionManager {
builder: (ctx: ITransactionContext) => void | Promise<void>,
options: TransactionOptions = {}
): Promise<TransactionResult<T>> {
const ctx = this.begin(options)
const ctx = this.begin(options);
try {
await builder(ctx)
const result = await ctx.execute<T>()
return result
await builder(ctx);
const result = await ctx.execute<T>();
return result;
} finally {
this._activeTransactions.delete(ctx.id)
this._activeTransactions.delete(ctx.id);
}
}
@@ -131,7 +131,7 @@ export class TransactionManager {
* @en Get active transaction
*/
getTransaction(id: string): ITransactionContext | undefined {
return this._activeTransactions.get(id)
return this._activeTransactions.get(id);
}
/**
@@ -139,21 +139,21 @@ export class TransactionManager {
* @en Recover pending transactions
*/
async recover(): Promise<number> {
if (!this._storage) return 0
if (!this._storage) return 0;
const pendingTransactions = await this._storage.getPendingTransactions(this._serverId)
let recoveredCount = 0
const pendingTransactions = await this._storage.getPendingTransactions(this._serverId);
let recoveredCount = 0;
for (const log of pendingTransactions) {
try {
await this._recoverTransaction(log)
recoveredCount++
await this._recoverTransaction(log);
recoveredCount++;
} catch (error) {
console.error(`Failed to recover transaction ${log.id}:`, error)
console.error(`Failed to recover transaction ${log.id}:`, error);
}
}
return recoveredCount
return recoveredCount;
}
/**
@@ -161,8 +161,8 @@ export class TransactionManager {
* @en Acquire distributed lock
*/
async acquireLock(key: string, ttl: number = 10000): Promise<string | null> {
if (!this._storage) return null
return this._storage.acquireLock(key, ttl)
if (!this._storage) return null;
return this._storage.acquireLock(key, ttl);
}
/**
@@ -170,8 +170,8 @@ export class TransactionManager {
* @en Release distributed lock
*/
async releaseLock(key: string, token: string): Promise<boolean> {
if (!this._storage) return false
return this._storage.releaseLock(key, token)
if (!this._storage) return false;
return this._storage.releaseLock(key, token);
}
/**
@@ -183,15 +183,15 @@ export class TransactionManager {
fn: () => Promise<T>,
ttl: number = 10000
): Promise<T> {
const token = await this.acquireLock(key, ttl)
const token = await this.acquireLock(key, ttl);
if (!token) {
throw new Error(`Failed to acquire lock for key: ${key}`)
throw new Error(`Failed to acquire lock for key: ${key}`);
}
try {
return await fn()
return await fn();
} finally {
await this.releaseLock(key, token)
await this.releaseLock(key, token);
}
}
@@ -200,24 +200,24 @@ export class TransactionManager {
* @en Clean up completed transaction logs
*/
async cleanup(beforeTimestamp?: number): Promise<number> {
if (!this._storage) return 0
if (!this._storage) return 0;
const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000 // 默认清理24小时前
const timestamp = beforeTimestamp ?? Date.now() - 24 * 60 * 60 * 1000; // 默认清理24小时前
const pendingTransactions = await this._storage.getPendingTransactions()
let cleanedCount = 0
const pendingTransactions = await this._storage.getPendingTransactions();
let cleanedCount = 0;
for (const log of pendingTransactions) {
if (
log.createdAt < timestamp &&
(log.state === 'committed' || log.state === 'rolledback')
) {
await this._storage.deleteTransaction(log.id)
cleanedCount++
await this._storage.deleteTransaction(log.id);
cleanedCount++;
}
}
return cleanedCount
return cleanedCount;
}
// =========================================================================
@@ -225,20 +225,20 @@ export class TransactionManager {
// =========================================================================
private _generateServerId(): string {
return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`
return `server_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`;
}
private async _recoverTransaction(log: TransactionLog): Promise<void> {
if (log.state === 'executing') {
const executedOps = log.operations.filter((op) => op.state === 'executed')
const executedOps = log.operations.filter((op) => op.state === 'executed');
if (executedOps.length > 0 && this._storage) {
for (let i = executedOps.length - 1; i >= 0; i--) {
await this._storage.updateOperationState(log.id, i, 'compensated')
await this._storage.updateOperationState(log.id, i, 'compensated');
}
await this._storage.updateTransactionState(log.id, 'rolledback')
await this._storage.updateTransactionState(log.id, 'rolledback');
} else {
await this._storage?.updateTransactionState(log.id, 'failed')
await this._storage?.updateTransactionState(log.id, 'failed');
}
}
}
@@ -251,5 +251,5 @@ export class TransactionManager {
export function createTransactionManager(
config: TransactionManagerConfig = {}
): TransactionManager {
return new TransactionManager(config)
return new TransactionManager(config);
}

View File

@@ -13,8 +13,8 @@ export type {
TransactionManagerConfig,
ITransactionStorage,
ITransactionOperation,
ITransactionContext,
} from './types.js'
ITransactionContext
} from './types.js';
export { TransactionContext, createTransactionContext } from './TransactionContext.js'
export { TransactionManager, createTransactionManager } from './TransactionManager.js'
export { TransactionContext, createTransactionContext } from './TransactionContext.js';
export { TransactionManager, createTransactionManager } from './TransactionManager.js';

View File

@@ -279,6 +279,15 @@ export interface TransactionManagerConfig {
* @en Transaction storage interface
*/
export interface ITransactionStorage {
/**
* @zh 关闭存储连接
* @en Close storage connection
*
* @zh 释放所有资源,关闭数据库连接
* @en Release all resources, close database connections
*/
close?(): Promise<void>
/**
* @zh 获取分布式锁
* @en Acquire distributed lock