Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,22 @@ jobs:
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.report-tests.outputs.matrix) }}
services:
postgres:
image: ${{ matrix.language == 'csharp' && matrix.category == 'objects' && 'postgres:18.3@sha256:7e32e9833a6fb1c92c32552794cb6ed569d51b445a54907d35fc112ef39684db' || '' }}
env:
POSTGRES_USER: benchmark
POSTGRES_PASSWORD: benchmark
POSTGRES_DB: benchmark
ports: ['5432:5432']
options: --health-cmd "pg_isready -U benchmark" --health-interval 5s --health-timeout 5s --health-retries 12
env:
LANGUAGE: ${{ matrix.language }}
CATEGORY: ${{ matrix.category }}
BITS: ${{ matrix.bits }}
SIZE: ${{ matrix.size }}
RUST_BACKTRACE: ${{ inputs.verbose && 'full' || '0' }}
POSTGRESQL_CONNECTION_STRING: ${{ matrix.language == 'csharp' && matrix.category == 'objects' && 'Host=127.0.0.1;Database=benchmark;Username=benchmark;Password=benchmark' || '' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
Expand Down
37 changes: 34 additions & 3 deletions .github/workflows/csharp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ on:
workflow_dispatch:
push:
branches: [main]
paths: ['csharp/**', 'experiments/**/*.cs', 'experiments/**/*.csproj', 'experiments/system_data_sqlite/**', '.github/workflows/csharp.yml', '.github/actions/**', 'scripts/check_pipeline_status.py', 'scripts/ci_process.py', 'scripts/workflow_yaml.py']
paths: ['csharp/**', 'experiments/**/*.cs', 'experiments/**/*.csproj', 'experiments/system_data_sqlite/**', 'experiments/postgresql/**', '.github/workflows/csharp.yml', '.github/actions/**', 'scripts/check_pipeline_status.py', 'scripts/ci_process.py', 'scripts/workflow_yaml.py']
pull_request:
paths: ['csharp/**', 'experiments/**/*.cs', 'experiments/**/*.csproj', 'experiments/system_data_sqlite/**', '.github/workflows/csharp.yml', '.github/actions/**', 'scripts/check_pipeline_status.py', 'scripts/ci_process.py', 'scripts/workflow_yaml.py']
paths: ['csharp/**', 'experiments/**/*.cs', 'experiments/**/*.csproj', 'experiments/system_data_sqlite/**', 'experiments/postgresql/**', '.github/workflows/csharp.yml', '.github/actions/**', 'scripts/check_pipeline_status.py', 'scripts/ci_process.py', 'scripts/workflow_yaml.py']

permissions:
contents: read
Expand Down Expand Up @@ -50,9 +50,40 @@ jobs:
- name: Check SQLite provider reports
run: python ../experiments/system_data_sqlite/check_reports.py

postgresql:
name: PostgreSQL integration
runs-on: ubuntu-24.04
timeout-minutes: 15
services:
postgres:
image: postgres:18.3@sha256:7e32e9833a6fb1c92c32552794cb6ed569d51b445a54907d35fc112ef39684db
env:
POSTGRES_USER: benchmark
POSTGRES_PASSWORD: benchmark
POSTGRES_DB: benchmark
ports: ['5432:5432']
options: --health-cmd "pg_isready -U benchmark" --health-interval 5s --health-timeout 5s --health-retries 12
env:
POSTGRESQL_CONNECTION_STRING: Host=127.0.0.1;Database=benchmark;Username=benchmark;Password=benchmark
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false
- uses: ./.github/actions/prepare-ci # zizmor: ignore[self-repository] actionlint 1.7.12 lacks $/ syntax
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
global-json-file: csharp/global.json
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.13'
- run: dotnet build -c Release -warnaserror
- run: dotnet test --project SQLiteVSDoublets.Tests -c Release --no-build -- --filter-class '*PostgreSQLTests' --timeout 5m
- run: python ../experiments/postgresql/check_reports.py

pipeline-status:
name: Pipeline Status
needs: [test]
needs: [test, postgresql]
if: ${{ !cancelled() }}
runs-on: ubuntu-24.04
timeout-minutes: 5
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ rows compare with Microsoft.Data.Sqlite of the same durability. Published
tables from older runs include only the providers measured in those runs;
the next Benchmarks run adds the new rows.

C# object benchmarks also support `PostgreSQL_EFCore` using
[Npgsql.EntityFrameworkCore.PostgreSQL](https://www.npgsql.org/efcore/).
Set `POSTGRESQL_CONNECTION_STRING` to include it in the default object comparison;
embedded benchmarks work without a PostgreSQL server. Explicitly selecting it
without configuration fails with a setup message. Both id widths use the same
deterministic posts, scattered point reads, validated operations, warm-ups and
transaction boundaries as the other object stores. EF saves one post at a time,
clears tracking after each save, reads without tracking and deletes by id.
Each warm-up and repetition creates and drops its own generated schema; the
database and other schemas remain intact. The configured user needs permission
to connect and create schemas in a benchmark database.

PostgreSQL is a server database and EF Core adds ORM and network costs. Its row
shows absolute timings without an embedded-storage speed ratio. `server_bytes`
measures the benchmark table, indexes and TOAST storage after creation, shown in
a separate server-relations column; it excludes shared WAL, server memory and
local client files. JSON and generated tables record PostgreSQL, Npgsql EF Core
and EF Core versions. Existing published measurements stay intact. CI adds a
pinned PostgreSQL service to each C# object benchmark job so all compared
variants still run on the same machine. Set `POSTGRESQL_VERBOSE=1` to log EF
diagnostics to stderr; logging is off by default and sensitive values are disabled.

Every repetition runs on a fresh store in an empty directory, after discarded
warm-up repetitions on up to 10,000 records that run for at least a second, so
the .NET JIT has already optimized the code. Each operation is one
Expand Down Expand Up @@ -95,6 +117,23 @@ dotnet run -c Release --project csharp/SQLiteVSDoublets -- \
python3 scripts/benchmark_report.py results
```

Run a bounded PostgreSQL comparison:

```bash
docker run -d --name benchmark-postgres -p 127.0.0.1:5432:5432 \
-e POSTGRES_USER=benchmark -e POSTGRES_PASSWORD=benchmark \
-e POSTGRES_DB=benchmark postgres:18.3
export POSTGRESQL_CONNECTION_STRING='Host=127.0.0.1;Database=benchmark;Username=benchmark;Password=benchmark'
dotnet run -c Release --project csharp/SQLiteVSDoublets -- \
objects 64 1000 --repetitions 3 \
--variants SQLite_Memory,SQLite_File,PostgreSQL_EFCore \
--output results/objects-csharp-64-1000.json
python3 scripts/benchmark_report.py results
python3 experiments/postgresql/check_reports.py
docker stop benchmark-postgres
docker rm benchmark-postgres
```

Notes:

- The split stores of doublets 0.5.0 (Rust) lose a link that is updated to
Expand Down
38 changes: 38 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,44 @@ Microsoft.Data.Sqlite. Оба провайдера используют один
надёжности хранения. Опубликованные таблицы старых запусков содержат только
измеренные тогда провайдеры; следующий запуск Benchmarks добавит новые строки.

Тесты объектов на C# также поддерживают `PostgreSQL_EFCore` через
[Npgsql.EntityFrameworkCore.PostgreSQL](https://www.npgsql.org/efcore/).
Переменная `POSTGRESQL_CONNECTION_STRING` добавляет этот вариант к сравнению
объектов по умолчанию. Без неё встроенные хранилища работают как прежде,
а явный выбор PostgreSQL завершается сообщением о настройке. Оба размера
идентификаторов используют те же данные, прогрев, проверяемые операции,
порядок точечных чтений и транзакции. EF сохраняет записи по одной, очищает
трекер после сохранения, читает без отслеживания и удаляет по id. Каждый
прогрев и повтор создаёт и удаляет собственную схему со случайным именем;
другие схемы и сама база сохраняются. Пользователю нужны права подключения
и создания схем в базе для тестирования.

PostgreSQL работает как сервер, а EF Core добавляет расходы ORM и сети.
Строка показывает абсолютное время без коэффициента относительно встроенного
SQLite. `server_bytes` — размер таблицы, индексов и TOAST после создания,
показанный в отдельном столбце; общие WAL, память сервера и локальные файлы
клиента в него не входят. JSON и таблицы содержат версии PostgreSQL, провайдера
Npgsql EF Core и EF Core. Опубликованные измерения сохраняются. CI запускает
PostgreSQL в том же задании, что и остальные варианты объектов C#.
`POSTGRESQL_VERBOSE=1` включает диагностику EF в stderr; по умолчанию она
выключена, чувствительные значения не записываются.

Пример с небольшим объёмом данных:

```bash
docker run -d --name benchmark-postgres -p 127.0.0.1:5432:5432 \
-e POSTGRES_USER=benchmark -e POSTGRES_PASSWORD=benchmark \
-e POSTGRES_DB=benchmark postgres:18.3
export POSTGRESQL_CONNECTION_STRING='Host=127.0.0.1;Database=benchmark;Username=benchmark;Password=benchmark'
dotnet run -c Release --project csharp/SQLiteVSDoublets -- \
objects 64 1000 --repetitions 3 \
--variants SQLite_Memory,SQLite_File,PostgreSQL_EFCore \
--output results/objects-csharp-64-1000.json
python3 experiments/postgresql/check_reports.py
docker stop benchmark-postgres
docker rm benchmark-postgres
```

Каждый повтор выполняется на новом хранилище в пустой папке после
отбрасываемых прогревочных повторов на не более чем 10 000 записей, которые
длятся не меньше секунды, чтобы JIT .NET уже оптимизировал код. Каждая
Expand Down
101 changes: 101 additions & 0 deletions csharp/SQLiteVSDoublets.Tests/PostgreSQLTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using Npgsql;
using Xunit;
using static Comparisons.SQLiteVSDoublets.Dataset;

namespace Comparisons.SQLiteVSDoublets.Tests;

public class PostgreSQLTests
{
public static bool IsConfigured => !string.IsNullOrEmpty(
Environment.GetEnvironmentVariable(PostgreSQLBlogPosts<uint>.ConnectionStringVariable));

[Fact]
public void PostgreSQLUsesTheObjectsHarness()
{
Assert.Contains("PostgreSQL_EFCore", Harness.ObjectsVariants);
Assert.DoesNotContain("PostgreSQL_EFCore", Harness.LinksVariants);
}

[Theory(Skip = "Set POSTGRESQL_CONNECTION_STRING for PostgreSQL integration tests.", SkipUnless = nameof(IsConfigured))]
[InlineData(false), InlineData(true)]
public void EveryIdWidthPassesValidatedRepetitions(bool wide)
{
var directory = Directory.CreateTempSubdirectory().FullName;
try
{
var measured = wide
? Harness.MeasureObjects<ulong>("PostgreSQL_EFCore", 100, 2, directory)
: Harness.MeasureObjects<uint>("PostgreSQL_EFCore", 100, 2, directory);
Assert.Null(measured.FileBytes);
Assert.True(measured.ServerBytes > 0);
Assert.Equal(new[] { "create", "read_all", "read_by_id", "delete" }, measured.Operations.Select(operation => operation.Operation));
Assert.All(measured.Operations, operation => Assert.Equal(2, operation.Samples.Count));
Assert.Empty(Directory.GetFileSystemEntries(directory));
}
finally
{
Directory.Delete(directory, true);
}
}

[Fact(Skip = "Set POSTGRESQL_CONNECTION_STRING for PostgreSQL integration tests.", SkipUnless = nameof(IsConfigured))]
public void PostsRoundTripWithoutAUniqueTitleConstraintOrTrackingCache()
{
using var posts = new PostgreSQLBlogPosts<ulong>(PostgreSQLBlogPosts<ulong>.ConnectionString());
var inputs = new[] { BlogPost(1), new BlogPost("", "", 0), new BlogPost("Ünïcödé 🌍 ✓", "a", 1_600_000_000), BlogPost(1) };
var ids = posts.Transaction(() => inputs.Select(posts.Create).ToArray());
Assert.Equal(inputs, ids.Select(posts.Get));
var stored = new List<(ulong, BlogPost)>();
posts.Each((id, post) => stored.Add((id, post)));
Assert.Equal(ids.Zip(inputs), stored.OrderBy(entry => entry.Item1));

using var connection = new NpgsqlConnection(PostgreSQLBlogPosts<ulong>.ConnectionString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"UPDATE \"{posts.SchemaName}\".blog_posts SET title = 'changed on server' WHERE id = $1";
command.Parameters.AddWithValue(checked((long)ids[0]));
command.ExecuteNonQuery();
Assert.Equal("changed on server", posts.Get(ids[0])!.Title);
foreach (var id in ids)
{
posts.Delete(id);
}
Assert.Equal(0UL, posts.Count());
Assert.Null(posts.Get(ids[0]));
}

[Fact(Skip = "Set POSTGRESQL_CONNECTION_STRING for PostgreSQL integration tests.", SkipUnless = nameof(IsConfigured))]
public void RollbackLeavesTheStoreReusable()
{
using var posts = new PostgreSQLBlogPosts<uint>(PostgreSQLBlogPosts<uint>.ConnectionString());
Assert.Throws<InvalidOperationException>(() => posts.Transaction<int>(() =>
{
posts.Create(BlogPost(1));
throw new InvalidOperationException("roll back the inserted post");
}));
Assert.Equal(0UL, posts.Count());
var id = posts.Transaction(() => posts.Create(BlogPost(2)));
Assert.Equal(BlogPost(2), posts.Get(id));
Assert.Equal(1UL, posts.Count());
}

[Fact(Skip = "Set POSTGRESQL_CONNECTION_STRING for PostgreSQL integration tests.", SkipUnless = nameof(IsConfigured))]
public void StoresAreIsolatedAndDisposeOnlyTheirOwnSchema()
{
using var first = new PostgreSQLBlogPosts<uint>(PostgreSQLBlogPosts<uint>.ConnectionString());
using var second = new PostgreSQLBlogPosts<uint>(PostgreSQLBlogPosts<uint>.ConnectionString());
Assert.NotEqual(first.SchemaName, second.SchemaName);
var id = second.Create(BlogPost(2));
first.Create(BlogPost(1));
var schema = first.SchemaName;
first.Dispose();
first.Dispose();
Assert.Equal(BlogPost(2), second.Get(id));
using var connection = new NpgsqlConnection(PostgreSQLBlogPosts<uint>.ConnectionString());
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM pg_namespace WHERE nspname = $1";
command.Parameters.AddWithValue(schema);
Assert.Equal(0L, command.ExecuteScalar());
}
}
2 changes: 1 addition & 1 deletion csharp/SQLiteVSDoublets.Tests/StoragesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public void ScatteredVisitsEveryIdOnce(ulong n) =>

public static TheoryData<string> LinksVariants => [.. Harness.LinksVariants];

public static TheoryData<string> ObjectsVariants => [.. Harness.ObjectsVariants];
public static TheoryData<string> ObjectsVariants => [.. Harness.ObjectsVariants.Where(variant => variant != "PostgreSQL_EFCore")];

[Theory, InlineData("SystemDataSQLite_Memory"), InlineData("SystemDataSQLite_File")]
public void SystemDataSQLiteVariantsAreRegistered(string variant)
Expand Down
33 changes: 27 additions & 6 deletions csharp/SQLiteVSDoublets/Harness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public static class Harness

public static readonly string[] ObjectsVariants =
[
"PostgreSQL_EFCore",
"SQLite_Memory",
"SQLite_File",
"SystemDataSQLite_Memory",
Expand Down Expand Up @@ -225,6 +226,8 @@ private static void Check(bool condition, string message)

public sealed record Measurement(string Variant, ulong? FileBytes, List<(string Operation, List<double> Samples)> Operations)
{
public ulong? ServerBytes { get; init; }

public JsonObject ToJson()
{
var operations = new JsonObject();
Expand All @@ -241,7 +244,12 @@ public JsonObject ToJson()
["samples_ns"] = new JsonArray(samples.Select(sample => (JsonNode)sample).ToArray()),
};
}
return new JsonObject { ["variant"] = Variant, ["file_bytes"] = FileBytes, ["operations"] = operations };
var result = new JsonObject { ["variant"] = Variant, ["file_bytes"] = FileBytes, ["operations"] = operations };
if (ServerBytes is not null)
{
result["server_bytes"] = ServerBytes;
}
return result;
}
}

Expand All @@ -258,7 +266,8 @@ private static Measurement Measure<TStorage>(
int repetitions,
string directory,
Func<string, TStorage> open,
Func<TStorage, ulong, Action, List<(string, TimeSpan)>> lifecycle)
Func<TStorage, ulong, Action, List<(string, TimeSpan)>> lifecycle,
Func<TStorage, ulong>? serverBytes = null)
where TStorage : IDisposable
{
(List<(string, TimeSpan)>, ulong) Run(ulong size)
Expand All @@ -269,11 +278,15 @@ private static Measurement Measure<TStorage>(
GC.WaitForPendingFinalizers();
ulong fileBytes = 0;
List<(string, TimeSpan)> timings;
using (var storage = open(workspace))
try
{
timings = lifecycle(storage, size, () => fileBytes = DirectoryBytes(workspace));
using var storage = open(workspace);
timings = lifecycle(storage, size, () => fileBytes = serverBytes?.Invoke(storage) ?? DirectoryBytes(workspace));
}
finally
{
Directory.Delete(workspace, true);
}
Directory.Delete(workspace, true);
return (timings, fileBytes);
}

Expand All @@ -288,7 +301,9 @@ private static Measurement Measure<TStorage>(
{
var (timings, fileBytes) = Run(n);
Console.Error.WriteLine($"{variant} #{repetition}: {Summary(timings, n)}");
measurement = measurement with { FileBytes = fileBytes > 0 ? fileBytes : null };
measurement = serverBytes is null
? measurement with { FileBytes = fileBytes > 0 ? fileBytes : null }
: measurement with { ServerBytes = fileBytes };
foreach (var (operation, elapsed) in timings)
{
var nsPerOperation = elapsed.TotalNanoseconds / n;
Expand Down Expand Up @@ -346,6 +361,12 @@ public static Measurement MeasureLinks<T>(string variant, ulong n, int repetitio
public static Measurement MeasureObjects<T>(string variant, ulong n, int repetitions, string directory)
where T : struct, IBinaryInteger<T>, IUnsignedNumber<T>, IMinMaxValue<T>
{
if (variant == "PostgreSQL_EFCore")
{
return Measure(variant, n, repetitions, directory,
_ => new PostgreSQLBlogPosts<T>(PostgreSQLBlogPosts<T>.ConnectionString()),
ObjectsLifecycle, storage => storage.ServerBytes());
}
var cached = variant.EndsWith("_Cached");
Func<string, IBlogPostsStorage<T>> open = variant.Replace("_Uncached", "").Replace("_Cached", "") switch
{
Expand Down
Loading
Loading