fix(core): 收口 legacy bridge 同步评审问题

- 修复 legacy 同步 bridge 的 runtime 等待方式,统一通过共享 helper 隔离同步上下文并收口重复 dispatch-context 解析逻辑

- 补充 legacy async command bridge 的取消可见性,并更新 ICqrsRuntime 与相关入口的契约说明

- 新增 bridge 回归测试并更新 cqrs-rewrite active tracking,覆盖同步上下文隔离、测试容器释放与取消语义
This commit is contained in:
gewuyou 2026-05-07 19:00:49 +08:00
parent 6056159866
commit dc3bd3744e
21 changed files with 640 additions and 143 deletions

View File

@ -43,6 +43,7 @@ namespace GFramework.Core.Tests.Architectures;
/// - GetUtility方法 - 获取未注册工具时抛出异常
/// - GetEnvironment方法 - 获取环境对象
/// </summary>
[NonParallelizable]
[TestFixture]
public class ArchitectureContextTests
{
@ -150,13 +151,20 @@ public class ArchitectureContextTests
{
LegacyBridgePipelineTracker.Reset();
var testQuery = new LegacyArchitectureBridgeQuery();
var bridgeContext = CreateFrozenBridgeContext();
var bridgeContext = CreateFrozenBridgeContext(out var bridgeContainer);
var result = bridgeContext.SendQuery(testQuery);
try
{
var result = bridgeContext.SendQuery(testQuery);
Assert.That(result, Is.EqualTo(24));
Assert.That(testQuery.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
Assert.That(result, Is.EqualTo(24));
Assert.That(testQuery.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
}
finally
{
bridgeContainer.Dispose();
}
}
/// <summary>
@ -190,13 +198,20 @@ public class ArchitectureContextTests
{
LegacyBridgePipelineTracker.Reset();
var testCommand = new LegacyArchitectureBridgeCommand();
var bridgeContext = CreateFrozenBridgeContext();
var bridgeContext = CreateFrozenBridgeContext(out var bridgeContainer);
bridgeContext.SendCommand(testCommand);
try
{
bridgeContext.SendCommand(testCommand);
Assert.That(testCommand.Executed, Is.True);
Assert.That(testCommand.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
Assert.That(testCommand.Executed, Is.True);
Assert.That(testCommand.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
}
finally
{
bridgeContainer.Dispose();
}
}
/// <summary>
@ -230,13 +245,20 @@ public class ArchitectureContextTests
{
LegacyBridgePipelineTracker.Reset();
var testCommand = new LegacyArchitectureBridgeCommandWithResult();
var bridgeContext = CreateFrozenBridgeContext();
var bridgeContext = CreateFrozenBridgeContext(out var bridgeContainer);
var result = bridgeContext.SendCommand(testCommand);
try
{
var result = bridgeContext.SendCommand(testCommand);
Assert.That(result, Is.EqualTo(42));
Assert.That(testCommand.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
Assert.That(result, Is.EqualTo(42));
Assert.That(testCommand.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
}
finally
{
bridgeContainer.Dispose();
}
}
/// <summary>
@ -247,22 +269,30 @@ public class ArchitectureContextTests
{
LegacyBridgePipelineTracker.Reset();
var testQuery = new LegacyArchitectureBridgeAsyncQuery();
var bridgeContext = CreateFrozenBridgeContext();
var bridgeContext = CreateFrozenBridgeContext(out var bridgeContainer);
var result = await bridgeContext.SendQueryAsync(testQuery).ConfigureAwait(false);
try
{
var result = await bridgeContext.SendQueryAsync(testQuery).ConfigureAwait(false);
Assert.That(result, Is.EqualTo(64));
Assert.That(testQuery.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
Assert.That(result, Is.EqualTo(64));
Assert.That(testQuery.ObservedContext, Is.SameAs(bridgeContext));
Assert.That(LegacyBridgePipelineTracker.InvocationCount, Is.EqualTo(1));
}
finally
{
bridgeContainer.Dispose();
}
}
/// <summary>
/// 为需要验证统一 CQRS pipeline 的用例创建一个已冻结的最小 bridge 上下文。
/// </summary>
/// <param name="container">返回承载当前 bridge 上下文的冻结容器,供测试在 finally 中显式释放。</param>
/// <returns>能够执行 legacy bridge request 且会 materialize open-generic pipeline behavior 的上下文。</returns>
private static ArchitectureContext CreateFrozenBridgeContext()
private static ArchitectureContext CreateFrozenBridgeContext(out MicrosoftDiContainer container)
{
var container = new MicrosoftDiContainer();
container = new MicrosoftDiContainer();
RegisterLegacyBridgeHandlers(container);
new CqrsRuntimeModule().Register(container);
container.ExecuteServicesHook(services =>

View File

@ -15,6 +15,7 @@ namespace GFramework.Core.Tests.Architectures;
/// 验证 Architecture 通过 <c>ArchitectureModules</c> 暴露出的模块安装与 CQRS 行为注册能力。
/// 这些测试覆盖模块安装回调和请求管道行为接入,确保模块管理器仍然保持可观察行为不变。
/// </summary>
[NonParallelizable]
[TestFixture]
public class ArchitectureModulesBehaviorTests
{

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Command;
using GFramework.Core.Tests.Architectures;
namespace GFramework.Core.Tests.Command;
@ -75,6 +76,59 @@ public class CommandExecutorTests
Assert.Throws<ArgumentNullException>(() => _commandExecutor.Send<int>(null!));
}
/// <summary>
/// 验证 legacy 同步命令桥接会在线程池上等待 runtime
/// 避免直接继承调用方当前的同步上下文。
/// </summary>
[Test]
public void Send_Should_Bridge_Through_Runtime_Without_Reusing_Caller_SynchronizationContext()
{
var runtime = new RecordingCqrsRuntime();
var executor = new CommandExecutor(runtime);
var command = new ContextAwareLegacyCommand();
var expectedContext = new TestArchitectureContextBaseStub();
((GFramework.Core.Abstractions.Rule.IContextAware)command).SetContext(expectedContext);
var originalContext = SynchronizationContext.Current;
try
{
SynchronizationContext.SetSynchronizationContext(new TestLegacySynchronizationContext());
executor.Send(command);
Assert.Multiple(() =>
{
Assert.That(runtime.LastRequest, Is.TypeOf<GFramework.Core.Cqrs.LegacyCommandDispatchRequest>());
Assert.That(runtime.ObservedSynchronizationContextType, Is.Null);
});
}
finally
{
SynchronizationContext.SetSynchronizationContext(originalContext);
}
}
/// <summary>
/// 验证 legacy 带返回值命令桥接也会保留上下文注入与返回值语义。
/// </summary>
[Test]
public void Send_WithResult_Should_Bridge_Through_Runtime_And_Preserve_Context()
{
var runtime = new RecordingCqrsRuntime(static _ => 123);
var executor = new CommandExecutor(runtime);
var command = new ContextAwareLegacyCommandWithResult(123);
var expectedContext = new TestArchitectureContextBaseStub();
((GFramework.Core.Abstractions.Rule.IContextAware)command).SetContext(expectedContext);
var result = executor.Send(command);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(123));
Assert.That(runtime.LastRequest, Is.TypeOf<GFramework.Core.Cqrs.LegacyCommandResultDispatchRequest>());
});
}
/// <summary>
/// 测试SendAsync方法执行异步命令
/// </summary>
@ -122,4 +176,11 @@ public class CommandExecutorTests
{
Assert.ThrowsAsync<ArgumentNullException>(() => _commandExecutor.SendAsync<int>(null!));
}
/// <summary>
/// 为同步 bridge 测试提供最小架构上下文替身。
/// </summary>
private sealed class TestArchitectureContextBaseStub : TestArchitectureContextBase
{
}
}

View File

@ -0,0 +1,31 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Command;
using GFramework.Core.Rule;
namespace GFramework.Core.Tests.Command;
/// <summary>
/// 为 <see cref="CommandExecutorTests" /> 提供可观察上下文注入的 legacy 命令。
/// </summary>
internal sealed class ContextAwareLegacyCommand : ContextAwareBase, ICommand
{
/// <summary>
/// 获取执行期间观察到的架构上下文。
/// </summary>
public IArchitectureContext? ObservedContext { get; private set; }
/// <summary>
/// 获取命令是否已经执行。
/// </summary>
public bool Executed { get; private set; }
/// <inheritdoc />
public void Execute()
{
Executed = true;
ObservedContext = ((GFramework.Core.Abstractions.Rule.IContextAware)this).GetContext();
}
}

View File

@ -0,0 +1,26 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Command;
using GFramework.Core.Rule;
namespace GFramework.Core.Tests.Command;
/// <summary>
/// 为 <see cref="CommandExecutorTests" /> 提供可观察上下文注入的带返回值 legacy 命令。
/// </summary>
internal sealed class ContextAwareLegacyCommandWithResult(int result) : ContextAwareBase, ICommand<int>
{
/// <summary>
/// 获取执行期间观察到的架构上下文。
/// </summary>
public IArchitectureContext? ObservedContext { get; private set; }
/// <inheritdoc />
public int Execute()
{
ObservedContext = ((GFramework.Core.Abstractions.Rule.IContextAware)this).GetContext();
return result;
}
}

View File

@ -0,0 +1,66 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Core.Tests.Command;
/// <summary>
/// 记录 bridge 执行线程与收到请求的最小 CQRS runtime 测试替身。
/// </summary>
internal sealed class RecordingCqrsRuntime(Func<object?, object?>? responseFactory = null) : ICqrsRuntime
{
private static readonly Func<object?, object?> DefaultResponseFactory = _ => null;
private readonly Func<object?, object?> _responseFactory = responseFactory ?? DefaultResponseFactory;
/// <summary>
/// 获取最近一次 <see cref="SendAsync{TResponse}" /> 观察到的同步上下文类型。
/// </summary>
public Type? ObservedSynchronizationContextType { get; private set; }
/// <summary>
/// 获取最近一次收到的请求实例。
/// </summary>
public object? LastRequest { get; private set; }
/// <inheritdoc />
public ValueTask<TResponse> SendAsync<TResponse>(
ICqrsContext context,
IRequest<TResponse> request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
ObservedSynchronizationContextType = SynchronizationContext.Current?.GetType();
LastRequest = request;
object? response = request switch
{
IRequest<Unit> => Unit.Value,
_ => _responseFactory(request)
};
return ValueTask.FromResult((TResponse)response!);
}
/// <inheritdoc />
public ValueTask PublishAsync<TNotification>(
ICqrsContext context,
TNotification notification,
CancellationToken cancellationToken = default)
where TNotification : INotification
{
throw new NotSupportedException();
}
/// <inheritdoc />
public IAsyncEnumerable<TResponse> CreateStream<TResponse>(
ICqrsContext context,
IStreamRequest<TResponse> request,
CancellationToken cancellationToken = default)
{
throw new NotSupportedException();
}
}

View File

@ -0,0 +1,11 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
namespace GFramework.Core.Tests.Command;
/// <summary>
/// 为 legacy 同步 bridge 回归测试提供可识别的同步上下文占位类型。
/// </summary>
internal sealed class TestLegacySynchronizationContext : SynchronizationContext
{
}

View File

@ -0,0 +1,87 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Abstractions.Command;
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Cqrs;
using GFramework.Core.Rule;
using GFramework.Core.Tests.Architectures;
namespace GFramework.Core.Tests.Cqrs;
/// <summary>
/// 验证 legacy 异步无返回值命令 bridge handler 的取消语义。
/// </summary>
[TestFixture]
public class LegacyAsyncCommandDispatchRequestHandlerTests
{
/// <summary>
/// 验证当取消令牌在执行前已触发时handler 不会启动底层 legacy 命令。
/// </summary>
[Test]
public void Handle_Should_Throw_Without_Executing_Command_When_Cancellation_Is_Already_Requested()
{
var handler = new LegacyAsyncCommandDispatchRequestHandler();
var command = new ProbeAsyncCommand(Task.CompletedTask);
using var cancellationTokenSource = new CancellationTokenSource();
cancellationTokenSource.Cancel();
Assert.ThrowsAsync<OperationCanceledException>(
async () => await handler.Handle(
new LegacyAsyncCommandDispatchRequest(command),
cancellationTokenSource.Token)
.AsTask()
.ConfigureAwait(false));
Assert.That(command.ExecutionCount, Is.Zero);
}
/// <summary>
/// 验证当底层 legacy 命令正在运行时handler 会通过 <c>WaitAsync</c> 及时向调用方暴露取消。
/// </summary>
[Test]
public async Task Handle_Should_Observe_Cancellation_While_Command_Is_Running()
{
var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var handler = new LegacyAsyncCommandDispatchRequestHandler();
var command = new ProbeAsyncCommand(completionSource.Task);
using var cancellationTokenSource = new CancellationTokenSource();
((IContextAware)handler).SetContext(new TestArchitectureContextBaseStub());
var handleTask = handler.Handle(
new LegacyAsyncCommandDispatchRequest(command),
cancellationTokenSource.Token)
.AsTask();
cancellationTokenSource.Cancel();
Assert.That(
async () => await handleTask.ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(command.ExecutionCount, Is.EqualTo(1));
}
/// <summary>
/// 为 handler 取消测试提供可控完成时机的异步命令替身。
/// </summary>
private sealed class ProbeAsyncCommand(Task executionTask) : ContextAwareBase, IAsyncCommand
{
/// <summary>
/// 获取底层命令逻辑的触发次数。
/// </summary>
public int ExecutionCount { get; private set; }
/// <inheritdoc />
public Task ExecuteAsync()
{
ExecutionCount++;
return executionTask;
}
}
/// <summary>
/// 为 handler 取消测试提供最小架构上下文替身。
/// </summary>
private sealed class TestArchitectureContextBaseStub : TestArchitectureContextBase
{
}
}

View File

@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Query;
using GFramework.Core.Tests.Architectures;
using GFramework.Core.Tests.Command;
namespace GFramework.Core.Tests.Query;
@ -138,4 +140,32 @@ public class AsyncQueryExecutorTests
Assert.That(result1, Is.EqualTo(20));
Assert.That(result2, Is.EqualTo(40));
}
/// <summary>
/// 验证 legacy 异步查询桥接会保留上下文注入,并通过 runtime 返回结果。
/// </summary>
[Test]
public async Task SendAsync_Should_Bridge_Through_Runtime_And_Preserve_Context()
{
var runtime = new RecordingCqrsRuntime(static _ => 64);
var executor = new AsyncQueryExecutor(runtime);
var query = new ContextAwareLegacyAsyncQuery(64);
var expectedContext = new TestArchitectureContextBaseStub();
((GFramework.Core.Abstractions.Rule.IContextAware)query).SetContext(expectedContext);
var result = await executor.SendAsync(query);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(64));
Assert.That(runtime.LastRequest, Is.TypeOf<GFramework.Core.Cqrs.LegacyAsyncQueryDispatchRequest>());
});
}
/// <summary>
/// 为异步 bridge 测试提供最小架构上下文替身。
/// </summary>
private sealed class TestArchitectureContextBaseStub : TestArchitectureContextBase
{
}
}

View File

@ -0,0 +1,26 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Query;
using GFramework.Core.Rule;
namespace GFramework.Core.Tests.Query;
/// <summary>
/// 为 <see cref="AsyncQueryExecutorTests" /> 提供可观察上下文注入的 legacy 异步查询。
/// </summary>
internal sealed class ContextAwareLegacyAsyncQuery(int result) : ContextAwareBase, IAsyncQuery<int>
{
/// <summary>
/// 获取执行期间观察到的架构上下文。
/// </summary>
public IArchitectureContext? ObservedContext { get; private set; }
/// <inheritdoc />
public Task<int> DoAsync()
{
ObservedContext = ((GFramework.Core.Abstractions.Rule.IContextAware)this).GetContext();
return Task.FromResult(result);
}
}

View File

@ -0,0 +1,26 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Query;
using GFramework.Core.Rule;
namespace GFramework.Core.Tests.Query;
/// <summary>
/// 为 <see cref="QueryExecutorTests" /> 提供可观察上下文注入的 legacy 查询。
/// </summary>
internal sealed class ContextAwareLegacyQuery(int result) : ContextAwareBase, IQuery<int>
{
/// <summary>
/// 获取执行期间观察到的架构上下文。
/// </summary>
public IArchitectureContext? ObservedContext { get; private set; }
/// <inheritdoc />
public int Do()
{
ObservedContext = ((GFramework.Core.Abstractions.Rule.IContextAware)this).GetContext();
return result;
}
}

View File

@ -2,6 +2,8 @@
// SPDX-License-Identifier: Apache-2.0
using GFramework.Core.Query;
using GFramework.Core.Tests.Architectures;
using GFramework.Core.Tests.Command;
namespace GFramework.Core.Tests.Query;
@ -61,4 +63,44 @@ public class QueryExecutorTests
Assert.That(result, Is.EqualTo("Result: 10"));
}
/// <summary>
/// 验证 legacy 同步查询桥接会在线程池上等待 runtime
/// 避免直接复用调用方的同步上下文。
/// </summary>
[Test]
public void Send_Should_Bridge_Through_Runtime_Without_Reusing_Caller_SynchronizationContext()
{
var runtime = new RecordingCqrsRuntime(static _ => 24);
var executor = new QueryExecutor(runtime);
var query = new ContextAwareLegacyQuery(24);
var expectedContext = new TestArchitectureContextBaseStub();
((GFramework.Core.Abstractions.Rule.IContextAware)query).SetContext(expectedContext);
var originalContext = SynchronizationContext.Current;
try
{
SynchronizationContext.SetSynchronizationContext(new TestLegacySynchronizationContext());
var result = executor.Send(query);
Assert.Multiple(() =>
{
Assert.That(result, Is.EqualTo(24));
Assert.That(runtime.LastRequest, Is.TypeOf<GFramework.Core.Cqrs.LegacyQueryDispatchRequest>());
Assert.That(runtime.ObservedSynchronizationContextType, Is.Null);
});
}
finally
{
SynchronizationContext.SetSynchronizationContext(originalContext);
}
}
/// <summary>
/// 为同步 bridge 测试提供最小架构上下文替身。
/// </summary>
private sealed class TestArchitectureContextBaseStub : TestArchitectureContextBase
{
}
}

