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`