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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
150 changes: 148 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

<!-- results:start -->
_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._
<!-- results:end -->
26 changes: 26 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions csharp/Doublets.Gql.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeFrameworkVersion>10.0.12</RuntimeFrameworkVersion>
<RollForward>Disable</RollForward>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GraphQL" Version="8.8.5" />
<PackageReference Include="GraphQL.SystemTextJson" Version="8.8.5" />
<PackageReference Include="Platform.Data.Doublets" Version="0.18.1" />
<Content Include="../schema/links.graphql" Link="links.graphql" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
Loading
Loading