View File

@ -112,7 +112,8 @@ public class ArchitectureContext : IArchitectureContext
/// <returns>响应结果</returns>
public TResponse SendRequest<TResponse>(IRequest<TResponse> request)
{
return SendRequestAsync(request).AsTask().GetAwaiter().GetResult();
ArgumentNullException.ThrowIfNull(request);
return LegacyCqrsDispatchHelper.SendSynchronously(CqrsRuntime, this, request);
}
/// <summary>
@ -197,7 +198,8 @@ public class ArchitectureContext : IArchitectureContext
/// <returns>查询结果</returns>
public TResponse SendQuery<TResponse>(global::GFramework.Cqrs.Abstractions.Cqrs.Query.IQuery<TResponse> query)
{
return SendQueryAsync(query).AsTask().GetAwaiter().GetResult();
ArgumentNullException.ThrowIfNull(query);
return LegacyCqrsDispatchHelper.SendSynchronously(CqrsRuntime, this, query);
}
/// <summary>
@ -403,7 +405,8 @@ public class ArchitectureContext : IArchitectureContext
/// <returns>命令执行结果</returns>
public TResponse SendCommand<TResponse>(global::GFramework.Cqrs.Abstractions.Cqrs.Command.ICommand<TResponse> command)
{
return SendCommandAsync(command).AsTask().GetAwaiter().GetResult();
ArgumentNullException.ThrowIfNull(command);
return LegacyCqrsDispatchHelper.SendSynchronously(CqrsRuntime, this, command);
}
/// <summary>

