diff --git a/GFramework.Cqrs.Benchmarks/Program.cs b/GFramework.Cqrs.Benchmarks/Program.cs
index 44324baa..7677b68d 100644
--- a/GFramework.Cqrs.Benchmarks/Program.cs
+++ b/GFramework.Cqrs.Benchmarks/Program.cs
@@ -1,6 +1,11 @@
// Copyright (c) 2025-2026 GeWuYou
// SPDX-License-Identifier: Apache-2.0
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Loggers;
using BenchmarkDotNet.Running;
@@ -11,13 +16,243 @@ namespace GFramework.Cqrs.Benchmarks;
///
internal static class Program
{
+ private const string ArtifactsSuffixOption = "--artifacts-suffix";
+ private const string ArtifactsSuffixEnvironmentVariable = "GFRAMEWORK_CQRS_BENCHMARK_ARTIFACTS_SUFFIX";
+ private const string ArtifactsPathEnvironmentVariable = "GFRAMEWORK_CQRS_BENCHMARK_ARTIFACTS_PATH";
+ private const string IsolatedHostEnvironmentVariable = "GFRAMEWORK_CQRS_BENCHMARK_ISOLATED_HOST";
+ private const string DefaultArtifactsDirectoryName = "BenchmarkDotNet.Artifacts";
+ private const string IsolatedHostDirectoryName = "host";
+
///
/// 运行当前程序集中的全部 benchmark。
///
- /// 透传给 BenchmarkDotNet 的命令行参数。
+ /// 仓库入口参数与透传给 BenchmarkDotNet 的命令行参数。
private static void Main(string[] args)
{
+ var invocation = ParseInvocation(args);
+
ConsoleLogger.Default.WriteLine("Running GFramework.Cqrs benchmarks");
- BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);
+
+ if (invocation.ArtifactsSuffix is not null &&
+ !string.Equals(
+ Environment.GetEnvironmentVariable(IsolatedHostEnvironmentVariable),
+ "1",
+ StringComparison.Ordinal))
+ {
+ Environment.Exit(RunFromIsolatedHost(invocation, args));
+ }
+
+ if (invocation.ArtifactsPath is null)
+ {
+ BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(invocation.BenchmarkDotNetArguments);
+ return;
+ }
+
+ ConsoleLogger.Default.WriteLine(
+ $"Using isolated BenchmarkDotNet artifacts path: {invocation.ArtifactsPath}");
+
+ BenchmarkSwitcher
+ .FromAssembly(typeof(Program).Assembly)
+ .Run(invocation.BenchmarkDotNetArguments, DefaultConfig.Instance.WithArtifactsPath(invocation.ArtifactsPath));
}
+
+ ///
+ /// 解析仓库自定义参数,并生成实际传递给 BenchmarkDotNet 的参数与隔离后的 artifacts 路径。
+ ///
+ /// 当前进程收到的完整命令行参数。
+ /// 入口解析后的 benchmark 调用选项。
+ /// 自定义参数缺失值或包含非法路径片段时抛出。
+ private static BenchmarkInvocation ParseInvocation(string[] args)
+ {
+ var benchmarkDotNetArguments = new List(args.Length);
+ string? commandLineSuffix = null;
+
+ for (var index = 0; index < args.Length; index++)
+ {
+ var argument = args[index];
+ if (!string.Equals(argument, ArtifactsSuffixOption, StringComparison.Ordinal))
+ {
+ benchmarkDotNetArguments.Add(argument);
+ continue;
+ }
+
+ if (index == args.Length - 1)
+ {
+ throw new ArgumentException(
+ $"The {ArtifactsSuffixOption} option requires a suffix value.",
+ nameof(args));
+ }
+
+ if (commandLineSuffix is not null)
+ {
+ throw new ArgumentException(
+ $"The {ArtifactsSuffixOption} option can only be provided once.",
+ nameof(args));
+ }
+
+ // 剥离仓库自定义参数,避免将它误传给 BenchmarkDotNet 自身的命令行解析器。
+ commandLineSuffix = args[++index];
+ }
+
+ var artifactsPath = ResolveArtifactsPath(commandLineSuffix);
+ return new BenchmarkInvocation(benchmarkDotNetArguments.ToArray(), commandLineSuffix, artifactsPath);
+ }
+
+ ///
+ /// 将当前 benchmark 入口重启到独立的宿主工作目录,避免多个并发进程共享同一份 auto-generated build 目录。
+ ///
+ /// 当前入口解析后的 benchmark 调用选项。
+ /// 原始命令行参数,用于透传给隔离后的宿主进程。
+ /// 隔离后宿主进程的退出码。
+ private static int RunFromIsolatedHost(BenchmarkInvocation invocation, string[] originalArgs)
+ {
+ var artifactsPath = invocation.ArtifactsPath
+ ?? throw new ArgumentNullException(nameof(invocation), "An isolated benchmark host requires an artifacts path.");
+
+ var currentAssemblyPath = typeof(Program).Assembly.Location;
+ var sourceHostDirectory = AppContext.BaseDirectory;
+ var isolatedHostDirectory = Path.Combine(artifactsPath, IsolatedHostDirectoryName);
+
+ PrepareIsolatedHostDirectory(sourceHostDirectory, isolatedHostDirectory);
+
+ var isolatedAssemblyPath = Path.Combine(
+ isolatedHostDirectory,
+ Path.GetFileName(currentAssemblyPath));
+
+ var startInfo = new ProcessStartInfo("dotnet")
+ {
+ WorkingDirectory = isolatedHostDirectory,
+ UseShellExecute = false
+ };
+
+ startInfo.ArgumentList.Add(isolatedAssemblyPath);
+ foreach (var argument in originalArgs)
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+
+ startInfo.Environment[IsolatedHostEnvironmentVariable] = "1";
+ startInfo.Environment[ArtifactsPathEnvironmentVariable] = artifactsPath;
+
+ ConsoleLogger.Default.WriteLine(
+ $"Launching isolated benchmark host in: {isolatedHostDirectory}");
+
+ using var process = Process.Start(startInfo) ??
+ throw new InvalidOperationException("Failed to launch the isolated benchmark host process.");
+
+ process.WaitForExit();
+ return process.ExitCode;
+ }
+
+ ///
+ /// 根据命令行或环境变量中的 suffix 生成当前 benchmark 运行的独立 artifacts 目录。
+ ///
+ /// 命令行显式提供的 suffix。
+ /// 隔离后的 artifacts 目录;若未提供 suffix,则返回 。
+ private static string? ResolveArtifactsPath(string? commandLineSuffix)
+ {
+ var explicitArtifactsPath = Environment.GetEnvironmentVariable(ArtifactsPathEnvironmentVariable);
+ if (!string.IsNullOrWhiteSpace(explicitArtifactsPath))
+ {
+ return Path.GetFullPath(explicitArtifactsPath);
+ }
+
+ if (!string.IsNullOrWhiteSpace(commandLineSuffix))
+ {
+ var validatedCommandLineSuffix = ValidateArtifactsSuffix(
+ commandLineSuffix,
+ ArtifactsSuffixOption);
+
+ return Path.GetFullPath(Path.Combine(DefaultArtifactsDirectoryName, validatedCommandLineSuffix));
+ }
+
+ var environmentSuffix = Environment.GetEnvironmentVariable(ArtifactsSuffixEnvironmentVariable);
+ if (string.IsNullOrWhiteSpace(environmentSuffix))
+ {
+ return null;
+ }
+
+ var validatedEnvironmentSuffix = ValidateArtifactsSuffix(
+ environmentSuffix,
+ ArtifactsSuffixEnvironmentVariable);
+
+ return Path.GetFullPath(Path.Combine(DefaultArtifactsDirectoryName, validatedEnvironmentSuffix));
+ }
+
+ ///
+ /// 校验自定义 suffix,避免路径穿越、分隔符注入或不可移植字符污染 BenchmarkDotNet 的输出目录。
+ ///
+ /// 待校验的后缀值。
+ /// 后缀来源名称,用于错误提示。
+ /// 可安全用于单级目录名的后缀。
+ /// 当后缀为空或包含未允许字符时抛出。
+ private static string ValidateArtifactsSuffix(string suffix, string sourceName)
+ {
+ var trimmedSuffix = suffix.Trim();
+ if (trimmedSuffix.Length == 0)
+ {
+ throw new ArgumentException(
+ $"The {sourceName} value must not be empty.",
+ nameof(suffix));
+ }
+
+ foreach (var character in trimmedSuffix)
+ {
+ if (char.IsAsciiLetterOrDigit(character) || character is '.' or '-' or '_')
+ {
+ continue;
+ }
+
+ throw new ArgumentException(
+ $"The {sourceName} value '{trimmedSuffix}' contains unsupported characters. " +
+ "Only ASCII letters, digits, '.', '-' and '_' are allowed.",
+ nameof(suffix));
+ }
+
+ return trimmedSuffix;
+ }
+
+ ///
+ /// 将当前 benchmark 宿主输出复制到独立目录,确保并发运行时的 auto-generated benchmark 项目不会写入同一路径。
+ ///
+ /// 当前 benchmark 宿主输出目录。
+ /// 当前 suffix 对应的独立宿主目录。
+ private static void PrepareIsolatedHostDirectory(string sourceHostDirectory, string isolatedHostDirectory)
+ {
+ Directory.CreateDirectory(isolatedHostDirectory);
+ CopyDirectoryRecursively(sourceHostDirectory, isolatedHostDirectory);
+ }
+
+ ///
+ /// 递归复制 benchmark 宿主输出目录,覆盖同名文件以支持同一 suffix 的重复运行。
+ ///
+ /// 源目录。
+ /// 目标目录。
+ private static void CopyDirectoryRecursively(string sourceDirectory, string destinationDirectory)
+ {
+ foreach (var directory in Directory.GetDirectories(sourceDirectory, "*", SearchOption.AllDirectories))
+ {
+ var relativeDirectory = Path.GetRelativePath(sourceDirectory, directory);
+ Directory.CreateDirectory(Path.Combine(destinationDirectory, relativeDirectory));
+ }
+
+ foreach (var file in Directory.GetFiles(sourceDirectory, "*", SearchOption.AllDirectories))
+ {
+ var relativeFile = Path.GetRelativePath(sourceDirectory, file);
+ var destinationFile = Path.Combine(destinationDirectory, relativeFile);
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!);
+ File.Copy(file, destinationFile, overwrite: true);
+ }
+ }
+
+ ///
+ /// 表示一次 benchmark 入口调用在剥离仓库自定义参数后的最终配置。
+ ///
+ /// 实际传递给 BenchmarkDotNet 的命令行参数。
+ /// 当前运行声明的隔离后缀;若未声明则为 。
+ /// 本次运行的 artifacts 目录;若未隔离则为 。
+ private readonly record struct BenchmarkInvocation(
+ string[] BenchmarkDotNetArguments,
+ string? ArtifactsSuffix,
+ string? ArtifactsPath);
}
diff --git a/GFramework.Cqrs.Benchmarks/README.md b/GFramework.Cqrs.Benchmarks/README.md
index 51dc8155..2d4a5b96 100644
--- a/GFramework.Cqrs.Benchmarks/README.md
+++ b/GFramework.Cqrs.Benchmarks/README.md
@@ -56,9 +56,17 @@ dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.cspro
dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --filter "*StreamLifetimeBenchmarks.Stream_*"
```
+如果需要在两个终端里并发复核不同的过滤 benchmark,请为每个进程追加不同的 `--artifacts-suffix `,把 `BenchmarkDotNet` auto-generated build 与 artifacts 输出隔离到不同目录;这只是运行入口的目录隔离约定,不是 benchmark 业务逻辑本身的要求。例如:
+
+```bash
+dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix req-lifetime-a --filter "*RequestLifetimeBenchmarks.SendRequest_*"
+dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix stream-lifetime-b --filter "*StreamLifetimeBenchmarks.Stream_*"
+```
+
## 当前约束
- `BenchmarkDotNet.Artifacts/` 属于本地生成输出,默认加入仓库忽略,不作为常规提交内容
+- 当两个带 `--filter` 的 benchmark 进程需要并发运行时,必须为它们分别传入不同的 `--artifacts-suffix `,避免多个 `BenchmarkDotNet` 进程写入同一份 auto-generated build / artifacts 目录;这个约束只服务于本地输出隔离,不代表 benchmark 场景之间存在额外业务依赖
- `RequestLifetimeBenchmarks` 现在复用与默认 generated-provider 路径一致的 benchmark 宿主接线;它比较的是生命周期切换后的 handler 解析与 dispatch 成本,不单独引入另一套 runtime 发现口径
- `RequestLifetimeBenchmarks` 的 `Scoped` 场景会在每次 request 分发时显式创建并释放真实 DI 作用域,用来观察 scoped handler 绑定到 request 边界后的解析与 dispatch 成本
- `StreamLifetimeBenchmarks` 现在按 direct handler、`GFramework.Cqrs` reflection、`GFramework.Cqrs` generated、`MediatR` 四层口径组织,并额外区分 `FirstItem` 与 `DrainAll` 两种观测方式,用于把 stream 建流/首个元素成本与完整枚举成本拆开观察
diff --git a/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md b/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md
index 61544c29..b5f5960e 100644
--- a/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md
+++ b/ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md
@@ -7,23 +7,22 @@ CQRS 迁移与收敛。
## 当前恢复点
-- 恢复点编号:`CQRS-REWRITE-RP-130`
+- 恢复点编号:`CQRS-REWRITE-RP-131`
- 当前阶段:`Phase 8`
- 当前 PR 锚点:`待重新抓取`
- 当前结论:
-- 当前 `RP-130` 延续 `$gframework-batch-boot 50`,并在上一波 `StreamingBenchmarks`、`StreamInvokerBenchmarks`、`RequestLifetimeBenchmarks` 扩口径之后,继续把 `StreamLifetimeBenchmarks` 的生命周期矩阵补齐到真实 `Scoped` stream 作用域,同时把 benchmark `README` 与当前矩阵同步
-- 本轮继续沿用已复核的基线:`origin/main` 与本地 `main` 当前都在 `699d0b48`(`2026-05-09 18:39:38 +0800`);当前分支连同本轮变更相对 `origin/main` 的累计 branch diff 约为 `9 files changed, 953 insertions(+), 83 deletions(-)`,离 `$gframework-batch-boot 50` 还有明显余量
-- 本轮写面收敛在 benchmark 层三处:`GFramework.Cqrs.Benchmarks/Messaging/BenchmarkHostFactory.cs`、`StreamLifetimeBenchmarks.cs`、`GFramework.Cqrs.Benchmarks/README.md`;没有扩散到 `GFramework.Cqrs` runtime 或测试项目
-- `BenchmarkHostFactory` 现在提供 `CreateScopedGFrameworkStream(...)` 与 `CreateScopedMediatRStream(...)`,通过显式 scope 包裹整个 async stream 枚举周期,避免 scoped handler 在建流后提前释放或错误回落到根容器语义
-- `StreamLifetimeBenchmarks` 当前矩阵已完整覆盖 `Singleton / Scoped / Transient` 与 `FirstItem / DrainAll`:
- - `Singleton` 下 `DrainAll` 仍表现为 generated 略优于 reflection,约 `131.96 ns` 对 `134.26 ns`
- - `Scoped` 下已经可以稳定产出真实作用域矩阵;`FirstItem` 约为 baseline `56.24 ns`、`MediatR` `338.82 ns`、reflection `612.49 ns`、generated `628.65 ns`,`DrainAll` 约为 baseline `81.20 ns`、`MediatR` `428.66 ns`、generated `692.05 ns`、reflection `716.61 ns`
- - `Transient` 下 `FirstItem` 约为 generated `107.79 ns`、reflection `112.60 ns`,但 `DrainAll` 仍是 reflection `131.41 ns` 略优于 generated `135.95 ns`
-- `README` 已同步三类 benchmark 现状:`RequestLifetimeBenchmarks` 与 `StreamLifetimeBenchmarks` 都包含真实 `Scoped` 生命周期,`StreamingBenchmarks` / `StreamInvokerBenchmarks` 都显式区分 `FirstItem / DrainAll`,且 `StreamInvokerBenchmarks` 的 `DrainAll` short-job 输出被明确标注为 smoke-only,不作为稳定排序结论
-- 本轮再次确认:此前并行运行两个 BenchmarkDotNet `dotnet run --no-build` 过滤命令时出现的冲突属于 benchmark 工件/生成目录层面的运行隔离问题,而不是 `Fixture`、`StreamInvokerBenchmarks` 或 `StreamLifetimeBenchmarks` 的业务逻辑错误
+- 当前 `RP-131` 继续沿用 `$gframework-batch-boot 50`,并把上一波定位到的 BenchmarkDotNet 并行运行冲突真正收口到 benchmark 入口层:`Program.cs` 现在支持 `--artifacts-suffix `,并在声明 suffix 时自动把当前 benchmark 运行重启到独立的 host 工作目录
+- 本轮继续沿用已复核的基线:`origin/main` 与本地 `main` 当前都在 `699d0b48`(`2026-05-09 18:39:38 +0800`);当前分支相对 `origin/main` 的累计 branch diff 仍约为 `9 files changed, 953 insertions(+), 83 deletions(-)`,离 `$gframework-batch-boot 50` 还有明显余量
+- 本轮写面收敛在 benchmark 入口与文档两处:`GFramework.Cqrs.Benchmarks/Program.cs` 与 `GFramework.Cqrs.Benchmarks/README.md`;没有扩散到 benchmark 业务逻辑文件、`GFramework.Cqrs` runtime 或测试项目
+- `Program.cs` 当前约定为:
+ - 解析并剥离仓库自定义参数 `--artifacts-suffix `,避免把它误传给 BenchmarkDotNet CLI
+ - 支持通过环境变量回退复用同一套 suffix / artifacts path 约定
+ - 当 suffix 存在时,先把当前 benchmark 宿主输出复制到 `BenchmarkDotNet.Artifacts//host/`,再从该隔离宿主目录重启运行,使 BenchmarkDotNet 自动生成的 `GFramework.Cqrs.Benchmarks-Job-*` 项目、`OutDir` 与最终 results 都落到 suffix 私有目录下
+- 并发 smoke 已直接证明这套隔离生效:`RequestLifetimeBenchmarks.SendRequest_*` 与 `StreamInvokerBenchmarks.Stream_*` 在两个终端并发 short-job 时,分别落到 `BenchmarkDotNet.Artifacts/req-lifetime-a/host/...` 与 `BenchmarkDotNet.Artifacts/stream-invoker-b/host/...`,不再共享同一 `.../bin/Release/net10.0/GFramework.Cqrs.Benchmarks-Job-JWUHXL-1/` 生成目录,也没有再出现 `.dll.config being used by another process`
+- `README` 已同步新的运行约定:当两个带 `--filter` 的 benchmark 需要并发执行时,必须为每个进程传入不同的 `--artifacts-suffix`;该约束只服务于本地输出隔离,不代表 benchmark 业务语义本身需要额外依赖
- 下一推荐步骤:
-- 下一批优先切到 `GFramework.Cqrs.Benchmarks` 的 benchmark-run/config 隔离层,避免两个过滤 benchmark 并行执行时再次共享自动生成目录或 artifacts 路径
-- 完成运行隔离后,再决定是否单开一波更稳定的 `StreamInvokerBenchmarks` `DrainAll` 复核,或继续把 scoped host 对照扩展到更多 stream 子场景
+- 若继续 benchmark 线,可重新利用新的并发运行隔离约定,单开一波更稳定的 `StreamInvokerBenchmarks` `DrainAll` 排序复核,或并行推进其他不冲突的 benchmark smoke
+- 若上下文预算仍允许,下一批更适合继续保持在 `GFramework.Cqrs.Benchmarks` 单模块,避免过早把 review 面重新扩到 runtime 或测试层
- 更早的 `RP-123` 及之前阶段细节以下方 trace 与归档为准,active 入口不再重复展开旧阶段流水。
- 当前分支相对 `origin/main` 的累计 branch diff 启动时为 `9 files`,仍明显低于 `$gframework-batch-boot 50` 的停止阈值;这一批继续保持单模块、低风险、可直接评审的 benchmark 边界
- 当前 `RP-113` 已继续沿用 `$gframework-batch-boot 50`,并把 notification 线从 benchmark 对照推进到实际 runtime 能力:新增公开内置 `TaskWhenAllNotificationPublisher`,让 `GFramework.Cqrs` 在保留默认顺序发布器的同时,提供与 `Mediator` `TaskWhenAllPublisher` 对齐的并行 notification publish 策略
@@ -159,6 +158,20 @@ CQRS 迁移与收敛。
## 最近权威验证
+- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
+ - 结果:通过,`0 warning / 0 error`
+ - 备注:覆盖 benchmark 入口 `--artifacts-suffix` 隔离实现、`README` 命令示例与 `ai-plan` 更新后的最小 Release build
+- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix req-lifetime-a --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
+ - 结果:通过
+ - 备注:本次 auto-generated benchmark 项目已在 `BenchmarkDotNet.Artifacts/req-lifetime-a/host/...` 下执行;`Singleton` 约为 baseline `5.189 ns`、`MediatR` `52.765 ns`、`GFramework.Cqrs` `60.938 ns`
+- `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix stream-invoker-b --filter "*StreamInvokerBenchmarks.Stream_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
+ - 结果:通过
+ - 备注:本次 auto-generated benchmark 项目已在 `BenchmarkDotNet.Artifacts/stream-invoker-b/host/...` 下执行;`DrainAll` 约为 baseline `77.82 ns`、generated `130.37 ns`、reflection `139.08 ns`、`MediatR` `245.23 ns`
+- `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Program.cs GFramework.Cqrs.Benchmarks/README.md ai-plan/public/cqrs-rewrite/todos/cqrs-rewrite-migration-tracking.md ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md`
+ - 结果:通过
+- `git --git-dir=/.git/worktrees/GFramework-cqrs --work-tree=. diff --check`
+ - 结果:通过
+
- `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
- 结果:通过,`0 warning / 0 error`
- 备注:覆盖 `BenchmarkHostFactory` scoped stream helper、`StreamLifetimeBenchmarks` scoped 生命周期矩阵与 `README` 同步后的最小 Release build
diff --git a/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md b/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md
index b48a495c..b7d9eec2 100644
--- a/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md
+++ b/ai-plan/public/cqrs-rewrite/traces/cqrs-rewrite-migration-trace.md
@@ -2,6 +2,38 @@
## 2026-05-11
+### 阶段:benchmark 并发运行隔离入口(CQRS-REWRITE-RP-131)
+
+- 延续 `$gframework-batch-boot 50`,在 `RP-130` 已确认冲突来自 BenchmarkDotNet 工件/生成目录层后,本轮继续保持写面只在 `GFramework.Cqrs.Benchmarks` 单模块,优先收口 benchmark 入口而不是回头改 benchmark 业务逻辑
+- 本轮主线程与 worker 边界:
+ - `README.md` 由 worker 独占,补 `--artifacts-suffix ` 的使用约定与两条并发 smoke 命令示例
+ - `Program.cs` 起初交给独立 worker,但其回传超时;主线程随后接管该单文件实现,并主动关闭未返回的 worker,避免继续消耗上下文预算
+- 本轮主线程关键修正:
+ - 首版只设置 `IConfig.ArtifactsPath`,虽然把结果导出目录隔离到了 `BenchmarkDotNet.Artifacts/`,但并行 smoke 立刻暴露出 auto-generated benchmark 项目仍写回共享 `bin/Release/net10.0/GFramework.Cqrs.Benchmarks-Job-JWUHXL-1/` 目录,`RequestLifetimeBenchmarks` 再次命中 `.dll.config being used by another process`
+ - 因此本轮把实现升级为“独立宿主目录重启”模型:当存在 `--artifacts-suffix` 时,`Program.cs` 会先把当前 benchmark 宿主输出复制到 `BenchmarkDotNet.Artifacts//host/`,再从该目录重新启动同一个程序集,并通过环境变量把隔离后的 artifacts path 传递给子进程
+ - 最终子进程里的 BenchmarkDotNet restore/build/output 路径均落到 `BenchmarkDotNet.Artifacts//host/GFramework.Cqrs.Benchmarks-Job-...`,从根上切断同名 job 目录在并发进程之间的共享
+- 本轮验证:
+ - `dotnet build GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release`
+ - 结果:通过,`0 warning / 0 error`
+ - 备注:中途曾引入 `MA0015`,已在同一轮把 `ThrowIfNull` 改成显式局部变量判空后清零
+ - `python3 scripts/license-header.py --check --paths GFramework.Cqrs.Benchmarks/Program.cs GFramework.Cqrs.Benchmarks/README.md`
+ - 结果:通过
+ - `git diff --check`
+ - 结果:通过
+ - `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix req-lifetime-a --filter "*RequestLifetimeBenchmarks.SendRequest_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
+ - 结果:通过
+ - 备注:实际执行路径已切到 `BenchmarkDotNet.Artifacts/req-lifetime-a/host/...`
+ - `dotnet run --project GFramework.Cqrs.Benchmarks/GFramework.Cqrs.Benchmarks.csproj -c Release --no-build -- --artifacts-suffix stream-invoker-b --filter "*StreamInvokerBenchmarks.Stream_*" --job short --warmupCount 1 --iterationCount 1 --launchCount 1`
+ - 结果:通过
+ - 备注:实际执行路径已切到 `BenchmarkDotNet.Artifacts/stream-invoker-b/host/...`
+ - 两条命令并发运行时未再出现 `.dll.config being used by another process`,说明冲突已经从入口层实质收口
+- 本轮结论:
+ - `--artifacts-suffix` 现在不只是“结果目录标签”,而是完整的并发运行隔离开关:它会同时隔离 BenchmarkDotNet 最终导出目录与 auto-generated benchmark 项目工作目录
+ - 这条修复只触及 benchmark 入口与文档,不影响 `Fixture`、generated registry、runtime 宿主或 benchmark 业务语义,因此评审面仍保持单模块、低风险边界
+- 下一恢复点:
+ - 若继续 benchmark 线,可直接复用新的并发隔离能力,同时跑互不冲突的 filtered short-job,而不必再人为串行化所有 BenchmarkDotNet smoke
+ - 优先候选仍是 `StreamInvokerBenchmarks` `DrainAll` 更稳定作业复核,或其他仅触达 `GFramework.Cqrs.Benchmarks` 的对照矩阵扩展
+
### 阶段:stream lifetime scoped 矩阵与 README 同步(CQRS-REWRITE-RP-130)
- 延续 `$gframework-batch-boot 50`,在上一波把 `StreamingBenchmarks`、`StreamInvokerBenchmarks` 与 `RequestLifetimeBenchmarks` 扩成双观测 / scoped-request 基线后,本轮先不回到 runtime,而是只补齐 `StreamLifetimeBenchmarks` 的真实 `Scoped` stream 生命周期矩阵,并同步 benchmark `README`