test: add isolated backend integration evidence
This commit is contained in:
parent
86af47ad09
commit
b9c2a6b378
@ -440,7 +440,7 @@ git commit -m "refactor: enforce one top-level type per file"
|
||||
- Modify: selected performance tests to emit `SOP_METRIC key=value` through `ITestOutputHelper`
|
||||
- Create: `tests/BuildFlow.Workflow.Tests/Performance/OpenXmlMemoryProfileTests.cs`
|
||||
|
||||
- [ ] **Step 1: Add environment-driven isolated connection support**
|
||||
- [x] **Step 1: Add environment-driven isolated connection support**
|
||||
|
||||
Replace fixed IM test endpoints with these environment variables and retain current values only as local fallbacks:
|
||||
|
||||
@ -456,11 +456,11 @@ BUILDFLOW_FILE_S3_TEST_ACCESS_KEY
|
||||
BUILDFLOW_FILE_S3_TEST_SECRET_KEY
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add a real MinIO S3 integration test**
|
||||
- [x] **Step 2: Add a real MinIO S3 integration test**
|
||||
|
||||
The test must create a unique bucket, upload a non-empty object, download and byte-compare it, verify a zero-byte object, delete objects/bucket in `finally`, and emit object count/bytes as `SOP_METRIC`. It must fail rather than skip when its configured endpoint is unavailable.
|
||||
|
||||
- [ ] **Step 3: Add deterministic metric output**
|
||||
- [x] **Step 3: Add deterministic metric output**
|
||||
|
||||
Emit at least these values from passing tests:
|
||||
|
||||
@ -478,7 +478,7 @@ excel_export_rows
|
||||
|
||||
`OpenXmlMemoryProfileTests` writes a large export to a temporary file from an `IAsyncEnumerable<string>`, samples `GC.GetTotalMemory(false)` while writing, asserts a conservative bounded peak delta, emits the observed bytes, and always deletes the file.
|
||||
|
||||
- [ ] **Step 4: Add random-port Docker Compose infrastructure**
|
||||
- [x] **Step 4: Add random-port Docker Compose infrastructure**
|
||||
|
||||
Use pinned images already compatible with the repository:
|
||||
|
||||
@ -491,13 +491,13 @@ minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
|
||||
Publish container ports with Docker-assigned random loopback ports, add health checks, use test-only credentials, and name volumes inside the per-run Compose project so teardown removes only SOP resources.
|
||||
|
||||
- [ ] **Step 5: Start the compose stack manually and run infrastructure tests**
|
||||
- [x] **Step 5: Start the compose stack manually and run infrastructure tests**
|
||||
|
||||
Use a unique Compose project name, discover ports with `docker compose port`, export the variables above, then run PostgreSQL reliability/migration tests, Redis tests, IM SignalR integration tests, Monitoring UPSERT tests, and the MinIO test.
|
||||
|
||||
Expected: all PASS, zero skipped, and no access to developer databases or existing containers.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
- [x] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/backend-optimization-compose.yml tests
|
||||
|
||||
70
scripts/backend-optimization-compose.yml
Normal file
70
scripts/backend-optimization-compose.yml
Normal file
@ -0,0 +1,70 @@
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:0.8.0-pg17
|
||||
environment:
|
||||
POSTGRES_USER: buildflow
|
||||
POSTGRES_PASSWORD: buildflow
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- "127.0.0.1::5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U buildflow -d postgres"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
ports:
|
||||
- "127.0.0.1::6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
mongo:
|
||||
image: mongo:7
|
||||
environment:
|
||||
MONGO_INITDB_ROOT_USERNAME: buildflow
|
||||
MONGO_INITDB_ROOT_PASSWORD: buildflow
|
||||
ports:
|
||||
- "127.0.0.1::27017"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
mongosh --quiet --host 127.0.0.1 --port 27017
|
||||
-u buildflow -p buildflow --authenticationDatabase admin
|
||||
--eval "quit(db.adminCommand('ping').ok ? 0 : 1)"
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
|
||||
minio:
|
||||
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
MINIO_ROOT_USER: buildflowminio
|
||||
MINIO_ROOT_PASSWORD: buildflowminiosecret
|
||||
ports:
|
||||
- "127.0.0.1::9000"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:9000/minio/health/live >/dev/null"]
|
||||
interval: 2s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
redis-data:
|
||||
mongo-data:
|
||||
minio-data:
|
||||
@ -7,11 +7,12 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BuildFlow.File.Tests;
|
||||
|
||||
/// <summary>后台文件大小修复的结果语义、批量边界与 S3 并发上限测试。</summary>
|
||||
public class FileSizeVerificationProcessorTests
|
||||
public class FileSizeVerificationProcessorTests(ITestOutputHelper output)
|
||||
{
|
||||
private const string Bucket = "files";
|
||||
|
||||
@ -153,6 +154,7 @@ public class FileSizeVerificationProcessorTests
|
||||
|
||||
maximum.Should().BeGreaterThan(1);
|
||||
maximum.Should().BeLessThanOrEqualTo(3);
|
||||
output.WriteLine($"SOP_METRIC file_size_max_s3_concurrency={maximum}");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
using System.Text;
|
||||
using File.Infrastructure.Storage;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BuildFlow.File.Tests;
|
||||
|
||||
/// <summary>真实 S3 兼容存储往返测试,供隔离 MinIO SOP 阶段执行。</summary>
|
||||
public sealed class S3StorageServiceIntegrationTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
[Trait("Category", "External")]
|
||||
public async Task UploadDownloadAndZeroByteObjectsRoundTrip()
|
||||
{
|
||||
var endpoint = GetRequiredVariable("BUILDFLOW_FILE_S3_TEST_ENDPOINT");
|
||||
var accessKey = GetRequiredVariable("BUILDFLOW_FILE_S3_TEST_ACCESS_KEY");
|
||||
var secretKey = GetRequiredVariable("BUILDFLOW_FILE_S3_TEST_SECRET_KEY");
|
||||
var options = new Mock<IOptionsMonitor<S3Options>>();
|
||||
options.SetupGet(x => x.CurrentValue).Returns(new S3Options
|
||||
{
|
||||
Endpoint = endpoint,
|
||||
AccessKey = accessKey,
|
||||
SecretKey = secretKey,
|
||||
Region = "us-east-1",
|
||||
});
|
||||
var storage = new S3StorageService(options.Object);
|
||||
var bucket = $"buildflow-sop-{Guid.NewGuid():N}";
|
||||
var payloadKey = "payload.bin";
|
||||
var zeroByteKey = "empty.bin";
|
||||
var bucketCreated = false;
|
||||
|
||||
try
|
||||
{
|
||||
await storage.CreateBucketAsync(bucket, CancellationToken.None);
|
||||
bucketCreated = true;
|
||||
|
||||
var payload = Encoding.UTF8.GetBytes("BuildFlow isolated MinIO round trip");
|
||||
await using (var payloadStream = new MemoryStream(payload, writable: false))
|
||||
{
|
||||
await storage.UploadFileAsync(
|
||||
bucket,
|
||||
payloadKey,
|
||||
payloadStream,
|
||||
"application/octet-stream",
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
var download = await storage.DownloadFileAsync(bucket, payloadKey, CancellationToken.None);
|
||||
await using (download.Stream)
|
||||
await using (var downloaded = new MemoryStream())
|
||||
{
|
||||
await download.Stream.CopyToAsync(downloaded, CancellationToken.None);
|
||||
downloaded.ToArray().Should().Equal(payload);
|
||||
}
|
||||
download.ContentType.Should().Be("application/octet-stream");
|
||||
|
||||
await using (var emptyStream = new MemoryStream(Array.Empty<byte>(), writable: false))
|
||||
{
|
||||
await storage.UploadFileAsync(
|
||||
bucket,
|
||||
zeroByteKey,
|
||||
emptyStream,
|
||||
"application/octet-stream",
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
(await storage.ObjectExistsAsync(bucket, zeroByteKey, CancellationToken.None)).Should().BeTrue();
|
||||
(await storage.GetObjectSizeAsync(bucket, zeroByteKey, CancellationToken.None)).Should().Be(0);
|
||||
var listed = await storage.ListObjectsAsync(bucket, null, 100, null, CancellationToken.None);
|
||||
listed.Files.Select(x => x.Key).Should().BeEquivalentTo(payloadKey, zeroByteKey);
|
||||
|
||||
output.WriteLine($"SOP_METRIC s3_object_count={listed.Files.Count}");
|
||||
output.WriteLine($"SOP_METRIC s3_object_bytes={payload.Length}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (bucketCreated)
|
||||
{
|
||||
await storage.DeleteFileAsync(bucket, payloadKey, CancellationToken.None);
|
||||
await storage.DeleteFileAsync(bucket, zeroByteKey, CancellationToken.None);
|
||||
await storage.DeleteBucketAsync(bucket, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRequiredVariable(string name)
|
||||
=> Environment.GetEnvironmentVariable(name)
|
||||
?? throw new InvalidOperationException($"必须设置环境变量 {name} 才能运行真实 S3 集成测试。");
|
||||
}
|
||||
@ -41,10 +41,10 @@ namespace BuildFlow.IM.Integration.Tests;
|
||||
/// </remarks>
|
||||
public sealed class ImServerFixture : IAsyncLifetime
|
||||
{
|
||||
/// <summary>本地基础设施连接串。指向独立测试库,与开发库(im_system)隔离。</summary>
|
||||
private const string PostgresConnection = "Host=localhost;Port=5432;Database=im_system_test;Username=rag;Password=rag123";
|
||||
private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_system_test?authSource=admin";
|
||||
private const string RedisConnection = "localhost:6379,abortConnect=false";
|
||||
/// <summary>本地基础设施回退连接串。SOP 通过环境变量覆盖并指向随机端口容器。</summary>
|
||||
private const string DefaultPostgresConnection = "Host=localhost;Port=5432;Database=im_system_test;Username=rag;Password=rag123";
|
||||
private const string DefaultMongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_system_test?authSource=admin";
|
||||
private const string DefaultRedisConnection = "localhost:6379,abortConnect=false";
|
||||
|
||||
/// <summary>JWT 对称密钥,与 appsettings.json 中的 Jwt:SigningKey 保持一致,使 Hub 验签通过。</summary>
|
||||
private const string SigningKey = "RagJwtSecretKey2026MustBeAtLeast32CharsLong!";
|
||||
@ -78,15 +78,21 @@ public sealed class ImServerFixture : IAsyncLifetime
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
_redis = await ConnectionMultiplexer.ConnectAsync(RedisConnection);
|
||||
var postgresConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_POSTGRES_CONNECTION")
|
||||
?? DefaultPostgresConnection;
|
||||
var mongoConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_MONGO_CONNECTION")
|
||||
?? DefaultMongoConnection;
|
||||
var redisConnection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_REDIS_CONNECTION")
|
||||
?? DefaultRedisConnection;
|
||||
_redis = await ConnectionMultiplexer.ConnectAsync(redisConnection);
|
||||
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["ConnectionStrings:Default"] = PostgresConnection,
|
||||
["ConnectionStrings:MongoDb"] = MongoConnection,
|
||||
["ConnectionStrings:Default"] = postgresConnection,
|
||||
["ConnectionStrings:MongoDb"] = mongoConnection,
|
||||
});
|
||||
|
||||
var services = builder.Services;
|
||||
@ -95,12 +101,12 @@ public sealed class ImServerFixture : IAsyncLifetime
|
||||
services.TryAddSingleton<ICurrentUserContext, TestCurrentUserContext>();
|
||||
services.AddScoped<AuditInterceptor>();
|
||||
services.AddDbContext<ImDbContext>((sp, options) =>
|
||||
options.UseNpgsql(PostgresConnection)
|
||||
options.UseNpgsql(postgresConnection)
|
||||
.AddInterceptors(sp.GetRequiredService<AuditInterceptor>()));
|
||||
|
||||
// —— MongoDB:消息正文(MongoDB.Entities 静态 DB.Default)——
|
||||
var mongoDbName = MongoUrl.Create(MongoConnection).DatabaseName;
|
||||
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult();
|
||||
var mongoDbName = MongoUrl.Create(mongoConnection).DatabaseName;
|
||||
DB.InitAsync(mongoDbName, MongoClientSettings.FromConnectionString(mongoConnection)).GetAwaiter().GetResult();
|
||||
|
||||
// —— Redis:在线状态与未读计数的真实实现 ——
|
||||
services.AddSingleton<IConnectionMultiplexer>(_ => _redis!);
|
||||
|
||||
@ -20,15 +20,17 @@ namespace BuildFlow.IM.Tests.Chat;
|
||||
/// </summary>
|
||||
public class SendMessageCommandHandlerTests
|
||||
{
|
||||
/// <summary>本地测试 MongoDB 连接串(与集成测试共用,需本地 docker 起 mongo)。</summary>
|
||||
private const string MongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_unit_test?authSource=admin";
|
||||
/// <summary>本地 MongoDB 回退连接串;SOP 通过环境变量覆盖并指向随机端口容器。</summary>
|
||||
private const string DefaultMongoConnection = "mongodb://mongo:mongo123@localhost:27017/im_unit_test?authSource=admin";
|
||||
|
||||
static SendMessageCommandHandlerTests()
|
||||
{
|
||||
// MongoDB.Entities 用静态 DB.Default,进程内一次性初始化(幂等)。
|
||||
// 消息正文落真实 Mongo,但本测试仅断言未读数语义,不校验 Mongo 内容。
|
||||
var dbName = MongoUrl.Create(MongoConnection).DatabaseName!;
|
||||
DB.InitAsync(dbName, MongoClientSettings.FromConnectionString(MongoConnection)).GetAwaiter().GetResult();
|
||||
var connection = Environment.GetEnvironmentVariable("BUILDFLOW_IM_TEST_MONGO_CONNECTION")
|
||||
?? DefaultMongoConnection;
|
||||
var dbName = MongoUrl.Create(connection).DatabaseName!;
|
||||
DB.InitAsync(dbName, MongoClientSettings.FromConnectionString(connection)).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
private static readonly Guid SenderId = Guid.Parse("22222222-2222-2222-2222-222222222222");
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Text;
|
||||
using BuildFlow.Application.Chat.Commands;
|
||||
using BuildFlow.Application.Chat.Handlers;
|
||||
using BuildFlow.Application.Chat.Services;
|
||||
@ -12,11 +13,12 @@ using FluentAssertions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BuildFlow.Rag.Tests.Chat;
|
||||
|
||||
/// <summary>聊天生成上下文的消息数、Token 和三条生成路径复用测试。</summary>
|
||||
public sealed class ChatContextWindowBuilderTests
|
||||
public sealed class ChatContextWindowBuilderTests(ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task ReturnsNewestMessagesWithinMessageLimitInChronologicalOrder()
|
||||
@ -36,6 +38,11 @@ public sealed class ChatContextWindowBuilderTests
|
||||
"message-098",
|
||||
"message-099",
|
||||
"message-100");
|
||||
var estimatedTokens = result.Sum(message => Math.Max(
|
||||
1,
|
||||
(Encoding.UTF8.GetByteCount(message.Text) + 3) / 4));
|
||||
output.WriteLine($"SOP_METRIC rag_context_message_count={result.Count}");
|
||||
output.WriteLine($"SOP_METRIC rag_context_estimated_tokens={estimatedTokens}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -4,12 +4,15 @@ using FluentAssertions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StackExchange.Redis;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BuildFlow.Rag.Tests.Chat;
|
||||
|
||||
/// <summary>Redis List 聊天缓存的并发追加、传输粒度、顺序和 TTL 集成测试。</summary>
|
||||
[Collection(RedisChatMessageCacheCollection.Name)]
|
||||
public sealed class RedisChatMessageCacheTests(RedisChatMessageCacheFixture fixture)
|
||||
public sealed class RedisChatMessageCacheTests(
|
||||
RedisChatMessageCacheFixture fixture,
|
||||
ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task ConcurrentAppendsPreserveEveryMessage()
|
||||
@ -32,8 +35,10 @@ public sealed class RedisChatMessageCacheTests(RedisChatMessageCacheFixture fixt
|
||||
storedMessages.Select(x => x.Id).Should().OnlyHaveUniqueItems();
|
||||
(await database.KeyTypeAsync(RedisChatMessageCacheFixture.Key(conversationId)))
|
||||
.Should().Be(RedisType.List);
|
||||
(await database.ListLengthAsync(RedisChatMessageCacheFixture.Key(conversationId)))
|
||||
.Should().Be(100);
|
||||
var listLength = await database.ListLengthAsync(RedisChatMessageCacheFixture.Key(conversationId));
|
||||
listLength.Should().Be(100);
|
||||
output.WriteLine($"SOP_METRIC redis_concurrent_append_count={messages.Length}");
|
||||
output.WriteLine($"SOP_METRIC redis_list_length={listLength}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -6,6 +6,7 @@ using Workflow.Application.Form.Aggregates;
|
||||
using Workflow.Domain.Entities;
|
||||
using Workflow.Domain.Enums;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BuildFlow.Workflow.Tests.Performance;
|
||||
|
||||
@ -13,7 +14,9 @@ namespace BuildFlow.Workflow.Tests.Performance;
|
||||
/// 表单聚合刷新查询次数门禁,避免目标行和统计字段触发 N+1。
|
||||
/// </summary>
|
||||
[Collection(PostgresWorkflowCollection.Name)]
|
||||
public class FormAggregateQueryCountTests(PostgresWorkflowFixture fixture)
|
||||
public class FormAggregateQueryCountTests(
|
||||
PostgresWorkflowFixture fixture,
|
||||
ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task AggregateRefreshUsesConstantSelectCountForManyRowsAndFields()
|
||||
@ -80,6 +83,7 @@ public class FormAggregateQueryCountTests(PostgresWorkflowFixture fixture)
|
||||
await service.RefreshAsync(targetFormId, instanceId, CancellationToken.None);
|
||||
|
||||
interceptor.SelectCount.Should().Be(4);
|
||||
output.WriteLine($"SOP_METRIC form_aggregate_select_count={interceptor.SelectCount}");
|
||||
}
|
||||
|
||||
await using var verify = fixture.CreateDbContext();
|
||||
|
||||
@ -9,6 +9,7 @@ using Workflow.Application.Form.Derivation;
|
||||
using Workflow.Domain.Entities;
|
||||
using Workflow.Domain.Enums;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BuildFlow.Workflow.Tests.Performance;
|
||||
|
||||
@ -16,7 +17,9 @@ namespace BuildFlow.Workflow.Tests.Performance;
|
||||
/// 导入任务有界批次事务和取消恢复测试。
|
||||
/// </summary>
|
||||
[Collection(PostgresWorkflowCollection.Name)]
|
||||
public class ImportJobBatchingTests(PostgresWorkflowFixture fixture)
|
||||
public class ImportJobBatchingTests(
|
||||
PostgresWorkflowFixture fixture,
|
||||
ITestOutputHelper output)
|
||||
{
|
||||
[Fact]
|
||||
public async Task FourHundredOneRowsUseThreeBatchCommitsAndOneCompletionCommit()
|
||||
@ -41,6 +44,7 @@ public class ImportJobBatchingTests(PostgresWorkflowFixture fixture)
|
||||
CancellationToken.None);
|
||||
|
||||
transactions.CommitCount.Should().Be(4);
|
||||
output.WriteLine($"SOP_METRIC import_commit_count={transactions.CommitCount}");
|
||||
await using var verify = fixture.CreateDbContext();
|
||||
var job = await verify.BuildFlowImportJobs.SingleAsync(x => x.Id == seeded.JobId);
|
||||
job.Status.Should().Be(BuildFlowImportJobStatus.Success);
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using FluentAssertions;
|
||||
using Workflow.Application.BuildFlow.Common.Services;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace BuildFlow.Workflow.Tests.Performance;
|
||||
|
||||
/// <summary>OpenXML 顺序导出的托管内存峰值门禁。</summary>
|
||||
public sealed class OpenXmlMemoryProfileTests(ITestOutputHelper output)
|
||||
{
|
||||
private const int ExportRows = 50_000;
|
||||
private const long MaxPeakDeltaBytes = 128L * 1024 * 1024;
|
||||
|
||||
[Fact]
|
||||
public async Task LargeAsyncExportKeepsManagedMemoryBounded()
|
||||
{
|
||||
var filePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"buildflow-openxml-memory-{Guid.NewGuid():N}.xlsx");
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
GC.Collect();
|
||||
var baselineBytes = GC.GetTotalMemory(true);
|
||||
var peakBytes = baselineBytes;
|
||||
|
||||
void SampleManagedMemory()
|
||||
{
|
||||
peakBytes = Math.Max(peakBytes, GC.GetTotalMemory(false));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var service = new FormSchemaExcelService();
|
||||
await using var file = new FileStream(
|
||||
filePath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
81_920,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
await service.WriteExportAsync(
|
||||
SchemaJson,
|
||||
"内存门禁",
|
||||
CreateRowsAsync(ExportRows, SampleManagedMemory),
|
||||
file,
|
||||
CancellationToken.None);
|
||||
await file.FlushAsync(CancellationToken.None);
|
||||
SampleManagedMemory();
|
||||
|
||||
new FileInfo(filePath).Length.Should().BeGreaterThan(0);
|
||||
var peakDeltaBytes = Math.Max(0, peakBytes - baselineBytes);
|
||||
peakDeltaBytes.Should().BeLessThan(
|
||||
MaxPeakDeltaBytes,
|
||||
"OpenXML 导出应按行顺序写入,不能把全部 JSON 行或工作表加载到内存");
|
||||
output.WriteLine($"SOP_METRIC excel_export_peak_managed_bytes={peakDeltaBytes}");
|
||||
output.WriteLine($"SOP_METRIC excel_export_rows={ExportRows}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<string> CreateRowsAsync(
|
||||
int count,
|
||||
Action sampleManagedMemory,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (index % 128 == 0)
|
||||
{
|
||||
sampleManagedMemory();
|
||||
}
|
||||
|
||||
yield return $$"""{"id":{{index}},"name":"row-{{index}}","active":{{(index % 2 == 0).ToString().ToLowerInvariant()}}}""";
|
||||
if (index % 1024 == 0)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
sampleManagedMemory();
|
||||
}
|
||||
|
||||
private const string SchemaJson = """
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "number", "title": "编号", "x-component": "InputNumber" },
|
||||
"name": { "type": "string", "title": "名称", "x-component": "Input" },
|
||||
"active": { "type": "boolean", "title": "启用", "x-component": "Switch" }
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user