View File

@ -1,9 +1,7 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using System.Diagnostics.CodeAnalysis;
using GFramework.Core.Abstractions.Command;
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Cqrs;
using GFramework.Cqrs.Abstractions.Cqrs;
using IAsyncCommand = GFramework.Core.Abstractions.Command.IAsyncCommand;
@ -78,9 +76,11 @@ public sealed class CommandExecutor(ICqrsRuntime? runtime = null) : ICommandExec
{
ArgumentNullException.ThrowIfNull(command);
if (TryResolveDispatchContext(command, out var context))
var cqrsRuntime = _runtime;
if (LegacyCqrsDispatchHelper.TryResolveDispatchContext(cqrsRuntime, command, out var context))
{
return _runtime.SendAsync(context, new LegacyAsyncCommandDispatchRequest(command)).AsTask();
return cqrsRuntime.SendAsync(context, new LegacyAsyncCommandDispatchRequest(command)).AsTask();
}
return command.ExecuteAsync();
@ -97,9 +97,11 @@ public sealed class CommandExecutor(ICqrsRuntime? runtime = null) : ICommandExec
{
ArgumentNullException.ThrowIfNull(command);
if (TryResolveDispatchContext(command, out var context))
var cqrsRuntime = _runtime;
if (LegacyCqrsDispatchHelper.TryResolveDispatchContext(cqrsRuntime, command, out var context))
{
return BridgeAsyncCommandWithResultAsync(_runtime, context, command);
return BridgeAsyncCommandWithResultAsync(cqrsRuntime, context, command);
}
return command.ExecuteAsync();
@ -119,12 +121,14 @@ public sealed class CommandExecutor(ICqrsRuntime? runtime = null) : ICommandExec
where TTarget : class
where TRequest : IRequest<Unit>
{
if (!TryResolveDispatchContext(target, out var context))
var cqrsRuntime = _runtime;
if (!LegacyCqrsDispatchHelper.TryResolveDispatchContext(cqrsRuntime, target, out var context))
{
return false;
}
_runtime.SendAsync(context, requestFactory(target)).AsTask().GetAwaiter().GetResult();
LegacyCqrsDispatchHelper.SendSynchronously(cqrsRuntime, context, requestFactory(target));
return true;
}
@ -145,13 +149,15 @@ public sealed class CommandExecutor(ICqrsRuntime? runtime = null) : ICommandExec
where TTarget : class
where TRequest : IRequest<object?>
{
if (!TryResolveDispatchContext(target, out var context))
var cqrsRuntime = _runtime;
if (!LegacyCqrsDispatchHelper.TryResolveDispatchContext(cqrsRuntime, target, out var context))
{
result = default;
return false;
}
var boxedResult = _runtime.SendAsync(context, requestFactory(target)).AsTask().GetAwaiter().GetResult();
var boxedResult = LegacyCqrsDispatchHelper.SendSynchronously(cqrsRuntime, context, requestFactory(target));
result = (TResult)boxedResult!;
return true;
}
@ -177,33 +183,4 @@ public sealed class CommandExecutor(ICqrsRuntime? runtime = null) : ICommandExec
.ConfigureAwait(false);
return (TResult)boxedResult!;
}
/// <summary>
/// 解析当前 legacy 目标对象应该绑定到哪个架构上下文。
/// </summary>
/// <param name="target">即将执行的 legacy 目标对象。</param>
/// <param name="context">命中时返回可用于 CQRS runtime 的架构上下文。</param>
/// <returns>如果既接入了 runtime 且目标对象提供了上下文,则返回 <see langword="true" />。</returns>
[MemberNotNullWhen(true, nameof(_runtime))]
private bool TryResolveDispatchContext(
object target,
out GFramework.Core.Abstractions.Architectures.IArchitectureContext context)
{
context = null!;
if (_runtime is null || target is not IContextAware contextAware)
{
return false;
}
try
{
context = contextAware.GetContext();
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
}

View File

@ -17,8 +17,10 @@ internal sealed class LegacyAsyncCommandDispatchRequestHandler
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
// Legacy ExecuteAsync contract does not accept CancellationToken; use WaitAsync so the caller can still observe cancellation promptly.
cancellationToken.ThrowIfCancellationRequested();
PrepareTarget(request.Command);
await request.Command.ExecuteAsync().ConfigureAwait(false);
await request.Command.ExecuteAsync().WaitAsync(cancellationToken).ConfigureAwait(false);
return Unit.Value;
}
}

