diff --git a/.github/workflows/db-perf-manual.yml b/.github/workflows/db-perf-manual.yml new file mode 100644 index 0000000..ed1cb27 --- /dev/null +++ b/.github/workflows/db-perf-manual.yml @@ -0,0 +1,227 @@ +name: DB Perf Tests (Manual) + +on: + workflow_dispatch: + inputs: + provider: + description: "Database provider to benchmark" + required: true + default: both + type: choice + options: + - both + - sqlite + - postgres + pull_request: + branches: [main] + paths: + - '.github/workflows/db-perf-manual.yml' + - 'src/Agoda.DevExTelemetry.DbPerfTests/**' + - 'src/Agoda.DevExTelemetry.Core/**' + - 'src/Agoda.DevExTelemetry.WebApi/**' + - 'docs/db-perf-tests.md' + +permissions: + contents: read + actions: read + pull-requests: write + +jobs: + sqlite: + if: ${{ github.event_name == 'pull_request' || github.event.inputs.provider == 'both' || github.event.inputs.provider == 'sqlite' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Restore + run: dotnet restore src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj + - name: Run SQLite perf tests + env: + PERF_DB_PROVIDER: sqlite + run: dotnet test src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj --configuration Release --logger "trx;LogFileName=sqlite-perf.trx" + - name: Upload SQLite artifacts + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: sqlite-db-perf-results + path: | + **/db-perf-results/*.json + **/sqlite-perf.trx + + postgres: + if: ${{ github.event_name == 'pull_request' || github.event.inputs.provider == 'both' || github.event.inputs.provider == 'postgres' }} + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Restore + run: dotnet restore src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj + - name: Run PostgreSQL perf tests + env: + PERF_DB_PROVIDER: postgres + PERF_POSTGRES_CONNECTION: Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres + run: dotnet test src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj --configuration Release --logger "trx;LogFileName=postgres-perf.trx" + - name: Upload PostgreSQL artifacts + uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: postgres-db-perf-results + path: | + **/db-perf-results/*.json + **/postgres-perf.trx + + pr-comment: + if: ${{ github.event_name == 'pull_request' }} + needs: [sqlite, postgres] + runs-on: ubuntu-latest + steps: + - name: Download current run SQLite artifact + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: sqlite-db-perf-results + path: artifacts/current/sqlite + + - name: Download current run PostgreSQL artifact + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: postgres-db-perf-results + path: artifacts/current/postgres + + - name: Build performance comment body + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + WORKFLOW_FILE: db-perf-manual.yml + CURRENT_RUN_ID: ${{ github.run_id }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + python3 - <<'PY' + import os, json, re, requests, zipfile, io + from pathlib import Path + + token = os.environ['GITHUB_TOKEN'] + repo = os.environ['REPO'] + workflow_file = os.environ['WORKFLOW_FILE'] + run_id = int(os.environ['CURRENT_RUN_ID']) + pr_number = int(os.environ['PR_NUMBER']) + api = 'https://api.github.com' + H = {'Authorization': f'Bearer {token}', 'Accept': 'application/vnd.github+json'} + + def load_current_results(base_dir): + results = {} + for p in Path(base_dir).rglob('endpoint-timings-*.json'): + data = json.loads(p.read_text()) + for row in data: + key = (row['Engine'], row['Endpoint']) + results[key] = row + return results + + def find_latest_main_success_run_id(): + url = f"{api}/repos/{repo}/actions/workflows/{workflow_file}/runs" + params = {'branch': 'main', 'status': 'completed', 'per_page': 20} + r = requests.get(url, headers=H, params=params, timeout=30) + if r.status_code != 200: + return None + runs = r.json().get('workflow_runs', []) + for run in runs: + if run.get('conclusion') == 'success': + rid = run.get('id') + if rid and rid != run_id: + return rid + return None + + def download_artifact_json_map(run_id, artifact_name): + url = f"{api}/repos/{repo}/actions/runs/{run_id}/artifacts" + r = requests.get(url, headers=H, timeout=30) + if r.status_code != 200: + return {} + artifacts = r.json().get('artifacts', []) + target = next((a for a in artifacts if a.get('name') == artifact_name), None) + if not target: + return {} + zurl = f"{api}/repos/{repo}/actions/artifacts/{target['id']}/zip" + zr = requests.get(zurl, headers=H, timeout=60) + if zr.status_code != 200: + return {} + out = {} + with zipfile.ZipFile(io.BytesIO(zr.content)) as zf: + for name in zf.namelist(): + if 'endpoint-timings-' in name and name.endswith('.json'): + data = json.loads(zf.read(name)) + for row in data: + out[(row['Engine'], row['Endpoint'])] = row + return out + + current = {} + current.update(load_current_results('artifacts/current/sqlite')) + current.update(load_current_results('artifacts/current/postgres')) + + baseline_run = find_latest_main_success_run_id() + baseline = {} + if baseline_run: + baseline.update(download_artifact_json_map(baseline_run, 'sqlite-db-perf-results')) + baseline.update(download_artifact_json_map(baseline_run, 'postgres-db-perf-results')) + + slug = '' + lines = [slug, '## DB Perf Report', ''] + lines.append(f"Run: `{run_id}`") + if baseline_run: + lines.append(f"Baseline (latest successful main run): `{baseline_run}`") + else: + lines.append('Baseline: _not available yet (first run or no successful main artifact found)_') + lines.append('') + + if not current: + lines.append('_No endpoint timing JSON artifacts found in this run._') + else: + lines.append('| Engine | Endpoint | Mean ms | p95 ms | Δ p95 vs main |') + lines.append('|---|---|---:|---:|---:|') + for key in sorted(current.keys()): + row = current[key] + mean_ms = row.get('MeanMs', 0) + p95_ms = row.get('P95Ms', 0) + delta = 'n/a' + b = baseline.get(key) + if b and b.get('P95Ms'): + bp95 = b['P95Ms'] + if bp95: + pct = ((p95_ms - bp95) / bp95) * 100 + delta = f"{pct:+.1f}%" + lines.append(f"| {row['Engine']} | `{row['Endpoint']}` | {mean_ms:.2f} | {p95_ms:.2f} | {delta} |") + + body = '\n'.join(lines) + + comments_url = f"{api}/repos/{repo}/issues/{pr_number}/comments" + existing = requests.get(comments_url, headers=H, timeout=30).json() + target = None + for c in existing: + if isinstance(c.get('body'), str) and slug in c['body']: + target = c + break + + if target: + u = f"{api}/repos/{repo}/issues/comments/{target['id']}" + requests.patch(u, headers=H, json={'body': body}, timeout=30) + else: + requests.post(comments_url, headers=H, json={'body': body}, timeout=30) + PY diff --git a/README.md b/README.md index 10617a3..bd2053f 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,10 @@ This is useful when DNS changes aren’t available yet. Your IT support team can See [docs/deployment-scenarios.md](docs/deployment-scenarios.md) for concrete deployment topologies, client routing examples, and markdown diagrams. +## DB Performance Testing + +See [docs/db-perf-tests.md](docs/db-perf-tests.md) for manual DB performance test workflow, seed controls, and artifact outputs. + ## Development ### Prerequisites diff --git a/docs/db-perf-tests.md b/docs/db-perf-tests.md new file mode 100644 index 0000000..3a7baff --- /dev/null +++ b/docs/db-perf-tests.md @@ -0,0 +1,57 @@ +# DB Performance Tests (Manual) + +This project adds a dedicated performance harness for dashboard read endpoints. + +Project: +- `src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj` + +Workflow: +- `.github/workflows/db-perf-manual.yml` +- Triggered manually via **workflow_dispatch** +- Supports `sqlite`, `postgres`, or `both` + +## What it does + +1. Boots the API with test host (`WebApplicationFactory`) +2. Verifies app startup (`/api/health`) +3. Seeds deterministic randomized data with configurable cardinality +4. Runs warm-up calls +5. Measures endpoint timings for selected dashboard endpoints +6. Produces JSON artifact per DB engine with mean/p50/p95/p99 + +## Local run examples + +### SQLite + +```bash +PERF_DB_PROVIDER=sqlite \ + dotnet test src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj +``` + +### PostgreSQL (external/local) + +```bash +PERF_DB_PROVIDER=postgres \ +PERF_POSTGRES_CONNECTION='Host=localhost;Port=5432;Database=postgres;Username=postgres;Password=postgres' \ + dotnet test src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj +``` + +## Optional seed overrides + +- `PERF_BUILD_METRICS` (default: 20000) +- `PERF_TEST_RUNS` (default: 10000) +- `PERF_TEST_CASES_PER_RUN` (default: 8) +- `PERF_RAW_PAYLOADS` (default: 10000) + +## Output + +JSON timing files are written under test work directory: +- `db-perf-results/endpoint-timings-sqlite.json` +- `db-perf-results/endpoint-timings-postgresql.json` + +CI uploads these JSON files as workflow artifacts. + +## Notes + +- This is currently manual-only (not nightly, not PR-blocking). +- Relative gating/delta comparison can be added after stabilization. diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj b/src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj new file mode 100644 index 0000000..6229687 --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/Agoda.DevExTelemetry.DbPerfTests.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/DatabaseProvider.cs b/src/Agoda.DevExTelemetry.DbPerfTests/DatabaseProvider.cs new file mode 100644 index 0000000..0cead14 --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/DatabaseProvider.cs @@ -0,0 +1,7 @@ +namespace Agoda.DevExTelemetry.DbPerfTests; + +public enum DatabaseProvider +{ + Sqlite, + PostgreSql +} diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/EndpointPerformanceTests.cs b/src/Agoda.DevExTelemetry.DbPerfTests/EndpointPerformanceTests.cs new file mode 100644 index 0000000..c5e1396 --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/EndpointPerformanceTests.cs @@ -0,0 +1,134 @@ +using System.Diagnostics; +using System.Text.Json; +using NUnit.Framework; +using Shouldly; + +namespace Agoda.DevExTelemetry.DbPerfTests; + +[TestFixture] +public class EndpointPerformanceTests +{ + private static readonly string[] EndpointPaths = + [ + "/api/build-metrics?page=1&pageSize=50&environment=all", + "/api/test-runs?page=1&pageSize=50&environment=all", + "/api/build-metrics/api-summary?environment=all", + "/api/build-metrics/clientside-summary?environment=all", + "/api/test-runs/summary?environment=all" + ]; + + [TestCase(DatabaseProvider.Sqlite)] + [TestCase(DatabaseProvider.PostgreSql)] + public async Task Should_seed_and_measure_dashboard_endpoints(DatabaseProvider provider) + { + var targetProvider = ResolveProviderFromEnvironment(provider); + if (targetProvider != provider) + { + Assert.Ignore($"Skipping {provider}. PERF_DB_PROVIDER={Environment.GetEnvironmentVariable("PERF_DB_PROVIDER")}"); + return; + } + + using var factory = new PerfWebApplicationFactory(provider); + using var client = factory.CreateClient(); + + var health = await client.GetAsync("/api/health"); + health.EnsureSuccessStatusCode(); + + var options = new PerfSeedOptions( + BuildMetrics: GetIntEnv("PERF_BUILD_METRICS", 20_000), + TestRuns: GetIntEnv("PERF_TEST_RUNS", 10_000), + TestCasesPerRun: GetIntEnv("PERF_TEST_CASES_PER_RUN", 8), + RawPayloads: GetIntEnv("PERF_RAW_PAYLOADS", 10_000)); + + await factory.SeedIfNeededAsync(options); + + // warm-up + foreach (var endpoint in EndpointPaths) + { + for (var i = 0; i < 2; i++) + { + var warmup = await client.GetAsync(endpoint); + warmup.EnsureSuccessStatusCode(); + } + } + + var results = new List(); + foreach (var endpoint in EndpointPaths) + { + var timings = new List(); + const int iterations = 10; + + for (var i = 0; i < iterations; i++) + { + var sw = Stopwatch.StartNew(); + var response = await client.GetAsync(endpoint); + sw.Stop(); + + response.EnsureSuccessStatusCode(); + timings.Add(sw.Elapsed.TotalMilliseconds); + } + + timings.Sort(); + var result = new EndpointTimingResult( + Engine: provider.ToString(), + Endpoint: endpoint, + Iterations: timings.Count, + MeanMs: timings.Average(), + P50Ms: Percentile(timings, 0.50), + P95Ms: Percentile(timings, 0.95), + P99Ms: Percentile(timings, 0.99), + CollectedAtUtc: DateTime.UtcNow); + + results.Add(result); + + // Non-strict guardrail: endpoint should not be catastrophically slow in perf harness. + result.P95Ms.ShouldBeLessThan(10_000); + } + + var outputDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "db-perf-results"); + Directory.CreateDirectory(outputDir); + var outputFile = Path.Combine(outputDir, $"endpoint-timings-{provider.ToString().ToLowerInvariant()}.json"); + await File.WriteAllTextAsync(outputFile, JsonSerializer.Serialize(results, new JsonSerializerOptions + { + WriteIndented = true + })); + + TestContext.AddTestAttachment(outputFile, $"Endpoint timing results for {provider}"); + } + + private static DatabaseProvider ResolveProviderFromEnvironment(DatabaseProvider fallback) + { + var env = Environment.GetEnvironmentVariable("PERF_DB_PROVIDER"); + if (string.IsNullOrWhiteSpace(env)) + return fallback; + + return env.Trim().ToLowerInvariant() switch + { + "sqlite" => DatabaseProvider.Sqlite, + "postgres" => DatabaseProvider.PostgreSql, + "postgresql" => DatabaseProvider.PostgreSql, + _ => fallback + }; + } + + private static int GetIntEnv(string name, int @default) + { + var env = Environment.GetEnvironmentVariable(name); + return int.TryParse(env, out var parsed) && parsed > 0 ? parsed : @default; + } + + private static double Percentile(IReadOnlyList sortedValues, double percentile) + { + if (sortedValues.Count == 0) + return 0; + + var rank = percentile * (sortedValues.Count - 1); + var lower = (int)Math.Floor(rank); + var upper = (int)Math.Ceiling(rank); + if (lower == upper) + return sortedValues[lower]; + + var weight = rank - lower; + return sortedValues[lower] * (1 - weight) + sortedValues[upper] * weight; + } +} diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/EndpointTimingResult.cs b/src/Agoda.DevExTelemetry.DbPerfTests/EndpointTimingResult.cs new file mode 100644 index 0000000..b13c18d --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/EndpointTimingResult.cs @@ -0,0 +1,11 @@ +namespace Agoda.DevExTelemetry.DbPerfTests; + +public record EndpointTimingResult( + string Engine, + string Endpoint, + int Iterations, + double MeanMs, + double P50Ms, + double P95Ms, + double P99Ms, + DateTime CollectedAtUtc); diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/PerfDataSeeder.cs b/src/Agoda.DevExTelemetry.DbPerfTests/PerfDataSeeder.cs new file mode 100644 index 0000000..5d62517 --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/PerfDataSeeder.cs @@ -0,0 +1,146 @@ +using Agoda.DevExTelemetry.Core.Data; +using Agoda.DevExTelemetry.Core.Models.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Agoda.DevExTelemetry.DbPerfTests; + +public static class PerfDataSeeder +{ + public static async Task SeedAsync(TelemetryDbContext db, PerfSeedOptions options, CancellationToken ct = default) + { + if (await db.BuildMetrics.AnyAsync(ct) || await db.TestRuns.AnyAsync(ct)) + return; + + db.ChangeTracker.AutoDetectChangesEnabled = false; + + var random = new Random(options.RandomSeed); + var projects = Enumerable.Range(1, options.ProjectCardinality).Select(i => $"project-{i:D3}").ToArray(); + var repos = Enumerable.Range(1, options.RepositoryCardinality).Select(i => $"repo-{i:D3}").ToArray(); + var branches = Enumerable.Range(1, options.BranchCardinality).Select(i => $"feature/{i:D4}").ToArray(); + var platforms = Enumerable.Range(1, options.PlatformCardinality).Select(i => $"platform-{i:D2}").ToArray(); + var metricTypes = new[] { ".Net", ".AspNetStartup", ".AspNetResponse", "vite", "webpack" }; + var buildCategories = new[] { "API", "Clientside" }; + var testRunners = new[] { "NUnit", "xUnit", "Jest", "Vitest" }; + var statuses = new[] { "Passed", "Failed", "Skipped" }; + var environments = new[] { "Local", "CI" }; + + var start = DateTime.UtcNow.Date.AddDays(-30); + + const int batchSize = 1000; + + for (var i = 0; i < options.BuildMetrics; i++) + { + var ts = start.AddMinutes(random.Next(0, 30 * 24 * 60)); + var category = buildCategories[random.Next(buildCategories.Length)]; + var metricType = category == "API" + ? metricTypes[random.Next(0, 3)] + : metricTypes[random.Next(3, 5)]; + + db.BuildMetrics.Add(new BuildMetric + { + Id = $"bm-{i:D8}", + ReceivedAt = ts, + UserName = $"user-{random.Next(1, 200):D3}", + CpuCount = random.Next(4, 17), + Hostname = $"host-{random.Next(1, 80):D3}", + Platform = platforms[random.Next(platforms.Length)], + Os = random.Next(2) == 0 ? "Windows" : "Linux", + Branch = branches[random.Next(branches.Length)], + ProjectName = projects[random.Next(projects.Length)], + Repository = repos[random.Next(repos.Length)], + RepositoryName = repos[random.Next(repos.Length)], + TimeTakenMs = random.Next(100, 20000), + MetricType = metricType, + BuildCategory = category, + ReloadType = random.Next(2) == 0 ? "hot" : "full", + ToolVersion = "1.0.0", + CommitSha = Guid.NewGuid().ToString("N")[..8], + IsDebuggerAttached = random.Next(10) == 0, + ExecutionEnvironment = environments[random.Next(environments.Length)], + SourceEndpoint = category == "API" ? "/dotnet" : "/vite", + ExtraData = null + }); + + if (i % batchSize == 0 && i > 0) + await db.SaveChangesAsync(ct); + } + + await db.SaveChangesAsync(ct); + + for (var i = 0; i < options.TestRuns; i++) + { + var ts = start.AddMinutes(random.Next(0, 30 * 24 * 60)); + var runId = $"tr-{i:D8}"; + var totalTests = options.TestCasesPerRun; + var failed = random.Next(0, 3); + var skipped = random.Next(0, 2); + var passed = Math.Max(0, totalTests - failed - skipped); + + var run = new TestRun + { + Id = runId, + RunId = Guid.NewGuid().ToString("N"), + ReceivedAt = ts, + UserName = $"user-{random.Next(1, 200):D3}", + CpuCount = random.Next(4, 17), + Hostname = $"host-{random.Next(1, 80):D3}", + Platform = platforms[random.Next(platforms.Length)], + Os = random.Next(2) == 0 ? "Windows" : "Linux", + Branch = branches[random.Next(branches.Length)], + ProjectName = projects[random.Next(projects.Length)], + Repository = repos[random.Next(repos.Length)], + RepositoryName = repos[random.Next(repos.Length)], + TestRunner = testRunners[random.Next(testRunners.Length)], + IsDebuggerAttached = random.Next(10) == 0, + ExecutionEnvironment = environments[random.Next(environments.Length)], + TotalTests = totalTests, + PassedTests = passed, + FailedTests = failed, + SkippedTests = skipped, + TotalDurationMs = random.Next(1000, 180000), + SourceEndpoint = "/dotnet/nunit" + }; + + db.TestRuns.Add(run); + + for (var t = 0; t < options.TestCasesPerRun; t++) + { + var status = statuses[random.Next(statuses.Length)]; + db.TestCases.Add(new TestCase + { + TestRunId = runId, + OriginalId = Guid.NewGuid().ToString("N"), + Name = $"Test_{t:D3}", + FullName = $"{run.ProjectName}.Spec.Test_{t:D3}", + ClassName = $"{run.ProjectName}.Spec", + MethodName = $"Test_{t:D3}", + Status = status, + DurationMs = random.Next(5, 5000), + ErrorMessage = status == "Failed" ? "Randomized failure for perf seed" : null + }); + } + + if (i % batchSize == 0 && i > 0) + await db.SaveChangesAsync(ct); + } + + await db.SaveChangesAsync(ct); + + for (var i = 0; i < options.RawPayloads; i++) + { + db.RawPayloads.Add(new RawPayload + { + ReceivedAt = start.AddMinutes(random.Next(0, 30 * 24 * 60)), + Endpoint = random.Next(2) == 0 ? "/dotnet" : "/vite", + ContentType = "application/json", + PayloadJson = "{\"seed\":true}" + }); + + if (i % batchSize == 0 && i > 0) + await db.SaveChangesAsync(ct); + } + + await db.SaveChangesAsync(ct); + db.ChangeTracker.AutoDetectChangesEnabled = true; + } +} diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/PerfSeedOptions.cs b/src/Agoda.DevExTelemetry.DbPerfTests/PerfSeedOptions.cs new file mode 100644 index 0000000..18befb1 --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/PerfSeedOptions.cs @@ -0,0 +1,12 @@ +namespace Agoda.DevExTelemetry.DbPerfTests; + +public record PerfSeedOptions( + int BuildMetrics = 20000, + int TestRuns = 10000, + int TestCasesPerRun = 8, + int RawPayloads = 10000, + int ProjectCardinality = 120, + int RepositoryCardinality = 80, + int BranchCardinality = 240, + int PlatformCardinality = 6, + int RandomSeed = 424242); diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/PerfWebApplicationFactory.cs b/src/Agoda.DevExTelemetry.DbPerfTests/PerfWebApplicationFactory.cs new file mode 100644 index 0000000..0845ab5 --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/PerfWebApplicationFactory.cs @@ -0,0 +1,84 @@ +using Agoda.DevExTelemetry.Core.Data; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Agoda.DevExTelemetry.DbPerfTests; + +public sealed class PerfWebApplicationFactory : WebApplicationFactory +{ + private readonly DatabaseProvider _provider; + private SqliteConnection? _sqliteConnection; + private string? _pgDatabaseName; + + public PerfWebApplicationFactory(DatabaseProvider provider) + { + _provider = provider; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + if (_provider == DatabaseProvider.Sqlite) + { + _sqliteConnection = new SqliteConnection("Data Source=:memory:"); + _sqliteConnection.Open(); + } + else + { + _pgDatabaseName = $"perf_{Guid.NewGuid():N}"; + PostgresPerfServer.CreateDatabaseAsync(_pgDatabaseName).GetAwaiter().GetResult(); + } + + builder.ConfigureServices(services => + { + var efDescriptors = services + .Where(d => + d.ServiceType.Namespace?.StartsWith("Microsoft.EntityFrameworkCore") == true || + d.ServiceType == typeof(DbContextOptions) || + d.ServiceType == typeof(TelemetryDbContext)) + .ToList(); + foreach (var d in efDescriptors) + services.Remove(d); + + services.RemoveAll(); + + if (_provider == DatabaseProvider.Sqlite) + { + services.AddDbContext(o => o.UseSqlite(_sqliteConnection!)); + services.AddScoped(); + } + else + { + var connectionString = PostgresPerfServer.GetConnectionStringAsync(_pgDatabaseName!).GetAwaiter().GetResult(); + services.AddDbContext(o => o.UseNpgsql(connectionString)); + services.AddScoped(); + } + }); + + builder.UseEnvironment("Development"); + } + + public async Task SeedIfNeededAsync(PerfSeedOptions options, CancellationToken ct = default) + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(ct); + await PerfDataSeeder.SeedAsync(db, options, ct); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) + return; + + _sqliteConnection?.Dispose(); + + if (_pgDatabaseName != null) + PostgresPerfServer.DropDatabaseAsync(_pgDatabaseName).GetAwaiter().GetResult(); + } +} diff --git a/src/Agoda.DevExTelemetry.DbPerfTests/PostgresPerfServer.cs b/src/Agoda.DevExTelemetry.DbPerfTests/PostgresPerfServer.cs new file mode 100644 index 0000000..78d42e5 --- /dev/null +++ b/src/Agoda.DevExTelemetry.DbPerfTests/PostgresPerfServer.cs @@ -0,0 +1,80 @@ +using Npgsql; +using Testcontainers.PostgreSql; + +namespace Agoda.DevExTelemetry.DbPerfTests; + +public static class PostgresPerfServer +{ + private static PostgreSqlContainer? _container; + private static readonly SemaphoreSlim Semaphore = new(1, 1); + + public static async Task GetAdminConnectionStringAsync() + { + var envConnection = Environment.GetEnvironmentVariable("PERF_POSTGRES_CONNECTION"); + if (!string.IsNullOrWhiteSpace(envConnection)) + return envConnection; + + await EnsureStartedAsync(); + return _container!.GetConnectionString(); + } + + public static async Task GetConnectionStringAsync(string databaseName) + { + var admin = await GetAdminConnectionStringAsync(); + var builder = new NpgsqlConnectionStringBuilder(admin) + { + Database = databaseName + }; + return builder.ConnectionString; + } + + public static async Task CreateDatabaseAsync(string databaseName) + { + var admin = await GetAdminConnectionStringAsync(); + await using var conn = new NpgsqlConnection(admin); + await conn.OpenAsync(); + await using var cmd = conn.CreateCommand(); + cmd.CommandText = $"CREATE DATABASE \"{databaseName}\""; + await cmd.ExecuteNonQueryAsync(); + } + + public static async Task DropDatabaseAsync(string databaseName) + { + var admin = await GetAdminConnectionStringAsync(); + await using var conn = new NpgsqlConnection(admin); + await conn.OpenAsync(); + + await using var terminateCmd = conn.CreateCommand(); + terminateCmd.CommandText = $""" + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '{databaseName}' AND pid <> pg_backend_pid() + """; + await terminateCmd.ExecuteNonQueryAsync(); + + await using var dropCmd = conn.CreateCommand(); + dropCmd.CommandText = $"DROP DATABASE IF EXISTS \"{databaseName}\""; + await dropCmd.ExecuteNonQueryAsync(); + } + + private static async Task EnsureStartedAsync() + { + if (_container != null) + return; + + await Semaphore.WaitAsync(); + try + { + if (_container != null) + return; + + _container = new PostgreSqlBuilder("postgres:16-alpine") + .Build(); + await _container.StartAsync(); + } + finally + { + Semaphore.Release(); + } + } +}