diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..cf30efc --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,154 @@ +name: GraphQL comparison +on: + pull_request: + branches: [main] + paths: ['rust/**', 'csharp/**', 'scripts/**', 'tests/**', 'schema/**', 'deep/**', 'compose.yml', 'requirements.txt', 'rust-toolchain.toml', 'global.json', '.gitignore', '.github/workflows/benchmarks.yml'] + push: + branches: [main] + paths: ['rust/**', 'csharp/**', 'scripts/**', 'tests/**', 'schema/**', 'deep/**', 'compose.yml', 'requirements.txt', 'rust-toolchain.toml', 'global.json', '.gitignore', '.github/workflows/benchmarks.yml'] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: gql-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + DOTNET_NOLOGO: 'true' + DOTNET_CLI_TELEMETRY_OPTOUT: 'true' + CARGO_TERM_COLOR: always + BENCHMARK_LINKS: ${{ github.event_name == 'pull_request' && '10' || '100' }} + BENCHMARK_SAMPLES: '10' + BENCHMARK_WARMUP: '2' +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d + with: + toolchain: '1.98.0' + components: rustfmt, clippy + - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 + with: + workspaces: rust + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + cache: true + cache-dependency-path: csharp/packages.lock.json + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 + with: + python-version: '3.12' + cache: pip + - name: Python checks + run: | + pip install -r requirements.txt + ruff check scripts tests + python -m unittest discover -s tests -v + - name: Rust checks + run: | + cargo fmt --manifest-path rust/Cargo.toml --check + cargo clippy --manifest-path rust/Cargo.toml --release --all-targets --locked -- -D warnings + cargo test --manifest-path rust/Cargo.toml --release --locked + cargo build --manifest-path rust/Cargo.toml --release --locked + - name: C# checks + run: | + dotnet restore csharp/Doublets.Gql.csproj --locked-mode + dotnet format csharp/Doublets.Gql.csproj --verify-no-changes --no-restore + dotnet build csharp/Doublets.Gql.csproj -c Release --no-restore + - name: All servers return the same results + run: python -m scripts.run conformance + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: conformance-logs + path: results/logs + benchmark: + needs: test + runs-on: ubuntu-24.04 + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + backend: [deep, rust, csharp] + background: ${{ fromJSON(github.event_name == 'pull_request' && '[1000]' || '[10000,100000,1000000]') }} + env: + BENCHMARK_BACKGROUND_LINKS: ${{ matrix.background }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d + if: matrix.backend == 'rust' + with: + toolchain: '1.98.0' + - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2 + if: matrix.backend == 'rust' + with: + workspaces: rust + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + if: matrix.backend == 'csharp' + with: + global-json-file: global.json + cache: true + cache-dependency-path: csharp/packages.lock.json + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 + with: + python-version: '3.12' + - name: Build Rust server + if: matrix.backend == 'rust' + run: cargo build --manifest-path rust/Cargo.toml --release --locked + - name: Build C# server + if: matrix.backend == 'csharp' + run: | + dotnet restore csharp/Doublets.Gql.csproj --locked-mode + dotnet build csharp/Doublets.Gql.csproj -c Release --no-restore + - name: Measure shared HTTP workload + run: python -m scripts.run benchmark --backend '${{ matrix.backend }}' + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + if: always() + with: + name: bench-${{ matrix.backend }}-${{ matrix.background }} + path: results + report: + needs: benchmark + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: bench-* + merge-multiple: true + path: results + - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4 + with: + python-version: '3.12' + cache: pip + - name: Generate validated tables and charts + run: | + pip install -r requirements.txt + python -m scripts.report results --readme README.md --charts Docs > report.md + cat report.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: comparison-report + path: | + README.md + Docs + - name: Commit main results + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' + run: | + git fetch origin main + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo 'Main advanced during measurement; skipping publication of older results' + exit 0 + fi + git config user.name LinksPlatformBencher + git config user.email linksplatform@gmail.com + git add README.md Docs + if ! git diff --cached --quiet; then + git commit -m 'Update GraphQL comparison results' + git push origin HEAD:main + fi diff --git a/.gitignore b/.gitignore index dfcfd56..0bad527 100644 --- a/.gitignore +++ b/.gitignore @@ -348,3 +348,13 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# Comparison build products and local measurements +rust/target/ +csharp/bin/ +csharp/obj/ +__pycache__/ +.venv/ +/results/ +/ci-logs/ +experiments/*.log diff --git a/README.md b/README.md index b850c65..5d9846e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,148 @@ -# Comparisons.GqlServers -Deep (PostgreSQL+Hasura) vs Doublets Rust vs Doublets C# +# GraphQL server comparison + +Deep's PostgreSQL + Hasura core links storage versus Doublets Rust and Doublets C#, +through the same GraphQL-over-HTTP workload. This repository now contains runnable +servers, correctness tests, a benchmark driver and generated reporting. + +## Scope and schema + +[`schema/links.graphql`](schema/links.graphql) defines the executable common subset +of the [Data.Doublets.Gql](https://github.com/linksplatform/Data.Doublets.Gql) / +Hasura API: `links`, `insert_links`, `update_links`, `delete_links`, with `id`, +`from_id`, `to_id`, equality, membership, range and boolean filters, ordering by +id, limit and offset. Rust uses async-graphql + Axum over `doublets` 0.5.0; +C# uses GraphQL.NET over `Platform.Data.Doublets` 0.18.1. Both use united volatile +Doublets stores and push equality and id-membership filters into their native indexes. +The adapters follow Data.Doublets.Gql's field and resolver conventions; they are +maintained in `rust/` and `csharp/` here because its legacy servers require old +runtimes and have unfinished resolvers. All original submodule pointers remain. + +The Deep baseline uses PostgreSQL 16.13 and Hasura 2.48.13, both pinned by digest +in [`compose.yml`](compose.yml). [`deep/links.sql`](deep/links.sql) reproduces the +core table and indexes from [Deep's links migration at +381207d](https://github.com/deep-foundation/deeplinks/blob/381207dda313ab3c1fed0a4bfc2ddbe631750411/migrations/1616701513782-links.ts), +with a default `type_id = 0`. This compares core links storage, without Deep's +application triggers, permissions, values, relationships, materialized paths or +subscriptions. Hasura exposes more schema fields than the common subset. + +Workloads use nonnegative, non-null endpoints and distinct `(from_id, to_id)` +pairs. They do not benchmark duplicates, explicit ids, nullable endpoints, +transactions or concurrency. Generated ids can be reused by Doublets and are +monotonic in PostgreSQL; tests start with fresh stores and benchmarks use ids +returned by each server. The comparison does not claim equivalence outside this +shared workload domain. + +## Running locally + +Prerequisites: Docker with Compose, Rust 1.98.0, .NET SDK 10.0.112 (runtime 10.0.12), and Python +3.12 or newer. Versions of libraries are pinned in Cargo.lock and +packages.lock.json; reporting dependencies are in requirements.txt. + +```bash +python3 -m pip install -r requirements.txt +cargo build --manifest-path rust/Cargo.toml --release --locked +dotnet restore csharp/Doublets.Gql.csproj --locked-mode +dotnet build csharp/Doublets.Gql.csproj -c Release --no-restore + +# Unit tests and the same-response test against all three real HTTP servers. +python3 -m unittest discover -s tests -v +cargo test --manifest-path rust/Cargo.toml --release --locked +python3 -m scripts.run conformance + +# Each command starts an empty dedicated backend and cleans up after itself. +export BENCHMARK_BACKGROUND_LINKS=1000 BENCHMARK_LINKS=10 +export BENCHMARK_SAMPLES=10 BENCHMARK_WARMUP=2 +python3 -m scripts.run benchmark --backend deep +python3 -m scripts.run benchmark --backend rust +python3 -m scripts.run benchmark --backend csharp +python3 -m scripts.report results --readme README.md --charts Docs +``` + +`python3 -m scripts.run` owns ports 8080, 8001 and 8002, starts only the required +servers, saves server logs under `results/logs/`, and stops its processes and +Compose services on completion or failure. Use a dedicated checkout and database; +Compose teardown removes its database. The low-level driver also accepts an +existing **empty dedicated** endpoint: +`python3 -m scripts.workload rust http://127.0.0.1:8001/v1/graphql results/rust-1000.json`. +It refuses to seed a nonempty store. Set `BENCHMARK_TRACE=1` for optional HTTP +tracing (off by default; trace runs should not be used for published timings). +Set `COMPOSE_OVERRIDE` to an optional local Compose override file if needed. + +| Environment variable | Default | Meaning | +|---|---:|---| +| `BENCHMARK_BACKGROUND_LINKS` | 1000 | B, links seeded outside timing | +| `BENCHMARK_LINKS` | 100 | N, links affected by a mutation request | +| `BENCHMARK_SAMPLES` | 10 | Measured requests per operation (2–1000) | +| `BENCHMARK_WARMUP` | 2 | Untimed requests per operation (0–100) | +| `LISTEN_ADDRESS` | Rust: `127.0.0.1:8001`; C#: `http://127.0.0.1:8002` | Adapter bind address | + +Require `1 ≤ N ≤ B ≤ 1,000,000` and `B + N < 1,040,384`, the capacity limit of +Rust doublets 0.5.0 described by the [sibling comparison](https://github.com/linksplatform/Comparisons.Neo4jVSDoublets/tree/main/experiments/store-capacity). +Servers are bounded to 4 GiB virtual memory (Rust), 2 GiB managed heap (C#), +and 2 GiB per database container; Rust's stack limit is 32 MiB. + +| Operation | Shared request and dataset | +|---|---| +| Create | Insert N temporary pairs `(i, i)`; delete returned ids after timing | +| Update | Set `to_id = 1` for N background ids; restore each original pair after timing | +| Delete | Insert N temporary pairs outside timing, delete their returned ids | +| Each All | Query and order all B background links | +| Each Identity | Query one background id | +| Each Concrete | Query `from_id = 1` and `to_id = 2` | +| Each Outgoing | Query `from_id = 1` | +| Each Incoming | Query `to_id = 2` | + +Background pairs are `(i, i+1)`, so each filtered query returns one row. +Create/update/delete each send **one batched HTTP request**, not N requests. +The driver uses one persistent HTTP connection and exactly the same documents, +variables and settings for every server. Each sample includes client JSON +encoding/decoding, HTTP, GraphQL parsing/validation/execution, storage and response +serialization. Setup, seeding, warm-up, response validation and mutation cleanup are untimed. Each +response is checked for the expected affected count, ids and endpoints before it +can become a sample. The complete background dataset is checked again at the end. These are end-to-end +server comparisons, not isolated storage or language-speed measurements; Hasura's +query-plan caching and PostgreSQL durability differ from volatile Doublets. + +Rust uses the united store because the split store in doublets 0.5.0 hangs during +repeated batched mutations on non-point links. A bounded regression test +(`repeated_batch_mutations_preserve_indexes`, 5 seconds) reproduces this failure +and verifies the chosen store. `python3 -m experiments.repeat_workload` retains +the small HTTP reproduction (1,000 background links, 3 samples, memory/CPU limits). + +The conformance test checks explicit expected responses and compares all three +servers: every query variant, membership/range/boolean filters, pagination, +partial updates, missing ids, batched deletes and final state. The Python tests +check workload bounds, timing boundaries and reporting validation/ratios. Rust +also tests repeated mutations, partial updates and negative identity lookups. Local CI checks: + +```bash +ruff check scripts tests +python3 -m unittest discover -s tests -v +cargo fmt --manifest-path rust/Cargo.toml --check +cargo clippy --manifest-path rust/Cargo.toml --release --all-targets --locked -- -D warnings +dotnet format csharp/Doublets.Gql.csproj --verify-no-changes --no-restore +``` + +## CI and results + +[`.github/workflows/benchmarks.yml`](.github/workflows/benchmarks.yml) checks +formatting, lint, builds, unit tests and real three-server conformance first. +Benchmark jobs then use a **server × B** matrix: Deep, Rust and C#, each in its +own runner. PRs use `B = 1,000`, `N = 10`; main and manual main runs use +`B = 10,000 / 100,000 / 1,000,000`, `N = 100`. All use 10 measured samples and +2 warm-up requests per operation. There are path filters, branch concurrency, +explicit job timeouts and pinned actions, toolchains and database images. + +Every successful run uploads raw samples, logs and a generated README with +linear and log charts. CI commits README, charts and raw samples under +`Docs/results/` only on main. It changes only the results markers below and +rejects incomplete backends/operations, duplicate results, inconsistent workloads, +missing provenance and invalid statistics. Generated-result-only commits do not +match the workflow's path filters. Times show sample medians and standard +deviations, ratios to Deep (`× faster`, `× slower`, `≈ same`), dependency versions, +CPU, commit, run link and UTC date. Treat overlapping error ranges or differences +under 5% as approximately the same. + + +_No published results yet. A successful main run will commit measured tables, provenance and linear/log charts here. PR reports are available as CI artifacts._ + diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..605e1d0 --- /dev/null +++ b/compose.yml @@ -0,0 +1,26 @@ +name: deepgql-comparison +services: + postgres: + image: postgres:16.13-alpine@sha256:4e6e670bb069649261c9c18031f0aded7bb249a5b6664ddec29c013a89310d50 + environment: + POSTGRES_PASSWORD: benchmark + POSTGRES_DB: deep + volumes: + - ./deep/links.sql:/docker-entrypoint-initdb.d/links.sql:ro + healthcheck: + test: [CMD-SHELL, 'pg_isready -U postgres -d deep'] + interval: 2s + timeout: 5s + retries: 30 + mem_limit: 2g + hasura: + image: hasura/graphql-engine:v2.48.13@sha256:224f4239b28583413b2a0ace7193c467d81f28d791dc8c65597ed11b2cd83123 + ports: ['127.0.0.1:8080:8080'] + environment: + HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:benchmark@postgres:5432/deep + HASURA_GRAPHQL_ENABLE_CONSOLE: 'false' + HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup + depends_on: + postgres: + condition: service_healthy + mem_limit: 2g diff --git a/csharp/Doublets.Gql.csproj b/csharp/Doublets.Gql.csproj new file mode 100644 index 0000000..e084aa2 --- /dev/null +++ b/csharp/Doublets.Gql.csproj @@ -0,0 +1,17 @@ + + + net10.0 + 10.0.12 + Disable + enable + enable + true + true + + + + + + + + diff --git a/csharp/Program.cs b/csharp/Program.cs new file mode 100644 index 0000000..43ce8f9 --- /dev/null +++ b/csharp/Program.cs @@ -0,0 +1,172 @@ +using GraphQL; +using GraphQL.SystemTextJson; +using GraphQL.Types; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory.United.Generic; +using Platform.Memory; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +if (Environment.GetEnvironmentVariable("BENCHMARK_TRACE") == "1") +{ + builder.Logging.AddConsole(); +} +builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("LISTEN_ADDRESS") ?? "http://127.0.0.1:8002"); +using var store = new LinksStore(); +using var schema = Schema.For(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "links.graphql")), config => +{ + config.Types.For("query_root").FieldFor("links").Resolver = new GraphQL.Resolvers.FuncFieldResolver(context => + store.Query(context.GetArgument("where"), context.GetArgument?>("order_by"), + context.GetArgument("limit"), context.GetArgument("offset"))); + config.Types.For("mutation_root").FieldFor("insert_links").Resolver = new GraphQL.Resolvers.FuncFieldResolver(context => + store.Insert(context.GetArgument>("objects"))); + config.Types.For("mutation_root").FieldFor("update_links").Resolver = new GraphQL.Resolvers.FuncFieldResolver(context => + store.Update(context.GetArgument("where"), context.GetArgument("_set"))); + config.Types.For("mutation_root").FieldFor("delete_links").Resolver = new GraphQL.Resolvers.FuncFieldResolver(context => + store.Delete(context.GetArgument("where"))); +}); +schema.RegisterType(new BigintGraphType()); +schema.Initialize(); +var serializer = new GraphQLSerializer(); +var executer = new DocumentExecuter(); +var app = builder.Build(); +app.MapPost("/v1/graphql", async (HttpContext context) => +{ + var request = await serializer.ReadAsync(context.Request.Body, context.RequestAborted); + var result = await executer.ExecuteAsync(options => + { + options.Schema = schema; + options.Query = request!.Query; + options.Variables = request.Variables; + options.OperationName = request.OperationName; + options.CancellationToken = context.RequestAborted; + }); + context.Response.ContentType = "application/json"; + await serializer.WriteAsync(context.Response.Body, result, context.RequestAborted); +}); +Console.Error.WriteLine("C# Doublets GraphQL started"); +await app.RunAsync(); + +public sealed class BigintGraphType : LongGraphType +{ + public BigintGraphType() { Name = "bigint"; } +} + +public sealed record Row(long id, long from_id, long to_id); +public sealed record MutationResponse(int affected_rows, List returning); +public sealed class Endpoints +{ + public long? from_id { get; set; } + public long? to_id { get; set; } +} +public sealed class OrderBy { public string? id { get; set; } } +public sealed class Comparison +{ + public long? _eq { get; set; } + public List? _in { get; set; } + public long? _gte { get; set; } + public long? _lte { get; set; } + public bool Matches(long value) => (!_eq.HasValue || _eq == value) + && (_in == null || _in.Contains(value)) && (!_gte.HasValue || value >= _gte) + && (!_lte.HasValue || value <= _lte); +} +public sealed class Filter +{ + public Comparison? id { get; set; } + public Comparison? from_id { get; set; } + public Comparison? to_id { get; set; } + public List? _and { get; set; } + public List? _or { get; set; } + public Filter? _not { get; set; } + public bool Matches(Row row) => (id?.Matches(row.id) ?? true) + && (from_id?.Matches(row.from_id) ?? true) && (to_id?.Matches(row.to_id) ?? true) + && (_and?.All(f => f.Matches(row)) ?? true) && (_or?.Any(f => f.Matches(row)) ?? true) + && !(_not?.Matches(row) ?? false); +} + +/// Data.Doublets.Gql benchmark adapter: native equality indexes with remaining predicates evaluated on matched rows. +public sealed class LinksStore : IDisposable +{ + private readonly ILinks links = new UnitedMemoryLinks(new HeapResizableDirectMemory()); + private readonly object gate = new(); + + private List Select(Filter? filter) + { + filter ??= new Filter(); + var equalities = new[] { filter.id?._eq, filter.from_id?._eq, filter.to_id?._eq }; + if (equalities.Any(value => value < 0)) { return []; } + var query = equalities.Select(value => value.HasValue ? (ulong)value.Value : links.Constants.Any).ToArray(); + var rows = new List(); + var queries = filter.id?._eq == null && filter.id?._in != null + ? filter.id._in.Where(id => id >= 0).Distinct().Select(id => new[] { (ulong)id, query[1], query[2] }).ToList() + : new List { query }; + foreach (var candidate in queries) + { + links.Each(link => + { + var row = new Row((long)link![0], (long)link[1], (long)link[2]); + if (filter.Matches(row)) { rows.Add(row); } + return links.Constants.Continue; + }, candidate); + } + return rows.OrderBy(row => row.id).ToList(); + } + + public List Query(Filter? filter, List? order, int? limit, int? offset) + { + if (limit < 0 || offset < 0) { throw new ExecutionError("limit and offset must be nonnegative"); } + lock (gate) + { + var rows = Select(filter); + if (order?.FirstOrDefault(item => item.id != null)?.id == "desc") { rows.Reverse(); } + return rows.Skip(offset ?? 0).Take(limit ?? int.MaxValue).ToList(); + } + } + private static ulong Endpoint(long? value, long fallback = 0) + { + var endpoint = value ?? fallback; + if (endpoint < 0) { throw new ExecutionError("Doublets endpoints must be nonnegative"); } + return (ulong)endpoint; + } + public MutationResponse Insert(List objects) + { + var pairs = objects.Select(item => (Source: Endpoint(item.from_id), Target: Endpoint(item.to_id))).ToList(); + lock (gate) + { + var rows = new List(); + foreach (var pair in pairs) + { + var id = links.Create(); + links.Update(id, pair.Source, pair.Target); + rows.Add(new Row((long)id, (long)pair.Source, (long)pair.Target)); + } + return new MutationResponse(rows.Count, rows); + } + } + public MutationResponse Update(Filter filter, Endpoints? set) + { + if (set == null) { throw new ExecutionError("_set is required for update"); } + lock (gate) + { + var updates = Select(filter).Select(row => (row.id, Source: Endpoint(set.from_id, row.from_id), Target: Endpoint(set.to_id, row.to_id))).ToList(); + var rows = new List(); + foreach (var item in updates) + { + links.Update((ulong)item.id, item.Source, item.Target); + rows.Add(new Row(item.id, (long)item.Source, (long)item.Target)); + } + return new MutationResponse(rows.Count, rows); + } + } + public MutationResponse Delete(Filter filter) + { + lock (gate) + { + var rows = Select(filter); + foreach (var row in rows.AsEnumerable().Reverse()) { links.Delete((ulong)row.id, handler: null); } + return new MutationResponse(rows.Count, rows); + } + } + public void Dispose() => (links as IDisposable)?.Dispose(); +} diff --git a/csharp/packages.lock.json b/csharp/packages.lock.json new file mode 100644 index 0000000..5784bdb --- /dev/null +++ b/csharp/packages.lock.json @@ -0,0 +1,207 @@ +{ + "version": 1, + "dependencies": { + "net10.0": { + "GraphQL": { + "type": "Direct", + "requested": "[8.8.5, )", + "resolved": "8.8.5", + "contentHash": "0dnaIzuzyRkpUfCBOWRLOnbTWcTpvK0pvWjgDTYoU3k/bu4Us1jkid7MPBhcth8I+6rg8SxhXFwNEenx53xUJg==", + "dependencies": { + "GraphQL-Parser": "9.5.0", + "GraphQL.Analyzers": "8.8.5" + } + }, + "GraphQL.SystemTextJson": { + "type": "Direct", + "requested": "[8.8.5, )", + "resolved": "8.8.5", + "contentHash": "m0uuLLSbnycqvf4IhRnbrC0Vwowdxiel2BH9vr/7ow2VXPWZz0g5A7ucqBDzJ6wqDFS/ixaUgiflFqU6vbgtOg==", + "dependencies": { + "GraphQL": "[8.8.5, 9.0.0)" + } + }, + "Platform.Data.Doublets": { + "type": "Direct", + "requested": "[0.18.1, )", + "resolved": "0.18.1", + "contentHash": "b6p7GgLDonEnRdhuy3Xsn/N5g5Qq+aivapAlkFH/Mk/MDTDiSJ0C1wnViC4Ff9/d6IXfmh2Sv/TeGIkyKbTOTw==", + "dependencies": { + "Platform.Collections.Methods": "0.7.3", + "Platform.Data": "0.16.1", + "Platform.Data.Doublets.FFI": "0.9.0", + "Platform.Memory": "0.4.1", + "Platform.Random": "0.3.0", + "Platform.Singletons": "0.3.0", + "Platform.Timestamps": "0.3.0" + } + }, + "GraphQL-Parser": { + "type": "Transitive", + "resolved": "9.5.0", + "contentHash": "5XWJGKHdVi8pyD4P0EglmJmlXEGs0HzvGlEBf3+/Ve1jLYBBKIOkKvY0Ej17b9Kn1bbBxkrmghqbmsMbkLL1nQ==" + }, + "GraphQL.Analyzers": { + "type": "Transitive", + "resolved": "8.8.5", + "contentHash": "2pmvrDAPbjNZfl7CB19cKpQ5y92A+tb1aPVZepz46KTx00/7nTojBTL9iJAOFVoKbq0I0sdZjj60aJLFKY/z8Q==" + }, + "Platform.Collections": { + "type": "Transitive", + "resolved": "0.4.0", + "contentHash": "ofU+XTf0CJry3dnpSq5pdiz8cArZDlrd3IaGtYoV8L6e+OhTdx2n544s+XLOZK90KJUsNv/docu2Y97Asdbvuw==", + "dependencies": { + "Platform.Disposables": "0.4.0", + "Platform.Interfaces": "0.5.0", + "Platform.Random": "0.3.0", + "Platform.Ranges": "0.5.0" + } + }, + "Platform.Collections.Methods": { + "type": "Transitive", + "resolved": "0.7.3", + "contentHash": "GG+XkIiZWlg96w9HxBeCeGrWdXr6+ST8L8piPjvC4lVrB0XaJNWWj+FYSnQOcX7MZOXs70Y6+e5eD0t1GygB5g==", + "dependencies": { + "Platform.Numbers": "0.9.0" + } + }, + "Platform.Converters": { + "type": "Transitive", + "resolved": "0.4.0", + "contentHash": "Rz4gLL2zPB/KsqORljmhMaQR8EVcTBnRRO2rz91F/nu+qJXuzpurx4VgtnL0lyso0NfkmNJffTiLucxwYX9pWQ==", + "dependencies": { + "Platform.Reflection": "0.7.0" + } + }, + "Platform.Data": { + "type": "Transitive", + "resolved": "0.16.1", + "contentHash": "1JdpgdWbowNgVx+aJatRyTEL5CS+hB5mjOJD57+R26XfbCpIsscJwL+Kcmk/ivaop6OABd3SX7M2DaAhYHXKtw==", + "dependencies": { + "Platform.Delegates": "0.1.1", + "Platform.Numbers": "0.9.0", + "Platform.Setters": "0.5.0", + "Platform.Threading": "0.4.0" + } + }, + "Platform.Data.Doublets.FFI": { + "type": "Transitive", + "resolved": "0.9.0", + "contentHash": "NRgGyihV3sdmvNW9SLnwBV14zkq89GJlTgyeiB+EKT2cMOkCjZtd55C/M0Sy5tLkZ076rBcup4jLFKmejnS2tg==" + }, + "Platform.Delegates": { + "type": "Transitive", + "resolved": "0.1.1", + "contentHash": "+182klZgGEJ+yWtxz3BMrTCYcpSXjvJzrYzpK+W2UecAaDKLvLyehjPlMfta+dM/XlKbPt53Wf20vhI1kul0SQ==" + }, + "Platform.Disposables": { + "type": "Transitive", + "resolved": "0.4.0", + "contentHash": "ko0W4cw5XhNxJANdO1T8srpWRVqpPiXtCohumlbTu33kivRBEmk1ivDsySaNhKTyuYjJnrjRYrgA1iEb2sS+/Q==", + "dependencies": { + "Platform.Exceptions": "0.5.0" + } + }, + "Platform.Exceptions": { + "type": "Transitive", + "resolved": "0.5.0", + "contentHash": "htu3iyPVJinbLxU1I1cpc2qFlur7IF2fTc9u2jqQ+3X1Lyzd3aeK9Y7gJu9ZkdnIO3tbdO6wxr1HZG4UhaIG8A==" + }, + "Platform.Interfaces": { + "type": "Transitive", + "resolved": "0.5.0", + "contentHash": "OBViyjgCYWaXH8xhibYL/fbwHfPP/+qVUDxcFGH22IaYzbrFbfNR5TxW6gsk3LR9QXLANnrLsuI7phZueoj/Tw==" + }, + "Platform.IO": { + "type": "Transitive", + "resolved": "0.5.1", + "contentHash": "MZ+uZIrDZuNZZu8J8gv8RcXwGihSTEo9UvFoRHCGIXQnaQxnJ0gBIF5s/zSjJ1+h8c78lv/wmlSvh4zMzwyJIQ==", + "dependencies": { + "Platform.Threading": "0.5.0", + "Platform.Unsafe": "0.6.1" + } + }, + "Platform.Memory": { + "type": "Transitive", + "resolved": "0.4.1", + "contentHash": "Jz4Xb0B8pmh6BU9hhvqMgK2L+R0fCxSk1bGWL7WK/WYKAYSuGaJUAvzWdJLTECyfs+w6PVUxXK53q1OXxHRmMQ==", + "dependencies": { + "Platform.IO": "0.5.1" + } + }, + "Platform.Numbers": { + "type": "Transitive", + "resolved": "0.9.0", + "contentHash": "0GgZSH0p+jP933DKtLzCfOAdkJ1cNKrSrisXgMKy0zTMBZOxMxzs83IVXwBju66vm5N/w6lFq8HwCLW0gQsYVA==", + "dependencies": { + "Platform.Converters": "0.4.0" + } + }, + "Platform.Random": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "GX24Xx45cUIr9EZl55evdwb7oYN/nimpoNqDG4zoMDDLqeEpL35zX8IiHHDYITV9F4bjrCrSEiRCVsRFAsi7SQ==", + "dependencies": { + "Platform.Ranges": "0.5.0" + } + }, + "Platform.Ranges": { + "type": "Transitive", + "resolved": "0.5.0", + "contentHash": "CUhZM/4F714NAyrtWtkBx78L4xJpFZ1wp+6ivFvpAXx6DsaBcBsyDxjn5eoLB9LSUYxB11gFkwwmXmJhMgZ3qw==", + "dependencies": { + "Platform.Exceptions": "0.5.0" + } + }, + "Platform.Reflection": { + "type": "Transitive", + "resolved": "0.7.0", + "contentHash": "CvpRnDWH5YArcXlY7lcY0mDYQCtnJAeZsqZ4/IdllJoj7z8JwTufeo/zK62Vn6K0ulTyHNhb+/T3HFusxnrfxA==", + "dependencies": { + "Platform.Collections": "0.4.0" + } + }, + "Platform.Setters": { + "type": "Transitive", + "resolved": "0.5.0", + "contentHash": "vZG+VnYc4u2x5vnBY7rqaFHtOS2AeoLwGV+02JBFa9pxluHwVTu4nVcVBTJTlenZSfAoZUNVsV1StnUWvINkCQ==", + "dependencies": { + "Platform.Delegates": "0.1.1", + "Platform.Interfaces": "0.5.0" + } + }, + "Platform.Singletons": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "fCfNw4CqPL6HzsUJVVYI/OLYXGkjmdDCe+QWThW8hPoNNlqMZ+FWbfBXbXpCuDYw9EYhLDlhuTAQMbqNt7kCzg==", + "dependencies": { + "Platform.Reflection": "0.7.0" + } + }, + "Platform.Threading": { + "type": "Transitive", + "resolved": "0.5.0", + "contentHash": "Q0p5oVDbhQ76IGHi/vYIX+de9T270XZmNgjNbHtobc+AoJbIWGf+kZXTzq+MmEfXS2ANtGNe9uWN1a7Nzp7olQ==", + "dependencies": { + "Platform.Collections": "0.4.0" + } + }, + "Platform.Timestamps": { + "type": "Transitive", + "resolved": "0.3.0", + "contentHash": "Lmhm8Vp0DobTlxSVTPZZE+3ABTrvuofcmTQOWZv6TyiZ5NyFBH6m61vZdnIkMa/3lQguKvGfgMsUT97p0t5x7w==", + "dependencies": { + "Platform.Interfaces": "0.5.0" + } + }, + "Platform.Unsafe": { + "type": "Transitive", + "resolved": "0.6.1", + "contentHash": "GbKgPC7lfw+q2GqQEch0NSxHOZXxoLTj3ChjdRZIQBvZnGWzYH6nat8glfTJseOmgdabIxlwogFECoai0O3akA==", + "dependencies": { + "Platform.Numbers": "0.7.0" + } + } + } + } +} \ No newline at end of file diff --git a/deep/links.sql b/deep/links.sql new file mode 100644 index 0000000..802b48a --- /dev/null +++ b/deep/links.sql @@ -0,0 +1,21 @@ +-- Deep core links table/indexes from deep-foundation/deeplinks +-- migrations/1616701513782-links.ts at 381207dda313ab3c1fed0a4bfc2ddbe631750411. +-- type_id defaults to 0 because the common benchmark contract is untyped. +-- Application triggers, permissions, computed values and materialized paths +-- belong to Deep's application layer and are outside this core-storage comparison. +CREATE TABLE public.links ( + id bigint PRIMARY KEY, + from_id bigint DEFAULT 0, + to_id bigint DEFAULT 0, + type_id bigint NOT NULL DEFAULT 0 +); +CREATE SEQUENCE links_id_seq AS bigint START WITH 1 INCREMENT BY 1 CACHE 1; +ALTER SEQUENCE links_id_seq OWNED BY public.links.id; +ALTER TABLE public.links ALTER COLUMN id SET DEFAULT nextval('links_id_seq'::regclass); +CREATE INDEX links__id_hash ON links USING hash (id); +CREATE INDEX links__from_id_hash ON links USING hash (from_id); +CREATE INDEX links__from_id_btree ON links USING btree (from_id); +CREATE INDEX links__to_id_hash ON links USING hash (to_id); +CREATE INDEX links__to_id_btree ON links USING btree (to_id); +CREATE INDEX links__type_id_hash ON links USING hash (type_id); +CREATE INDEX links__type_id_btree ON links USING btree (type_id); diff --git a/experiments/compose.local.yml b/experiments/compose.local.yml new file mode 100644 index 0000000..b094ff8 --- /dev/null +++ b/experiments/compose.local.yml @@ -0,0 +1,11 @@ +# Nested local Docker needs the host cgroup namespace and root cgroup parent. +# CI uses compose.yml unchanged on a regular runner. +services: + postgres: + mem_limit: 0 + cgroup_parent: / + cgroup: host + hasura: + mem_limit: 0 + cgroup_parent: / + cgroup: host diff --git a/experiments/repeat_workload.py b/experiments/repeat_workload.py new file mode 100644 index 0000000..fac903e --- /dev/null +++ b/experiments/repeat_workload.py @@ -0,0 +1,23 @@ +"""Bounded reproduction: batch updates after repeated create/delete on 1,000 links.""" + +import os +from pathlib import Path +import resource + +from scripts.run import server +from scripts.workload import Client, OPERATIONS, Settings, measure, seed + +resource.setrlimit(resource.RLIMIT_AS, (2 * 1024**3, 2 * 1024**3)) +resource.setrlimit(resource.RLIMIT_CPU, (60, 60)) +settings = Settings(10, 1000, 3, 1) +os.environ["BENCHMARK_TRACE"] = "1" +with server("rust", Path("results/reproduction")) as url: + client = Client(url) + try: + client.connection.timeout = 3 + ids = seed(client, settings) + for operation in OPERATIONS: + print(operation, flush=True) + print(measure(client, operation, settings, ids), flush=True) + finally: + client.close() diff --git a/experiments/repeated-mutations.md b/experiments/repeated-mutations.md new file mode 100644 index 0000000..9d12a32 --- /dev/null +++ b/experiments/repeated-mutations.md @@ -0,0 +1,44 @@ +# Repeated mutation regression + +The first Rust adapter used `doublets` 0.5.0's split store. Seeding 1,000 +`(i, i+1)` links, inserting/deleting temporary `(i, i)` pairs, and then updating +background targets to `1` made its native index traversal hang. The HTTP client +timed out after 30 seconds. A smaller native test with 20 background links and +three changed links reproduces the hang within a five-second bound. + +`rust/src/main.rs::tests::repeated_batch_mutations_preserve_indexes` exercises +the sequence without HTTP. With a split store its receiver timed out after five +seconds; with the united store the test completes, including its indexed lookup. +Both benchmark adapters therefore use united volatile stores. + +Run the retained bounded HTTP workload against the current Rust server: + +```bash +cargo build --manifest-path rust/Cargo.toml --release --locked +python3 -m experiments.repeat_workload +``` + +The script uses N=10, B=1,000, three samples, one warm-up request, a two-GiB +address-space limit, a 60-second CPU limit and a three-second HTTP timeout. +Tracing is enabled to identify the last successful operation. It completes all +eight operations with the united store. Trace runs are diagnostic rather than +published performance samples. + +To reproduce the original native failure in a disposable checkout, change the +`RawStore` alias to +`split::Store>, Global>>`, +import `doublets::split` instead of `doublets::unit`, and replace the two +`RawStore::new(Global::new())` calls with `RawStore::new(Global::new(), Global::new())`. +Then run only the regression test: + +```bash +cargo test --manifest-path rust/Cargo.toml --release --locked repeated_batch_mutations_preserve_indexes +``` + +Keep the five-second receiver bound. Do not run an unbounded mutation probe. + +The workload driver has a second correctness regression: +`test_successful_http_noop_is_not_a_measurement` supplies a successful update +response containing an unchanged target. Before response validation, it was +accepted as a sample; the driver now rejects it before emitting results. Both +regressions were observed failing before their respective fixes. diff --git a/global.json b/global.json new file mode 100644 index 0000000..614f5c9 --- /dev/null +++ b/global.json @@ -0,0 +1 @@ +{"sdk":{"version":"10.0.112","rollForward":"disable","allowPrerelease":false}} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e367238 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +matplotlib==3.10.7 +ruff==0.14.0 +graphql-core==3.2.6 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..b36f60f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.98.0" +components = ["rustfmt", "clippy"] +profile = "minimal" diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..12240d1 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,1218 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + +[[package]] +name = "ascii_utils" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" + +[[package]] +name = "async-graphql" +version = "7.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "036618f842229ba0b89652ffe425f96c7c16a49f7e3cb23b56fca7f61fd74980" +dependencies = [ + "async-graphql-derive", + "async-graphql-parser", + "async-graphql-value", + "async-stream", + "async-trait", + "base64", + "bytes", + "fast_chemail", + "fnv", + "futures-timer", + "futures-util", + "handlebars", + "http", + "indexmap", + "mime", + "multer", + "num-traits", + "pin-project-lite", + "regex", + "serde", + "serde_json", + "serde_urlencoded", + "static_assertions_next", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "async-graphql-derive" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e6cbeadc8515e66450fba0985ce722192e28443697799988265d86304d7cc68" +dependencies = [ + "Inflector", + "async-graphql-parser", + "darling", + "proc-macro-crate", + "proc-macro2", + "quote", + "strum", + "syn 2.0.119", + "thiserror 2.0.21", +] + +[[package]] +name = "async-graphql-parser" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64ef70f77a1c689111e52076da1cd18f91834bcb847de0a9171f83624b07fbf" +dependencies = [ + "async-graphql-value", + "pest", + "serde", + "serde_json", +] + +[[package]] +name = "async-graphql-value" +version = "7.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e3ef112905abea9dea592fc868a6873b10ebd3f983e83308f995d6284e9ba41" +dependencies = [ + "bytes", + "indexmap", + "serde", + "serde_json", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deepgql-doublets-rust" +version = "0.1.0" +dependencies = [ + "async-graphql", + "axum", + "doublets", + "serde_json", + "tokio", +] + +[[package]] +name = "doublets" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677be8ee593349204e8e7cbc2398d4b98fb95fd1386f3a7eaf286860a5788c8f" +dependencies = [ + "cfg-if", + "leak_slice", + "platform-data", + "platform-mem", + "platform-num", + "platform-trees", + "tap", + "thiserror 2.0.21", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fast_chemail" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "495a39d30d624c2caabe6312bfead73e7717692b44e0b32df168c275a2e8e9e4" +dependencies = [ + "ascii_utils", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "handlebars" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08485b96a0e6393e9e4d1b8d48cf74ad6c063cd905eb33f42c1ce3f0377539b" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leak_slice" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf3387da9fb41906394e1306ddd3cd26dd9b7177af11c19b45b364b743aed26" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +dependencies = [ + "pest", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "platform-data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6782bc71345465116de96d250a36dcf49336010a2320d958d12a5d4390186c90" +dependencies = [ + "beef", + "platform-num", + "thiserror 2.0.21", +] + +[[package]] +name = "platform-mem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27cff7c92440ac926c8c91ea3151db6e52a262f602d0c157f254e422fc15b12" +dependencies = [ + "allocator-api2", + "memmap2", + "tempfile", + "thiserror 2.0.21", +] + +[[package]] +name = "platform-num" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ca8e18138b1c90ad802aff931f946a0e6bd760c35af30f1ff2489489ab54a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "platform-trees" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e25a531617fa762c8505826c930f6c1cfcc226f63dea09882b56ae0b8ed078" +dependencies = [ + "platform-num", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "static_assertions_next" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7beae5182595e9a8b683fa98c4317f956c9a2dec3b9716990d20023cc60c766" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio" +version = "1.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..3f3d377 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "deepgql-doublets-rust" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +async-graphql = "=7.0.17" +axum = "=0.8.4" +doublets = "=0.5.0" +tokio = { version = "=1.53.0", features = ["macros", "rt-multi-thread", "net"] } +serde_json = "=1.0.145" diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..195debc --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,416 @@ +use async_graphql::{ + Context, EmptySubscription, Enum, InputObject, InputValueError, InputValueResult, Object, + Scalar, ScalarType, Schema, SimpleObject, Value, +}; +use axum::{Json, Router, extract::State, routing::post}; +use doublets::{Doublets, Links, data::Flow, mem::Global, unit}; +use std::sync::Mutex; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Bigint(i64); + +#[Scalar(name = "bigint")] +impl ScalarType for Bigint { + fn parse(value: Value) -> InputValueResult { + match value { + Value::Number(n) => n + .as_i64() + .map(Self) + .ok_or_else(|| InputValueError::custom("Expected signed 64-bit integer")), + Value::String(s) => s.parse().map(Self).map_err(InputValueError::custom), + value => Err(InputValueError::expected_type(value)), + } + } + fn to_value(&self) -> Value { + Value::from(self.0) + } +} + +#[derive(SimpleObject, Clone)] +#[graphql(name = "links", rename_fields = "snake_case")] +struct Link { + id: Bigint, + from_id: Option, + to_id: Option, +} + +#[derive(InputObject, Default)] +#[graphql(name = "bigint_comparison_exp")] +struct Comparison { + #[graphql(name = "_eq")] + eq: Option, + #[graphql(name = "_in")] + inside: Option>, + #[graphql(name = "_gte")] + gte: Option, + #[graphql(name = "_lte")] + lte: Option, +} +impl Comparison { + fn matches(&self, value: Bigint) -> bool { + self.eq.is_none_or(|v| v == value) + && self.inside.as_ref().is_none_or(|v| v.contains(&value)) + && self.gte.is_none_or(|v| value.0 >= v.0) + && self.lte.is_none_or(|v| value.0 <= v.0) + } +} + +#[derive(InputObject, Default)] +#[graphql(name = "links_bool_exp", rename_fields = "snake_case")] +struct Filter { + id: Option, + from_id: Option, + to_id: Option, + #[graphql(name = "_and")] + and: Option>, + #[graphql(name = "_or")] + or: Option>, + #[graphql(name = "_not")] + not: Option>, +} +impl Filter { + fn matches(&self, link: &Link) -> bool { + self.id.as_ref().is_none_or(|c| c.matches(link.id)) + && self + .from_id + .as_ref() + .is_none_or(|c| c.matches(link.from_id.unwrap())) + && self + .to_id + .as_ref() + .is_none_or(|c| c.matches(link.to_id.unwrap())) + && self + .and + .as_ref() + .is_none_or(|items| items.iter().all(|f| f.matches(link))) + && self + .or + .as_ref() + .is_none_or(|items| items.iter().any(|f| f.matches(link))) + && self.not.as_ref().is_none_or(|f| !f.matches(link)) + } +} + +#[derive(InputObject)] +#[graphql(name = "links_insert_input", rename_fields = "snake_case")] +struct Insert { + from_id: Option, + to_id: Option, +} +#[derive(InputObject)] +#[graphql(name = "links_set_input", rename_fields = "snake_case")] +struct Set { + from_id: Option, + to_id: Option, +} +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +#[graphql(name = "order_by", rename_items = "snake_case")] +enum Order { + Asc, + Desc, +} +#[derive(InputObject)] +#[graphql(name = "links_order_by")] +struct OrderBy { + id: Option, +} +#[derive(SimpleObject)] +#[graphql(name = "links_mutation_response", rename_fields = "snake_case")] +struct Response { + affected_rows: i32, + returning: Vec, +} +impl Response { + fn new(returning: Vec) -> Self { + Self { + affected_rows: returning.len() as i32, + returning, + } + } +} + +type RawStore = unit::Store>>; +struct Store(RawStore); +impl Store { + fn select(&self, filter: &Filter) -> Vec { + let constants = self.0.constants(); + let eq = |comparison: &Option| comparison.as_ref().and_then(|c| c.eq); + // Push equality restrictions into Doublets' native indexes, then evaluate all predicates. + let constraints = [eq(&filter.id), eq(&filter.from_id), eq(&filter.to_id)]; + if constraints.iter().flatten().any(|v| v.0 < 0) { + return Vec::new(); + } + let query = constraints.map(|v| v.map_or(constants.any, |v| v.0 as usize)); + let mut rows = Vec::new(); + let queries = if constraints[0].is_none() { + if let Some(ids) = filter.id.as_ref().and_then(|c| c.inside.as_ref()) { + ids.iter() + .filter(|id| id.0 >= 0) + .map(|id| id.0 as usize) + .collect::>() + .into_iter() + .map(|id| [id, query[1], query[2]]) + .collect::>() + } else { + vec![query] + } + } else { + vec![query] + }; + for query in queries { + self.0.each_by(query, |link| { + let row = Link { + id: Bigint(link.index as i64), + from_id: Some(Bigint(link.source as i64)), + to_id: Some(Bigint(link.target as i64)), + }; + if filter.matches(&row) { + rows.push(row); + } + Flow::Continue + }); + } + rows.sort_by_key(|row| row.id.0); + rows + } +} +struct Query; +#[Object( + name = "query_root", + rename_fields = "snake_case", + rename_args = "snake_case" +)] +impl Query { + async fn links( + &self, + ctx: &Context<'_>, + r#where: Option, + order_by: Option>, + limit: Option, + offset: Option, + ) -> async_graphql::Result> { + if limit.is_some_and(|n| n < 0) || offset.is_some_and(|n| n < 0) { + return Err("limit and offset must be nonnegative".into()); + } + let store = ctx + .data_unchecked::>() + .lock() + .map_err(|_| "Store lock poisoned")?; + let mut rows = store.select(&r#where.unwrap_or_default()); + if order_by + .as_ref() + .and_then(|items| items.iter().find_map(|order| order.id)) + == Some(Order::Desc) + { + rows.reverse(); + } + Ok(rows + .into_iter() + .skip(offset.unwrap_or(0) as usize) + .take(limit.map_or(usize::MAX, |n| n as usize)) + .collect()) + } +} +struct Mutation; +fn endpoint(value: Option, fallback: i64) -> async_graphql::Result { + let value = value.map_or(fallback, |v| v.0); + if value < 0 { + return Err("Doublets endpoints must be nonnegative".into()); + } + Ok(value as usize) +} +#[Object( + name = "mutation_root", + rename_fields = "snake_case", + rename_args = "snake_case" +)] +impl Mutation { + async fn insert_links( + &self, + ctx: &Context<'_>, + objects: Vec, + ) -> async_graphql::Result> { + let pairs = objects + .into_iter() + .map(|o| Ok((endpoint(o.from_id, 0)?, endpoint(o.to_id, 0)?))) + .collect::>>()?; + let mut store = ctx + .data_unchecked::>() + .lock() + .map_err(|_| "Store lock poisoned")?; + let mut rows = Vec::new(); + for (source, target) in pairs { + let id = store.0.create().map_err(|e| e.to_string())?; + store + .0 + .update(id, source, target) + .map_err(|e| e.to_string())?; + rows.push(Link { + id: Bigint(id as i64), + from_id: Some(Bigint(source as i64)), + to_id: Some(Bigint(target as i64)), + }); + } + Ok(Some(Response::new(rows))) + } + async fn update_links( + &self, + ctx: &Context<'_>, + r#where: Filter, + #[graphql(name = "_set")] set: Option, + ) -> async_graphql::Result> { + let mut store = ctx + .data_unchecked::>() + .lock() + .map_err(|_| "Store lock poisoned")?; + let set = set.ok_or("_set is required for update")?; + // Resolve the selection before changing index trees. + let rows = store.select(&r#where); + let updates = rows + .into_iter() + .map(|row| { + Ok(( + row.id, + endpoint(set.from_id, row.from_id.unwrap().0)?, + endpoint(set.to_id, row.to_id.unwrap().0)?, + )) + }) + .collect::>>()?; + let mut returning = Vec::new(); + for (id, source, target) in updates { + store + .0 + .update(id.0 as usize, source, target) + .map_err(|e| e.to_string())?; + returning.push(Link { + id, + from_id: Some(Bigint(source as i64)), + to_id: Some(Bigint(target as i64)), + }); + } + Ok(Some(Response::new(returning))) + } + async fn delete_links( + &self, + ctx: &Context<'_>, + r#where: Filter, + ) -> async_graphql::Result> { + let mut store = ctx + .data_unchecked::>() + .lock() + .map_err(|_| "Store lock poisoned")?; + let rows = store.select(&r#where); + for row in rows.iter().rev() { + store + .0 + .delete(row.id.0 as usize) + .map_err(|e| e.to_string())?; + } + Ok(Some(Response::new(rows))) + } +} + +type GqlSchema = Schema; +fn schema() -> GqlSchema { + Schema::build(Query, Mutation, EmptySubscription) + .data(Mutex::new(Store( + RawStore::new(Global::new()).expect("Doublets store"), + ))) + .limit_depth(32) + .finish() +} +async fn graphql( + State(schema): State, + Json(request): Json, +) -> Json { + Json(schema.execute(request).await) +} +#[tokio::main] +async fn main() { + let address = std::env::var("LISTEN_ADDRESS").unwrap_or_else(|_| "127.0.0.1:8001".into()); + let listener = tokio::net::TcpListener::bind(&address) + .await + .expect("Bind server"); + eprintln!("Rust Doublets GraphQL: {address}/v1/graphql"); + let app = Router::new() + .route("/v1/graphql", post(graphql)) + .with_state(schema()) + .layer(axum::extract::DefaultBodyLimit::max(8 * 1024 * 1024)); + axum::serve(listener, app).await.expect("HTTP server"); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn repeated_batch_mutations_preserve_indexes() { + let (done, result) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut store = Store(RawStore::new(Global::new()).unwrap()); + for i in 1..=20 { + let id = store.0.create().unwrap(); + store.0.update(id, i, i + 1).unwrap(); + } + for _ in 0..4 { + let ids = (1..=3) + .map(|i| { + let id = store.0.create().unwrap(); + store.0.update(id, i, i).unwrap(); + id + }) + .collect::>(); + for id in ids.into_iter().rev() { + store.0.delete(id).unwrap(); + } + } + for _ in 0..4 { + for i in 1..=3 { + store.0.update(i, i, 1).unwrap(); + } + for i in 1..=3 { + store.0.update(i, i, i + 1).unwrap(); + } + } + let rows = store.select(&Filter { + from_id: Some(Comparison { + eq: Some(Bigint(1)), + ..Default::default() + }), + ..Default::default() + }); + done.send(rows.iter().map(|row| row.id.0).collect::>()) + .unwrap(); + }); + assert_eq!( + result + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("Store hung during repeated create/delete/update"), + vec![1] + ); + } + + #[tokio::test] + async fn partial_update_and_negative_identity() { + let schema = schema(); + let inserted = schema + .execute( + "mutation { insert_links(objects: [{from_id: 1, to_id: 2}]) { affected_rows } }", + ) + .await; + assert!(inserted.errors.is_empty(), "{:?}", inserted.errors); + let update = schema.execute("mutation { update_links(where: {id: {_eq: 1}}, _set: {to_id: 3}) { returning { from_id to_id } } }").await; + assert!(update.errors.is_empty(), "{:?}", update.errors); + assert_eq!( + update.data.into_json().unwrap()["update_links"]["returning"][0], + serde_json::json!({"from_id":1,"to_id":3}) + ); + let query = schema + .execute("{ links(where: {id: {_eq: -1}}) { id } }") + .await; + assert!(query.errors.is_empty()); + assert_eq!( + query.data.into_json().unwrap(), + serde_json::json!({"links":[]}) + ); + } +} diff --git a/schema/links.graphql b/schema/links.graphql new file mode 100644 index 0000000..936c7e4 --- /dev/null +++ b/schema/links.graphql @@ -0,0 +1,26 @@ +schema { query: query_root mutation: mutation_root } +scalar bigint + +type links { id: bigint! from_id: bigint to_id: bigint } +type links_mutation_response { affected_rows: Int! returning: [links!]! } +input bigint_comparison_exp { _eq: bigint _in: [bigint!] _gte: bigint _lte: bigint } +input links_bool_exp { + id: bigint_comparison_exp + from_id: bigint_comparison_exp + to_id: bigint_comparison_exp + _and: [links_bool_exp!] + _or: [links_bool_exp!] + _not: links_bool_exp +} +input links_insert_input { from_id: bigint to_id: bigint } +input links_set_input { from_id: bigint to_id: bigint } +enum order_by { asc desc } +input links_order_by { id: order_by } +type query_root { + links(where: links_bool_exp, order_by: [links_order_by!], limit: Int, offset: Int): [links!]! +} +type mutation_root { + insert_links(objects: [links_insert_input!]!): links_mutation_response + update_links(where: links_bool_exp!, _set: links_set_input): links_mutation_response + delete_links(where: links_bool_exp!): links_mutation_response +} diff --git a/scripts/client.py b/scripts/client.py new file mode 100644 index 0000000..93e090e --- /dev/null +++ b/scripts/client.py @@ -0,0 +1,48 @@ +"""Persistent HTTP GraphQL client shared by conformance tests and measurements.""" + +import http.client +import json +import os +from urllib.parse import urlsplit + + +class Client: + def __init__(self, url): + parts = urlsplit(url) + if parts.scheme not in ("http", "https") or not parts.hostname: + raise ValueError(f"Invalid GraphQL URL: {url}") + connection = ( + http.client.HTTPSConnection + if parts.scheme == "https" + else http.client.HTTPConnection + ) + self.connection = connection(parts.hostname, parts.port, timeout=30) + self.path = parts.path or "/" + self.headers = {"Content-Type": "application/json"} + if os.environ.get("HASURA_ADMIN_SECRET"): + self.headers["x-hasura-admin-secret"] = os.environ["HASURA_ADMIN_SECRET"] + self.trace = os.environ.get("BENCHMARK_TRACE") == "1" + + def execute(self, query, variables=None): + body = json.dumps( + {"query": query, "variables": variables or {}}, separators=(",", ":") + ) + self.connection.request("POST", self.path, body, self.headers) + response = self.connection.getresponse() + data = response.read() + if self.trace: + import sys + + print( + f"GraphQL HTTP {response.status}: {query[:200]} ({len(data)} bytes)", + file=sys.stderr, + ) + if response.status != 200: + raise RuntimeError(f"GraphQL HTTP {response.status}: {data[:1000]!r}") + result = json.loads(data) + if result.get("errors") or "data" not in result: + raise RuntimeError(f"GraphQL failed: {result}") + return result["data"] + + def close(self): + self.connection.close() diff --git a/scripts/conformance.py b/scripts/conformance.py new file mode 100644 index 0000000..c056da8 --- /dev/null +++ b/scripts/conformance.py @@ -0,0 +1,174 @@ +"""Execute identical documents and compare every response with explicit expected rows.""" + +import argparse +from pathlib import Path + +from graphql import build_schema, get_introspection_query, print_type +from scripts.client import Client +from scripts.workload import DELETE, INSERT, UPDATE, QUERY + + +def normalized(value, sort_rows=False): + if isinstance(value, list): + rows = [normalized(item) for item in value] + return ( + sorted(rows, key=lambda r: r["id"]) + if sort_rows and rows and isinstance(rows[0], dict) and "id" in rows[0] + else rows + ) + if isinstance(value, dict): + return { + key: normalized(item, sort_rows=(key == "returning")) + for key, item in value.items() + } + # Hasura may serialize bigint as a JSON string; compare numeric scalar values. + return ( + int(value) if isinstance(value, str) and value.lstrip("-").isdigit() else value + ) + + +def verify_schema(client): + """Check all contract fields and types; Hasura may expose extra fields.""" + contract = build_schema( + (Path(__file__).resolve().parents[1] / "schema/links.graphql").read_text() + ) + remote = client.execute(get_introspection_query(descriptions=False)) + from graphql import build_client_schema + + actual = build_client_schema(remote) + for name, expected_type in contract.type_map.items(): + if name.startswith("__") or name in ("String", "Boolean", "Int"): + continue + actual_type = actual.get_type(name) + if actual_type is None: + raise AssertionError(f"Missing contract type {name}") + if hasattr(expected_type, "fields"): + for field_name, expected_field in expected_type.fields.items(): + actual_field = actual_type.fields.get(field_name) + if actual_field is None or str(actual_field.type) != str( + expected_field.type + ): + raise AssertionError( + f"Different contract field {name}.{field_name}" + ) + for arg_name, expected_arg in getattr( + expected_field, "args", {} + ).items(): + actual_arg = actual_field.args.get(arg_name) + if actual_arg is None or str(actual_arg.type) != str( + expected_arg.type + ): + raise AssertionError( + f"Different contract argument {name}.{field_name}({arg_name})" + ) + elif hasattr(expected_type, "values"): + if not set(expected_type.values) <= set(actual_type.values): + raise AssertionError(f"Missing enum values for {name}") + elif print_type(expected_type) != print_type(actual_type): + raise AssertionError(f"Different scalar {name}") + + +def verify(url): + client = Client(url) + responses = [] + + def check(query, variables, expected): + actual = normalized(client.execute(query, variables)) + if actual != expected: + raise AssertionError( + f"{url}: {query}\nexpected {expected}\nactual {actual}" + ) + responses.append(actual) + + rows = [ + {"id": i + 1, "from_id": source, "to_id": target} + for i, (source, target) in enumerate([(1, 1), (1, 2), (2, 1), (2, 3), (3, 2)]) + ] + try: + verify_schema(client) + check(QUERY, {"where": {}}, {"links": []}) + check( + INSERT, + {"objects": [{k: v for k, v in row.items() if k != "id"} for row in rows]}, + {"insert_links": {"affected_rows": 5, "returning": rows}}, + ) + for where, indices in [ + ({}, range(5)), + ({"id": {"_eq": 3}}, [2]), + ({"from_id": {"_eq": 1}, "to_id": {"_eq": 2}}, [1]), + ({"from_id": {"_eq": 1}}, [0, 1]), + ({"to_id": {"_eq": 1}}, [0, 2]), + ({"to_id": {"_eq": 2}}, [1, 4]), + ({"id": {"_in": []}}, []), + ({"id": {"_in": [1, 5]}}, [0, 4]), + ({"id": {"_in": [1, 1, 5, -1, 999]}}, [0, 4]), + ({"id": {"_gte": 2, "_lte": 4}}, [1, 2, 3]), + ({"_or": [{"id": {"_eq": 1}}, {"id": {"_eq": 5}}]}, [0, 4]), + ({"_and": [{"from_id": {"_eq": 1}}, {"_not": {"id": {"_eq": 1}}}]}, [1]), + ({"id": {"_eq": -1}}, []), + ({"_or": []}, []), + ({"_and": []}, range(5)), + ]: + check(QUERY, {"where": where}, {"links": [rows[i] for i in indices]}) + check( + "query { links(order_by: {id: desc}, limit: 2, offset: 1) { id from_id to_id } }", + {}, + {"links": [rows[3], rows[2]]}, + ) + changed = {**rows[3], "to_id": 2} + check( + UPDATE, + {"where": {"id": {"_eq": 4}}, "set": {"to_id": 2}}, + {"update_links": {"affected_rows": 1, "returning": [changed]}}, + ) + rows[3] = changed + check(QUERY, {"where": {}}, {"links": rows}) + check( + UPDATE, + {"where": {"id": {"_eq": 999}}, "set": {"from_id": 1}}, + {"update_links": {"affected_rows": 0, "returning": []}}, + ) + check( + DELETE, + {"where": {"id": {"_in": [2, 4]}}}, + {"delete_links": {"affected_rows": 2, "returning": [rows[1], rows[3]]}}, + ) + check(QUERY, {"where": {}}, {"links": [rows[i] for i in [0, 2, 4]]}) + check( + DELETE, + {"where": {"id": {"_eq": 999}}}, + {"delete_links": {"affected_rows": 0, "returning": []}}, + ) + check( + DELETE, + {"where": {}}, + { + "delete_links": { + "affected_rows": 3, + "returning": [rows[i] for i in [0, 2, 4]], + } + }, + ) + check(QUERY, {"where": {}}, {"links": []}) + return responses + finally: + client.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "urls", nargs="+", help="Fresh, dedicated GraphQL endpoints (all three in CI)" + ) + args = parser.parse_args() + baseline = None + for url in args.urls: + responses = verify(url) + if baseline is not None and responses != baseline: + raise AssertionError(f"Server {url} differs from baseline") + baseline = responses + print(f"{url}: {len(responses)} conformance responses passed") + + +if __name__ == "__main__": + main() diff --git a/scripts/init_deep.py b/scripts/init_deep.py new file mode 100644 index 0000000..6db1b01 --- /dev/null +++ b/scripts/init_deep.py @@ -0,0 +1,58 @@ +"""Wait for Hasura and track the freshly initialized Deep core links table.""" + +import argparse +import json +import time +from scripts.client import Client + + +def request(url, path, body=None, timeout=30): + # The shared client permits only HTTP and HTTPS, including for health/metadata. + client = Client(url) + client.connection.timeout = timeout + try: + client.connection.request( + "POST" if body is not None else "GET", + client.path.rstrip("/") + path, + json.dumps(body) if body is not None else None, + client.headers, + ) + response = client.connection.getresponse() + data = response.read().decode() + if response.status != 200: + raise RuntimeError(f"Hasura HTTP {response.status}: {data[:1000]}") + return data + finally: + client.close() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--url", default="http://127.0.0.1:8080") + args = parser.parse_args() + for attempt in range(60): + try: + request(args.url, "/healthz", timeout=2) + break + except (OSError, RuntimeError): + pass + time.sleep(1) + else: + raise RuntimeError("Hasura did not become healthy") + print( + request( + args.url, + "/v1/metadata", + { + "type": "pg_track_table", + "args": { + "source": "default", + "table": {"schema": "public", "name": "links"}, + }, + }, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/report.py b/scripts/report.py new file mode 100644 index 0000000..53d0992 --- /dev/null +++ b/scripts/report.py @@ -0,0 +1,239 @@ +"""Validated comparison tables and linear/log charts, following sibling repositories.""" + +import argparse +import json +import math +from pathlib import Path +import statistics + +from scripts.workload import BACKENDS, OPERATIONS + +START = "" +END = "" + + +def ratio(baseline, value, baseline_sd, sd): + if ( + abs(baseline - value) / max(baseline, value) < 0.05 + or abs(baseline - value) <= baseline_sd + sd + ): + return "≈ same" + return ( + f"{baseline / value:.3g}× faster" + if value < baseline + else f"{value / baseline:.3g}× slower" + ) + + +def validate_statistics(data, samples, label): + values = data.get("samples_ns", []) + median, sd = data.get("median_ns"), data.get("stdev_ns") + if len(values) != samples or any( + not isinstance(v, (int, float)) or not math.isfinite(v) or v <= 0 + for v in values + ): + raise ValueError(f"Invalid samples for {label}") + if ( + not isinstance(median, (int, float)) + or not math.isfinite(median) + or median <= 0 + or not isinstance(sd, (int, float)) + or not math.isfinite(sd) + or sd < 0 + ): + raise ValueError(f"Invalid statistics for {label}") + if not math.isclose(median, statistics.median(values)) or not math.isclose( + sd, statistics.stdev(values), abs_tol=1e-6 + ): + raise ValueError(f"Statistics do not match samples for {label}") + + +def validate_provenance(metadata): + if any(not metadata.get(k) for k in ["cpu", "date", "run", "commit", "versions"]): + raise ValueError("Incomplete provenance") + if not isinstance(metadata["versions"], dict) or not all( + isinstance(v, str) for v in metadata["versions"].values() + ): + raise ValueError("Invalid versions") + + +def validate_record(record): + backend, size = record.get("backend"), record.get("background_links") + if ( + backend not in BACKENDS + or not isinstance(size, int) + or not 1 <= size <= 1_000_000 + ): + raise ValueError("Unknown backend or invalid size") + parameters = tuple(record.get(k) for k in ["links", "samples", "warmup"]) + n, samples, warmup = parameters + if not all(isinstance(v, int) for v in parameters) or not ( + 1 <= n <= size and 2 <= samples <= 1000 and 0 <= warmup <= 100 + ): + raise ValueError("Invalid measurement settings") + validate_provenance(record.get("metadata", {})) + operations = record.get("operations", {}) + if set(operations) != set(OPERATIONS): + raise ValueError(f"Missing or unknown operations for {(size, backend)}") + for name, data in operations.items(): + validate_statistics(data, samples, f"{(size, backend)} {name}") + return size, backend, parameters + + +def validate(records): + if not records: + raise ValueError("No result files") + seen = set() + groups = {} + for record in records: + size, backend, parameters = validate_record(record) + key = (size, backend) + if key in seen: + raise ValueError(f"Duplicate result {key}") + seen.add(key) + if size in groups and groups[size] != parameters: + raise ValueError(f"Different workload settings for size {size}") + groups[size] = parameters + for size in groups: + if any((size, backend) not in seen for backend in BACKENDS): + raise ValueError(f"Missing backend for size {size}") + return groups + + +def load_results(directory): + records = [ + json.loads(p.read_text()) for p in sorted(Path(directory).rglob("*.json")) + ] + validate(records) + return records + + +def duration(ns): + if ns < 1000: + return f"{ns:.3g} ns" + if ns < 1_000_000: + return f"{ns / 1000:.3g} µs" + if ns < 1_000_000_000: + return f"{ns / 1_000_000:.3g} ms" + return f"{ns / 1_000_000_000:.3g} s" + + +def render(records, charts="Docs"): + groups = validate(records) + text = [ + "Times are medians per HTTP request (including client encoding/decoding, network, GraphQL and storage); ± values are sample standard deviations. Ratios compare each Doublets server with Deep. Differences below 5% or overlapping ±1 SD ranges are shown as ≈ same.", + "", + ] + for size, (n, samples, warmup) in sorted(groups.items()): + by_backend = {r["backend"]: r for r in records if r["background_links"] == size} + text += [ + f"### B = {size:,}, N = {n:,}", + "", + f"{samples} samples after {warmup} warm-up requests per operation. Create/update/delete act on N links; Each All returns B links; the other queries return one link.", + "", + ] + for backend in BACKENDS: + m = by_backend[backend]["metadata"] + versions = ", ".join( + f"{key} {value}" for key, value in sorted(m["versions"].items()) + ) + run = f"[run]({m['run']})" if m["run"] != "local" else "local run" + text.append( + f"- **{backend}**: {versions}; CPU: {m['cpu']}; {m['date']}; {run}; commit `{m['commit']}`." + ) + text += [ + "", + "| Operation | Deep (PostgreSQL + Hasura) | Doublets Rust | Doublets C# |", + "|---|---:|---:|---:|", + ] + for operation in OPERATIONS: + baseline = by_backend["deep"]["operations"][operation] + cells = [] + for backend in BACKENDS: + data = by_backend[backend]["operations"][operation] + cell = f"{duration(data['median_ns'])} ± {duration(data['stdev_ns'])}" + if backend != "deep": + cell += f" (**{ratio(baseline['median_ns'], data['median_ns'], baseline['stdev_ns'], data['stdev_ns'])}**)" + cells.append(cell) + text.append(f"| {operation} | " + " | ".join(cells) + " |") + text += [ + "", + f"![Linear chart B={size}]({charts}/bench_{size}.png)", + "", + f"![Log chart B={size}]({charts}/bench_log_{size}.png)", + "", + ] + return "\n".join(text).strip() + + +def replace_section(readme, section): + if ( + readme.count(START) != 1 + or readme.count(END) != 1 + or readme.index(START) >= readme.index(END) + ): + raise ValueError("README must have exactly one ordered pair of result markers") + before, rest = readme.split(START) + _, after = rest.split(END) + return before + START + "\n" + section + "\n" + END + after + + +def plot(records, directory): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + groups = validate(records) + directory.mkdir(parents=True, exist_ok=True) + for size, (n, _, _) in sorted(groups.items()): + by_backend = {r["backend"]: r for r in records if r["background_links"] == size} + for log in (False, True): + fig, ax = plt.subplots(figsize=(12, 5)) + for index, backend in enumerate(BACKENDS): + values = [ + by_backend[backend]["operations"][op]["median_ns"] / 1e6 + for op in OPERATIONS + ] + ax.bar( + [i + (index - 1) * 0.26 for i in range(len(OPERATIONS))], + values, + width=0.26, + label=backend, + ) + ax.set_xticks(range(len(OPERATIONS)), OPERATIONS, rotation=20, ha="right") + ax.set_ylabel("Median HTTP request time (ms)") + ax.set_title(f"GraphQL comparison · B={size:,}, N={n:,}") + if log: + ax.set_yscale("log") + else: + ax.set_ylim(bottom=0) + ax.legend() + fig.tight_layout() + fig.savefig(directory / f"bench_{'log_' if log else ''}{size}.png", dpi=150) + plt.close(fig) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("results", type=Path) + parser.add_argument("--readme", type=Path, default=Path("README.md")) + parser.add_argument("--charts", type=Path, default=Path("Docs")) + args = parser.parse_args() + records = load_results(args.results) + section = render(records, args.charts.as_posix()) + updated = replace_section(args.readme.read_text(), section) + plot(records, args.charts) + args.readme.write_text(updated) + # Retain the raw samples/provenance alongside the generated figures. + output = args.charts / "results" + output.mkdir(parents=True, exist_ok=True) + for record in records: + (output / f"{record['backend']}-{record['background_links']}.json").write_text( + json.dumps(record, indent=2, allow_nan=False) + "\n" + ) + print(section) + + +if __name__ == "__main__": + main() diff --git a/scripts/run.py b/scripts/run.py new file mode 100644 index 0000000..958039d --- /dev/null +++ b/scripts/run.py @@ -0,0 +1,162 @@ +"""Start dedicated servers, run tests/benchmarks, and stop every owned process.""" + +import argparse +from contextlib import ExitStack, contextmanager +import os +from pathlib import Path +import resource +import subprocess +import sys +import time + +from scripts.client import Client +from scripts.workload import BACKENDS + +ROOT = Path(__file__).resolve().parents[1] +URLS = { + "deep": "http://127.0.0.1:8080/v1/graphql", + "rust": "http://127.0.0.1:8001/v1/graphql", + "csharp": "http://127.0.0.1:8002/v1/graphql", +} + + +def compose(): + command = ["docker", "compose", "-f", str(ROOT / "compose.yml")] + if os.environ.get("COMPOSE_OVERRIDE"): + command += ["-f", os.environ["COMPOSE_OVERRIDE"]] + return command + + +def bounded_process(): + # Finite workload settings and per-server virtual-memory/stack limits bound probes. + for kind, requested in [ + (resource.RLIMIT_AS, 4 * 1024**3), + (resource.RLIMIT_STACK, 32 * 1024**2), + ]: + _, hard = resource.getrlimit(kind) + soft = requested if hard == resource.RLIM_INFINITY else min(requested, hard) + resource.setrlimit(kind, (soft, hard)) + + +@contextmanager +def server(backend, log_directory): + log_directory.mkdir(parents=True, exist_ok=True) + with (log_directory / f"{backend}-server.log").open("w") as log: + process = None + try: + if backend == "deep": + subprocess.run( + compose() + ["up", "-d"], + check=True, + stdout=log, + stderr=subprocess.STDOUT, + ) + subprocess.run( + [sys.executable, "-m", "scripts.init_deep"], + check=True, + cwd=ROOT, + stdout=log, + stderr=subprocess.STDOUT, + ) + else: + command = ( + [str(ROOT / "rust/target/release/deepgql-doublets-rust")] + if backend == "rust" + else [ + "dotnet", + str(ROOT / "csharp/bin/Release/net10.0/Doublets.Gql.dll"), + ] + ) + # .NET reserves a large virtual address space; bound its managed heap instead. + environment = {**os.environ, "DOTNET_GCHeapHardLimit": "0x80000000"} + process = subprocess.Popen( + command, + stdout=log, + stderr=subprocess.STDOUT, + cwd=ROOT, + env=environment, + preexec_fn=bounded_process if backend == "rust" else None, + ) + for attempt in range(60): + if process is not None and process.poll() is not None: + raise RuntimeError(f"{backend} exited; see {log.name}") + try: + client = Client(URLS[backend]) + try: + client.execute("query { links(limit: 0) { id } }") + finally: + client.close() + break + except (OSError, RuntimeError): + time.sleep(1) + else: + raise RuntimeError(f"{backend} not ready; see {log.name}") + yield URLS[backend] + finally: + if process is not None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + if backend == "deep": + subprocess.run( + compose() + ["logs", "--no-color"], + stdout=log, + stderr=subprocess.STDOUT, + ) + subprocess.run( + compose() + ["down", "-v"], + check=True, + stdout=log, + stderr=subprocess.STDOUT, + ) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["conformance", "benchmark"]) + parser.add_argument("--backend", choices=BACKENDS) + parser.add_argument("--output", type=Path, default=Path("results")) + args = parser.parse_args() + if args.mode == "conformance": + from scripts.conformance import verify + + with ExitStack() as stack: + urls = [ + stack.enter_context(server(b, args.output / "logs")) for b in BACKENDS + ] + baseline = None + for url in urls: + responses = verify(url) + if baseline is not None and responses != baseline: + raise AssertionError(f"{url} differs from baseline") + baseline = responses + print( + f"{url}: {len(responses)} identical conformance responses passed", + flush=True, + ) + else: + if not args.backend: + parser.error("benchmark requires --backend") + from scripts.workload import Settings + + size = Settings.from_env().background + with server(args.backend, args.output / "logs") as url: + subprocess.run( + [ + sys.executable, + "-m", + "scripts.workload", + args.backend, + url, + str(args.output / f"{args.backend}-{size}.json"), + ], + cwd=ROOT, + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/workload.py b/scripts/workload.py new file mode 100644 index 0000000..e85f23d --- /dev/null +++ b/scripts/workload.py @@ -0,0 +1,350 @@ +"""One workload for all servers; only the endpoint varies.""" + +import argparse +from dataclasses import dataclass +from datetime import datetime, timezone +import json +import os +from pathlib import Path +import platform +import statistics +import subprocess +import time + +from scripts.client import Client + +OPERATIONS = ( + "Create", + "Update", + "Delete", + "Each All", + "Each Identity", + "Each Concrete", + "Each Outgoing", + "Each Incoming", +) +BACKENDS = ("deep", "rust", "csharp") +FIELDS = "id from_id to_id" +INSERT = f"mutation($objects: [links_insert_input!]!) {{ insert_links(objects: $objects) {{ affected_rows returning {{ {FIELDS} }} }} }}" +DELETE = f"mutation($where: links_bool_exp!) {{ delete_links(where: $where) {{ affected_rows returning {{ {FIELDS} }} }} }}" +UPDATE = f"mutation($where: links_bool_exp!, $set: links_set_input!) {{ update_links(where: $where, _set: $set) {{ affected_rows returning {{ {FIELDS} }} }} }}" +QUERY = f"query($where: links_bool_exp) {{ links(where: $where, order_by: {{id: asc}}) {{ {FIELDS} }} }}" + + +@dataclass(frozen=True) +class Settings: + links: int = 100 + background: int = 1000 + samples: int = 10 + warmup: int = 2 + + @classmethod + def from_env(cls): + values = [ + int(os.environ.get(name, default)) + for name, default in [ + ("BENCHMARK_LINKS", 100), + ("BENCHMARK_BACKGROUND_LINKS", 1000), + ("BENCHMARK_SAMPLES", 10), + ("BENCHMARK_WARMUP", 2), + ] + ] + n, b, samples, warmup = values + if not ( + 1 <= n <= b <= 1_000_000 + and b + n < 1_040_384 + and 2 <= samples <= 1000 + and 0 <= warmup <= 100 + ): + raise ValueError( + "Require 1 <= N <= B <= 1000000, B+N < 1040384, 2..1000 samples, 0..100 warmups" + ) + return cls(*values) + + +@dataclass +class Scenario: + query: str + variables: dict + prepare: object = None + undo: object = None + + +def objects(start, count): + # Distinct pairs, so the Doublets pair uniqueness rule does not change the work. + return [{"from_id": i, "to_id": i + 1} for i in range(start, start + count)] + + +def scenario(operation, n, ids): + where = {"id": {"_in": ids[:n]}} + if operation == "Create": + + def undo(client, data): + created = data["insert_links"]["returning"] + client.execute( + DELETE, + {"where": {"id": {"_in": [int(link["id"]) for link in created]}}}, + ) + + return Scenario( + INSERT, + {"objects": [{"from_id": i, "to_id": i} for i in range(1, n + 1)]}, + undo=undo, + ) + if operation == "Update": + + def undo(client, data): + for link in data["update_links"]["returning"]: + # Seeding assigns pair (i, i+1) to the i-th background link. + index = ids.index(int(link["id"])) + 1 + client.execute( + UPDATE, + { + "where": {"id": {"_eq": int(link["id"])}}, + "set": {"from_id": index, "to_id": index + 1}, + }, + ) + + return Scenario( + UPDATE, + {"where": where, "set": {"to_id": 1}}, + prepare=lambda c: None, + undo=undo, + ) + if operation == "Delete": + # Fresh temporary links each iteration keep the background intact; allocator ids may differ. + current = {} + + def prepare(client): + inserted = client.execute( + INSERT, + {"objects": [{"from_id": i, "to_id": i} for i in range(1, n + 1)]}, + )["insert_links"]["returning"] + current.update( + where={"id": {"_in": [int(link["id"]) for link in inserted]}} + ) + + return Scenario(DELETE, current, prepare=prepare, undo=lambda c, d: None) + filters = { + "Each All": {}, + "Each Identity": {"id": {"_eq": ids[0]}}, + "Each Concrete": {"from_id": {"_eq": 1}, "to_id": {"_eq": 2}}, + "Each Outgoing": {"from_id": {"_eq": 1}}, + "Each Incoming": {"to_id": {"_eq": 2}}, + } + if operation not in filters: + raise ValueError(f"Unknown operation {operation}") + # Named operation makes documents distinct, including outgoing and concrete variants. + query = QUERY.replace("query(", f"query {operation.replace(' ', '')}(") + return Scenario(query, {"where": filters[operation]}) + + +def seed(client, settings): + # Refuse to destroy arbitrary data on a supplied endpoint. + if client.execute("query { links(limit: 1) { id } }")["links"]: + raise ValueError( + "Benchmark endpoint must be empty; start a fresh dedicated server/database" + ) + ids = [] + for start in range(1, settings.background + 1, 1000): + batch = objects(start, min(1000, settings.background - start + 1)) + data = client.execute(INSERT, {"objects": batch})["insert_links"] + if data["affected_rows"] != len(batch): + raise ValueError("Seeding did not create every link") + ids.extend(int(link["id"]) for link in data["returning"]) + return ids + + +def verify_result(operation, data, settings, ids, variables): + """Validate outside timing so successful no-ops cannot become benchmark results.""" + if operation in OPERATIONS[:3]: + field = { + "Create": "insert_links", + "Update": "update_links", + "Delete": "delete_links", + }[operation] + response = data[field] + rows = response["returning"] + if response["affected_rows"] != settings.links or len(rows) != settings.links: + raise ValueError(f"{operation} did not affect N links") + actual = sorted( + (int(row["id"]), int(row["from_id"]), int(row["to_id"])) for row in rows + ) + if operation == "Update": + expected = sorted( + (identity, index + 1, 1) + for index, identity in enumerate(ids[: settings.links]) + ) + valid = actual == expected + else: + valid = len({row[0] for row in actual}) == settings.links and sorted( + row[1:] for row in actual + ) == [(i, i) for i in range(1, settings.links + 1)] + if operation == "Delete": + valid = valid and {row[0] for row in actual} == set( + variables["where"]["id"]["_in"] + ) + if not valid: + raise ValueError(f"{operation} returned incorrect links") + else: + rows = data["links"] + count = settings.background if operation == "Each All" else 1 + if len(rows) != count: + raise ValueError(f"{operation} returned an incorrect row count") + for index, row in enumerate(rows): + if (int(row["id"]), int(row["from_id"]), int(row["to_id"])) != ( + ids[index], + index + 1, + index + 2, + ): + raise ValueError(f"{operation} returned incorrect links") + + +def measure(client, operation, settings, ids): + case = scenario(operation, settings.links, ids) + samples = [] + for index in range(settings.warmup + settings.samples): + if case.prepare: + case.prepare(client) + start = time.perf_counter_ns() + data = client.execute(case.query, case.variables) + elapsed = time.perf_counter_ns() - start + verify_result(operation, data, settings, ids, case.variables) + if case.undo: + case.undo(client, data) + if index >= settings.warmup: + samples.append(elapsed) + return { + "median_ns": statistics.median(samples), + "stdev_ns": statistics.stdev(samples), + "samples_ns": samples, + } + + +def metadata(backend): + import tomllib + + root = Path(__file__).resolve().parents[1] + lock = tomllib.loads((root / "rust/Cargo.lock").read_text()) + versions = { + p["name"]: p["version"] + for p in lock["package"] + if p["name"] in ("doublets", "async-graphql", "axum") + } + import xml.etree.ElementTree as ET + + # Pinned source manifest in this checkout; no external XML is accepted. + project = ET.parse(root / "csharp/Doublets.Gql.csproj") # nosec B314 + versions.update( + { + p.attrib["Include"]: p.attrib["Version"] + for p in project.iter("PackageReference") + } + ) + versions["postgres"] = "16.13-alpine" + versions["hasura"] = "v2.48.13" + versions["deep-core-schema"] = "381207dda313ab3c1fed0a4bfc2ddbe631750411" + versions["rust"] = ( + (root / "rust-toolchain.toml").read_text().split('channel = "')[1].split('"')[0] + ) + versions["dotnet-sdk"] = json.loads((root / "global.json").read_text())["sdk"][ + "version" + ] + versions["dotnet-runtime"] = project.findtext(".//RuntimeFrameworkVersion") + if backend == "deep": + import re + + versions.update( + { + "image-" + image.split(":", 1)[0]: image + for image in re.findall( + r"image: (\S+)", (root / "compose.yml").read_text() + ) + } + ) + selected = { + "deep": {"postgres", "hasura", "deep-core-schema"}, + "rust": {"doublets", "async-graphql", "axum", "rust"}, + "csharp": { + "Platform.Data.Doublets", + "GraphQL", + "GraphQL.SystemTextJson", + "dotnet-sdk", + "dotnet-runtime", + }, + } + versions = { + key: value + for key, value in versions.items() + if key in selected[backend] or key.startswith("image-") + } + versions["python"] = platform.python_version() + cpu = platform.processor() or platform.machine() + if Path("/proc/cpuinfo").exists(): + cpu = next( + ( + line.split(":", 1)[1].strip() + for line in Path("/proc/cpuinfo").read_text().splitlines() + if line.startswith("model name") + ), + cpu, + ) + repo = os.environ.get( + "GITHUB_REPOSITORY", "linksplatform/Comparisons.DeepGqlStandard" + ) + run_id = os.environ.get("GITHUB_RUN_ID") + return { + "cpu": cpu, + "date": datetime.now(timezone.utc).isoformat(), + "run": f"https://github.com/{repo}/actions/runs/{run_id}" + if run_id + else "local", + "commit": os.environ.get("GITHUB_SHA") + or subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(), + "versions": versions, + } + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("backend", choices=BACKENDS) + parser.add_argument("url") + parser.add_argument("output", type=Path) + args = parser.parse_args() + settings = Settings.from_env() + client = Client(args.url) + try: + ids = seed(client, settings) + operations = {} + for operation in OPERATIONS: + operations[operation] = measure(client, operation, settings, ids) + print( + f"{args.backend} B={settings.background} {operation}: {operations[operation]['median_ns'] / 1e6:.3f} ms", + flush=True, + ) + # Ensure all mutation undo steps preserved the original dataset. + expected = [ + {"id": identity, **pair} + for identity, pair in zip(ids, objects(1, settings.background)) + ] + actual = client.execute(QUERY, {"where": {}})["links"] + actual = [{key: int(value) for key, value in link.items()} for link in actual] + if actual != expected: + raise ValueError("Benchmark mutations changed the background dataset") + record = { + "backend": args.backend, + "background_links": settings.background, + "links": settings.links, + "samples": settings.samples, + "warmup": settings.warmup, + "metadata": metadata(args.backend), + "operations": operations, + } + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(record, indent=2, allow_nan=False) + "\n") + finally: + client.close() + + +if __name__ == "__main__": + main() diff --git a/tests/test_comparison.py b/tests/test_comparison.py new file mode 100644 index 0000000..018393d --- /dev/null +++ b/tests/test_comparison.py @@ -0,0 +1,116 @@ +import unittest +from unittest.mock import Mock, patch + +from scripts.workload import OPERATIONS, Scenario, Settings, measure, scenario + + +class WorkloadTests(unittest.TestCase): + def test_hasura_initialization_rejects_non_http_urls(self): + import tempfile + from pathlib import Path + from scripts.init_deep import main + + with tempfile.TemporaryDirectory() as directory: + with ( + patch("sys.argv", ["init_deep", "--url", Path(directory).as_uri()]), + patch("scripts.init_deep.time.sleep"), + self.assertRaisesRegex(ValueError, "Invalid GraphQL URL"), + ): + main() + + def test_normalization_preserves_query_order(self): + from scripts.conformance import normalized + + value = { + "links": [{"id": "2"}, {"id": "1"}], + "delete_links": {"returning": [{"id": "2"}, {"id": "1"}]}, + } + actual = normalized(value) + self.assertEqual(actual["links"], [{"id": 2}, {"id": 1}]) + self.assertEqual(actual["delete_links"]["returning"], [{"id": 1}, {"id": 2}]) + + def test_nonempty_endpoint_is_rejected(self): + from scripts.workload import seed + + client = Mock() + client.execute.return_value = {"links": [{"id": 1}]} + with self.assertRaises(ValueError): + seed(client, Settings()) + self.assertEqual(client.execute.call_count, 1) + + def test_graphql_errors_are_not_measurements(self): + from scripts.client import Client + + client = Client("http://localhost:8001/v1/graphql") + response = Mock(status=200) + response.read.return_value = b'{"errors":[{"message":"failed"}],"data":null}' + client.connection = Mock() + client.connection.getresponse.return_value = response + with self.assertRaises(RuntimeError): + client.execute("{ links { id } }") + + def test_sizes_are_bounded_and_validated(self): + for name, value in [ + ("BENCHMARK_LINKS", "0"), + ("BENCHMARK_BACKGROUND_LINKS", "1040384"), + ("BENCHMARK_SAMPLES", "1"), + ("BENCHMARK_LINKS", "oops"), + ]: + with ( + self.subTest(name=name), + patch.dict("os.environ", {name: value}, clear=True), + ): + with self.assertRaises(ValueError): + Settings.from_env() + + def test_all_variants_use_one_shared_document(self): + self.assertEqual(len(OPERATIONS), 8) + docs = [scenario(op, 3, [1, 2, 3]).query for op in OPERATIONS] + self.assertEqual(len(set(docs)), 8) + self.assertTrue(all("returning" in doc for doc in docs[:3])) + + def test_setup_and_undo_are_outside_timer(self): + client = Mock() + events = [] + case = Scenario( + "query", + {}, + prepare=lambda c: events.append("setup"), + undo=lambda c, data: events.append("undo"), + ) + client.execute.side_effect = lambda *args: events.append("request") or { + "links": [{"id": i, "from_id": i, "to_id": i + 1} for i in [1, 2, 3]] + } + with ( + patch("scripts.workload.scenario", return_value=case), + patch( + "scripts.workload.time.perf_counter_ns", + side_effect=lambda: events.append("clock") or len(events), + ), + ): + measure(client, "Each All", Settings(3, 3, 2, 0), [1, 2, 3]) + self.assertEqual( + events, + ["setup", "clock", "request", "clock", "undo"] * 2, + ) + + def test_successful_http_noop_is_not_a_measurement(self): + client = Mock() + client.execute.return_value = { + "update_links": { + "affected_rows": 1, + "returning": [{"id": 1, "from_id": 1, "to_id": 2}], + } + } + with self.assertRaisesRegex(ValueError, "Update"): + measure(client, "Update", Settings(1, 1, 2, 0), [1]) + + def test_mutations_have_undo_and_setup(self): + for op in OPERATIONS[:3]: + case = scenario(op, 3, [1, 2, 3]) + self.assertIsNotNone(case.undo) + self.assertIsNotNone(case.prepare if op != "Create" else case.undo) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_report.py b/tests/test_report.py new file mode 100644 index 0000000..1cbb555 --- /dev/null +++ b/tests/test_report.py @@ -0,0 +1,91 @@ +import tempfile +import unittest +from pathlib import Path + +from scripts.report import load_results, ratio, render +from scripts.workload import OPERATIONS + + +def result(backend="deep", size=10): + return { + "backend": backend, + "background_links": size, + "links": 3, + "samples": 2, + "warmup": 0, + "metadata": { + "cpu": "test CPU", + "date": "2026-10-06", + "run": "https://github.com/linksplatform/Comparisons.DeepGqlStandard/actions/runs/1", + "commit": "a" * 40, + "versions": {"test": "1"}, + }, + "operations": { + op: {"median_ns": 100, "stdev_ns": 0, "samples_ns": [100, 100]} + for op in OPERATIONS + }, + } + + +class ReportTests(unittest.TestCase): + def test_generated_raw_archive_can_be_committed(self): + import subprocess + + root = Path(__file__).resolve().parents[1] + result = subprocess.run( + ["git", "check-ignore", "--no-index", "--", "Docs/results/deep-1000.json"], + cwd=root, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + + def test_ratios_and_noise(self): + self.assertEqual(ratio(100, 50, 0, 0), "2× faster") + self.assertEqual(ratio(100, 200, 0, 0), "2× slower") + self.assertEqual(ratio(100, 102, 0, 0), "≈ same") + self.assertEqual(ratio(100, 120, 15, 15), "≈ same") + + def test_requires_every_backend_and_operation(self): + with tempfile.TemporaryDirectory() as directory: + import json + + path = Path(directory) + for backend in ["deep", "rust", "csharp"]: + (path / f"{backend}.json").write_text(json.dumps(result(backend))) + self.assertEqual(len(load_results(path)), 3) + (path / "rust.json").unlink() + with self.assertRaises(ValueError): + load_results(path) + + def test_rejects_nan_missing_and_mismatched_workloads(self): + for mutate in [ + lambda r: r[0]["operations"].pop("Create"), + lambda r: r[0]["operations"]["Create"].update(median_ns=float("nan")), + lambda r: r[0].update(links=4), + lambda r: r[0]["metadata"].pop("versions"), + lambda r: r.append(r[0]), + lambda r: r[0]["operations"]["Create"].update(stdev_ns=1), + lambda r: r[0]["operations"]["Create"].update(samples_ns=[-100, 100]), + ]: + records = [result(b) for b in ["deep", "rust", "csharp"]] + mutate(records) + with self.assertRaises(ValueError): + render(records) + + def test_keeps_handwritten_text(self): + from scripts.report import replace_section + + self.assertEqual( + replace_section( + "before\n\nold\n\nafter", + "new", + ), + "before\n\nnew\n\nafter", + ) + with self.assertRaises(ValueError): + replace_section("no markers", "new") + + +if __name__ == "__main__": + unittest.main()