View File

@ -0,0 +1,94 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using System.Diagnostics.CodeAnalysis;
using System.Threading.Tasks;
using GFramework.Core.Abstractions.Architectures;
using GFramework.Core.Abstractions.Rule;
using GFramework.Cqrs.Abstractions.Cqrs;
namespace GFramework.Core.Cqrs;
/// <summary>
/// 为 legacy Core CQRS bridge 提供共享的上下文解析与同步兼容辅助逻辑。
/// </summary>
/// <remarks>
/// 旧的同步 Command/Query 入口仍需要阻塞等待统一 <see cref="ICqrsRuntime" /> 返回结果。
/// 这里统一通过 <see cref="Task.Run(System.Func{System.Threading.Tasks.Task})" /> 把等待动作切换到线程池,
/// 避免直接占用调用方的 <see cref="SynchronizationContext" /> 导致 legacy 同步入口与异步 pipeline 互相卡死。
/// </remarks>
internal static class LegacyCqrsDispatchHelper
{
/// <summary>
/// 解析当前 legacy 目标对象是否能够绑定到统一 CQRS runtime 的架构上下文。
/// </summary>
/// <param name="runtime">当前执行器可用的统一 CQRS runtime。</param>
/// <param name="target">即将执行的 legacy 目标对象。</param>
/// <param name="context">命中时返回可用于 CQRS runtime 的架构上下文。</param>
/// <returns>
/// 当 <paramref name="runtime" /> 可用且 <paramref name="target" /> 能稳定提供
/// <see cref="IArchitectureContext" /> 时返回 <see langword="true" />;否则返回 <see langword="false" />。
/// </returns>
internal static bool TryResolveDispatchContext(
[NotNullWhen(true)] ICqrsRuntime? runtime,
object target,
out IArchitectureContext context)
{
ArgumentNullException.ThrowIfNull(target);
context = null!;
if (runtime is null || target is not IContextAware contextAware)
{
return false;
}
try
{
context = contextAware.GetContext();
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
/// <summary>
/// 同步等待统一 CQRS runtime 完成无返回值请求。
/// </summary>
/// <param name="runtime">负责分发当前请求的统一 CQRS runtime。</param>
/// <param name="context">当前架构上下文。</param>
/// <param name="request">要同步等待的请求。</param>
internal static void SendSynchronously(
ICqrsRuntime runtime,
IArchitectureContext context,
IRequest<Unit> request)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
Task.Run(() => runtime.SendAsync(context, request).AsTask()).GetAwaiter().GetResult();
}
/// <summary>
/// 同步等待统一 CQRS runtime 完成带返回值请求,并返回实际响应。
/// </summary>
/// <typeparam name="TResponse">请求响应类型。</typeparam>
/// <param name="runtime">负责分发当前请求的统一 CQRS runtime。</param>
/// <param name="context">当前架构上下文。</param>
/// <param name="request">要同步等待的请求。</param>
/// <returns>统一 CQRS runtime 返回的响应结果。</returns>
internal static TResponse SendSynchronously<TResponse>(
ICqrsRuntime runtime,
IArchitectureContext context,
IRequest<TResponse> request)
{
ArgumentNullException.ThrowIfNull(runtime);
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(request);
return Task.Run(() => runtime.SendAsync(context, request).AsTask()).GetAwaiter().GetResult();
}
}

View File

@ -1,9 +1,7 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using System.Diagnostics.CodeAnalysis;
using GFramework.Core.Abstractions.Query;
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Cqrs;
using GFramework.Cqrs.Abstractions.Cqrs;
@ -31,9 +29,11 @@ public sealed class AsyncQueryExecutor(ICqrsRuntime? runtime = null) : IAsyncQue
{
ArgumentNullException.ThrowIfNull(query);
if (TryResolveDispatchContext(query, out var context))
var cqrsRuntime = _runtime;
if (LegacyCqrsDispatchHelper.TryResolveDispatchContext(cqrsRuntime, query, out var context))
{
return BridgeAsyncQueryAsync(_runtime, context, query);
return BridgeAsyncQueryAsync(cqrsRuntime, context, query);
}
return query.DoAsync();
@ -61,32 +61,4 @@ public sealed class AsyncQueryExecutor(ICqrsRuntime? runtime = null) : IAsyncQue
return (TResult)boxedResult!;
}
/// <summary>
/// 解析当前 legacy 查询应该绑定到哪个架构上下文。
/// </summary>
/// <param name="query">即将执行的 legacy 查询对象。</param>
/// <param name="context">命中时返回可用于 CQRS runtime 的架构上下文。</param>
/// <returns>如果既接入了 runtime 且查询对象提供了上下文,则返回 <see langword="true" />。</returns>
[MemberNotNullWhen(true, nameof(_runtime))]
private bool TryResolveDispatchContext(
object query,
out GFramework.Core.Abstractions.Architectures.IArchitectureContext context)
{
context = null!;
if (_runtime is null || query is not IContextAware contextAware)
{
return false;
}
try
{
context = contextAware.GetContext();
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
}

View File

@ -1,9 +1,7 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
using System.Diagnostics.CodeAnalysis;
using GFramework.Core.Abstractions.Query;
using GFramework.Core.Abstractions.Rule;
using GFramework.Core.Cqrs;
using GFramework.Cqrs.Abstractions.Cqrs;
@ -31,53 +29,30 @@ public sealed class QueryExecutor(ICqrsRuntime? runtime = null) : IQueryExecutor
/// </summary>
/// <typeparam name="TResult">查询结果的类型。</typeparam>
/// <param name="query">要执行的查询对象,必须实现 IQuery&lt;TResult&gt; 接口。</param>
/// <returns>查询执行的结果,类型为 TResult。</returns>
/// <returns>查询执行成功后还原出的 <typeparamref name="TResult" /> 结果。</returns>
/// <exception cref="NullReferenceException">
/// 统一 CQRS runtime 返回 <see langword="null" />,但 <typeparamref name="TResult" /> 为值类型。
/// </exception>
/// <exception cref="InvalidCastException">
/// 统一 CQRS runtime 返回的装箱结果无法转换为 <typeparamref name="TResult" />。
/// </exception>
public TResult Send<TResult>(IQuery<TResult> query)
{
ArgumentNullException.ThrowIfNull(query);
if (TryResolveDispatchContext(query, out var context))
var cqrsRuntime = _runtime;
if (LegacyCqrsDispatchHelper.TryResolveDispatchContext(cqrsRuntime, query, out var context))
{
var boxedResult = _runtime.SendAsync(
context,
new LegacyQueryDispatchRequest(
query,
() => query.Do()))
.AsTask()
.GetAwaiter()
.GetResult();
var boxedResult = LegacyCqrsDispatchHelper.SendSynchronously(
cqrsRuntime,
context,
new LegacyQueryDispatchRequest(
query,
() => query.Do()));
return (TResult)boxedResult!;
}
return query.Do();
}
/// <summary>
/// 解析当前 legacy 查询应该绑定到哪个架构上下文。
/// </summary>
/// <param name="query">即将执行的 legacy 查询对象。</param>
/// <param name="context">命中时返回可用于 CQRS runtime 的架构上下文。</param>
/// <returns>如果既接入了 runtime 且查询对象提供了上下文,则返回 <see langword="true" />。</returns>
[MemberNotNullWhen(true, nameof(_runtime))]
private bool TryResolveDispatchContext(
object query,
out GFramework.Core.Abstractions.Architectures.IArchitectureContext context)
{
context = null!;
if (_runtime is null || query is not IContextAware contextAware)
{
return false;
}
try
{
context = contextAware.GetContext();
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
}

View File

@ -28,6 +28,9 @@ public interface ICqrsRuntime
/// <remarks>
/// 该契约允许调用方传入任意 <see cref="ICqrsContext" />
/// 但默认运行时在需要向处理器或行为注入框架上下文时,仍要求该上下文同时实现 <c>IArchitectureContext</c>。
/// 为了兼容 legacy 同步入口,<c>ArchitectureContext</c>、<c>QueryExecutor</c> 与 <c>CommandExecutor</c>
/// 可能会在后台线程上同步等待该异步结果;实现者与 pipeline 行为不应依赖调用方的
/// <see cref="SynchronizationContext" />,并应优先在内部异步链路上使用 <c>ConfigureAwait(false)</c>。
/// </remarks>
ValueTask<TResponse> SendAsync<TResponse>(
ICqrsContext context,

View File

@ -7,7 +7,7 @@ CQRS 迁移与收敛。
## 当前恢复点
- 恢复点编号:`CQRS-REWRITE-RP-094`
- 恢复点编号:`CQRS-REWRITE-RP-095`
- 当前阶段:`Phase 8`
- 当前 PR 锚点:`PR #334`
- 当前结论:
@ -29,8 +29,9 @@ CQRS 迁移与收敛。
- 当前 `RP-091` 已把 benchmark 项目发布面隔离与包清单校验前移到 PR`GFramework.Cqrs.Benchmarks` 明确保持不可打包,`publish.yml``ci.yml` 复用同一份 packed-modules 校验脚本
- `RP-092` 已补齐 request handler `Singleton / Transient` 生命周期矩阵 benchmark并明确把 `Scoped` 对照留到具备真实显式作用域边界的宿主模型后再评估
- `RP-093` 已把 `GFramework.Core` 的 legacy `SendCommand` / `SendQuery` 兼容入口收敛到底层统一 `GFramework.Cqrs` runtime同时补充 `Mediator` 未吸收能力差距复核
- 当前 `RP-094` 已按 `PR #334` latest-head review 收口 legacy bridge 的测试注册方式、模块运行时依赖契约、异步取消语义、XML 文档缺口与兼容文档回退边界
- `ai-plan` active 入口现以 `RP-094` 为最新恢复锚点;`PR #334``PR #331``PR #326``PR #323``PR #307` 与其他更早阶段细节均以下方归档或说明为准
- `RP-094` 已按 `PR #334` latest-head review 收口 legacy bridge 的测试注册方式、模块运行时依赖契约、异步取消语义、XML 文档缺口与兼容文档回退边界
- 当前 `RP-095` 已继续收口 `PR #334` 剩余 review把 legacy 同步 bridge 的阻塞等待统一切到线程池隔离 helper、补齐 `ArchitectureContext` / executor 共享 dispatch helper、修正 bridge fixture 的并行与容器释放约束,并为 runtime bridge 与 async void command cancellation 增补回归测试
- `ai-plan` active 入口现以 `RP-095` 为最新恢复锚点;`PR #334``PR #331``PR #326``PR #323``PR #307` 与其他更早阶段细节均以下方归档或说明为准
## 当前活跃事实
@ -42,9 +43,13 @@ CQRS 迁移与收敛。
- `GFramework.Core` 当前已通过内部 bridge request / handler 把 legacy `ICommand``IAsyncCommand``IQuery``IAsyncQuery` 接到统一 `ICqrsRuntime`
- 标准 `Architecture` 初始化路径会自动扫描 `GFramework.Core` 程序集中的 legacy bridge handler因此旧 `SendCommand(...)` / `SendQuery(...)` 无需改变用法即可进入统一 pipeline
- `CommandExecutor``QueryExecutor``AsyncQueryExecutor` 仍保留“无 runtime 时直接执行”的回退路径,用于不依赖容器的隔离单元测试
- `LegacyCqrsDispatchHelper` 现统一负责 runtime dispatch context 解析,以及 legacy 同步 bridge 对 `ICqrsRuntime.SendAsync(...)` 的线程池隔离等待
- `ArchitectureContext``CommandExecutor``QueryExecutor` 的同步 CQRS/legacy bridge 入口不再直接在调用线程上阻塞 `SendAsync(...).GetAwaiter().GetResult()`
- `GFramework.Core.Tests` 现通过 `InternalsVisibleTo("GFramework.Core.Tests")` 直接实例化内部 bridge handler不再依赖字符串反射装配测试桥接注册
- 使用 `LegacyBridgePipelineTracker``ArchitectureContextTests``ArchitectureModulesBehaviorTests` 现都显式标记为 `NonParallelizable`
- `ArchitectureContextTests.CreateFrozenBridgeContext(...)` 现把冻结容器所有权显式交回调用方,并在每个 bridge 用例的 `finally` 中释放
- `CommandExecutorModule``QueryExecutorModule``AsyncQueryExecutorModule` 现改为 `GetRequired<ICqrsRuntime>()` 并在 XML 文档里显式声明注册顺序契约,避免 runtime 缺失时静默回退
- `LegacyAsyncQueryDispatchRequestHandler``LegacyAsyncCommandResultDispatchRequestHandler` 现通过 `ThrowIfCancellationRequested()` + `WaitAsync(cancellationToken)` 显式保留调用方取消可见性
- `LegacyAsyncQueryDispatchRequestHandler``LegacyAsyncCommandResultDispatchRequestHandler``LegacyAsyncCommandDispatchRequestHandler`通过 `ThrowIfCancellationRequested()` + `WaitAsync(cancellationToken)` 显式保留调用方取消可见性
- 相对 `ai-libs/Mediator`当前仍未完全吸收的能力集中在六类facade 公开入口、telemetry、stream pipeline、notification publisher 策略、生成器配置与诊断、生命周期/缓存公开配置面
- 发布工作流已有 packed modules 校验,但 PR 工作流此前没有等价的 solution pack 产物名单校验
- 本地 `dotnet pack GFramework.sln -c Release --no-restore -o <temp-dir>` 当前只产出 14 个预期包,未复现 benchmark `.nupkg`
@ -55,7 +60,7 @@ CQRS 迁移与收敛。
- 已新增手动触发的 benchmark workflow默认只验证 benchmark 项目 Release build只有显式提供过滤器时才执行 BenchmarkDotNet 运行;过滤器输入现通过环境变量传入 shell避免 workflow_dispatch 输入直接插值到命令行
- 远端 `CTRF` 最新汇总为 `2274/2274` passed
- `MegaLinter` 当前只暴露 `dotnet-format``Restore operation failed` 环境噪音,尚未提供本地仍成立的文件级格式诊断
- `PR #334` 当前 latest-head open AI feedback 已集中到 legacy bridge / 文档收尾;本轮本地修复后,剩余 thread 应主要是待 GitHub 重新索引的状态差异或低价值建议
- `PR #334` 当前 latest-head open AI feedback 经过本轮本地复核与修复后,应主要剩余待 GitHub 重新索引的状态差异或已实质关闭但未 resolve 的 thread
## 当前风险
@ -127,6 +132,13 @@ CQRS 迁移与收敛。
- 结果:通过
- `git diff --check`
- 结果:通过
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~ArchitectureModulesBehaviorTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests|FullyQualifiedName~LegacyAsyncCommandDispatchRequestHandlerTests"`
- 结果:通过,`54/54` passed
- 备注:覆盖 legacy 同步 bridge 的同步上下文隔离、bridge fixture 容器释放,以及 async void command cancellation 可见性
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
- 结果:通过
## 下一推荐步骤

View File

@ -2,6 +2,28 @@
## 2026-05-07
### 阶段PR #334 legacy bridge sync follow-upCQRS-REWRITE-RP-095
- 再次使用 `$gframework-pr-review` 抓取 `feat/cqrs-optimization` 对应的 `PR #334` latest-head review并只保留本地复核后仍成立的问题
- `QueryExecutor` / `CommandExecutor` 新增的同步 bridge 仍直接阻塞 `ICqrsRuntime.SendAsync(...)`,在调用方存在 `SynchronizationContext` 时容易放大 sync-over-async 死锁面
- `QueryExecutor` / `CommandExecutor` / `AsyncQueryExecutor` 各自保留一份相同的 dispatch-context 解析逻辑,仍有漂移风险
- `ArchitectureContextTests` 的 bridge fixture 依然共享静态 tracker 且未显式声明非并行;冻结容器所有权也未交还给调用方释放
- `LegacyAsyncCommandDispatchRequestHandler` 仍未沿用另两个 async bridge handler 的取消可见性模式
- 本轮主线程决策:
- 新增 `GFramework.Core/Cqrs/LegacyCqrsDispatchHelper.cs`,统一收口 legacy bridge 的 dispatch-context 解析,以及同步 bridge 对 `ICqrsRuntime.SendAsync(...)` 的线程池隔离等待
- 将 `QueryExecutor``CommandExecutor``AsyncQueryExecutor` 的重复 helper 改为复用共享 helper并把 `ArchitectureContext` 的同步 CQRS 包装入口一并切换到同一阻塞策略,避免留下半修状态
- 为 `ICqrsRuntime.SendAsync(...)` 补充 `<remarks>`,显式说明 legacy 同步入口会在后台线程上等待该异步契约,处理链路不应依赖调用方 `SynchronizationContext`
- 把 `ArchitectureContextTests``ArchitectureModulesBehaviorTests` 标记为 `NonParallelizable`,并让 `CreateFrozenBridgeContext(...)` 把冻结容器通过 `out` 参数返还给每个测试在 `finally` 中释放
- 为 `LegacyAsyncCommandDispatchRequestHandler` 增补 `ThrowIfCancellationRequested()` + `WaitAsync(cancellationToken)`,与另外两个 async bridge handler 保持一致
- 新增回归测试覆盖同步 bridge 的 `SynchronizationContext` 隔离、legacy async command handler 的取消语义,以及 async/sync bridge request 的 request-type 命中
- 本轮权威验证:
- `env GIT_DIR=... GIT_WORK_TREE=... python3 scripts/license-header.py --check`
- 结果:通过
- `dotnet build GFramework.Core/GFramework.Core.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- `dotnet test GFramework.Core.Tests/GFramework.Core.Tests.csproj -c Release --filter "FullyQualifiedName~ArchitectureContextTests|FullyQualifiedName~ArchitectureModulesBehaviorTests|FullyQualifiedName~CommandExecutorTests|FullyQualifiedName~QueryExecutorTests|FullyQualifiedName~AsyncQueryExecutorTests|FullyQualifiedName~LegacyAsyncCommandDispatchRequestHandlerTests"`
- 结果:通过,`54/54` passed
### 阶段PR #334 legacy bridge / 文档 review 收尾CQRS-REWRITE-RP-094
- 使用 `$gframework-pr-review` 抓取当前分支公开 PR确认 `feat/cqrs-optimization` 当前对应 `PR #334`