From cb94fc8dc7c25c252cfbaa63b327115a89fcec47 Mon Sep 17 00:00:00 2001 From: AIVensk <194205757+AIVensk@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:44:49 -0400 Subject: [PATCH 1/2] feat: compare real GraphQL storage backends --- .github/workflows/graphql-benchmark.yml | 45 +++++++ .gitignore | 4 + README.md | 6 + graphql/.dockerignore | 4 + graphql/Dockerfile.doublets | 10 ++ graphql/README.md | 126 ++++++++++++++++++++ graphql/benchmark.js | 150 ++++++++++++++++++++++++ graphql/compose.yaml | 47 ++++++++ graphql/init.sql | 9 ++ graphql/prepare-source.py | 37 ++++++ graphql/record-run.py | 40 +++++++ graphql/report.py | 66 +++++++++++ graphql/run.sh | 23 ++++ graphql/sample-results/README.md | 18 +++ graphql/sample-results/comparison.md | 19 +++ graphql/sample-results/doublets.json | 75 ++++++++++++ graphql/sample-results/hasura.json | 75 ++++++++++++ graphql/sample-results/run.json | 49 ++++++++ graphql/test_report.py | 48 ++++++++ 19 files changed, 851 insertions(+) create mode 100644 .github/workflows/graphql-benchmark.yml create mode 100644 graphql/.dockerignore create mode 100644 graphql/Dockerfile.doublets create mode 100644 graphql/README.md create mode 100644 graphql/benchmark.js create mode 100644 graphql/compose.yaml create mode 100644 graphql/init.sql create mode 100644 graphql/prepare-source.py create mode 100644 graphql/record-run.py create mode 100644 graphql/report.py create mode 100755 graphql/run.sh create mode 100644 graphql/sample-results/README.md create mode 100644 graphql/sample-results/comparison.md create mode 100644 graphql/sample-results/doublets.json create mode 100644 graphql/sample-results/hasura.json create mode 100644 graphql/sample-results/run.json create mode 100644 graphql/test_report.py diff --git a/.github/workflows/graphql-benchmark.yml b/.github/workflows/graphql-benchmark.yml new file mode 100644 index 0000000..d009170 --- /dev/null +++ b/.github/workflows/graphql-benchmark.yml @@ -0,0 +1,45 @@ +name: GraphQL comparison +on: + pull_request: + paths: ['graphql/**', '.github/workflows/graphql-benchmark.yml'] + workflow_dispatch: + inputs: + background: + description: Background point links (1..3000) + default: '3000' + required: true + iterations: + description: Sequential iterations per server (1..1000) + default: '1000' + required: true +permissions: + contents: read +jobs: + compare: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + BACKGROUND: ${{ inputs.background || '30' }} + ITERATIONS: ${{ inputs.iterations || '3' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/checkout@v4 + with: + repository: linksplatform/Data.Doublets.Gql + ref: b11f33b4080a7ef6b6d1c056c40bbf758d6cdd7e + path: graphql/source + submodules: true + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - name: Test result validation + run: python3 -m unittest discover -s graphql -v + - name: Run real local GraphQL servers and k6 + run: ./graphql/run.sh + - name: Upload measured results + if: always() + uses: actions/upload-artifact@v4 + with: + name: graphql-comparison + path: graphql/results/ diff --git a/.gitignore b/.gitignore index 90f7c20..27f1be7 100644 --- a/.gitignore +++ b/.gitignore @@ -370,3 +370,7 @@ MigrationBackup/ # Ionide (cross platform F# VS Code tools) working folder .ionide/ + +# Disposable GraphQL benchmark source and measured outputs +graphql/source/ +graphql/results/ diff --git a/README.md b/README.md index 6ecd8f4..7fd3e86 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ Both databases used to store and retrieve doublet-links representation. To suppo - **Each Concrete** – take all links matching `[*, source, target]` constraint - **Each Identity** – take all links matching `[id, *, *]` constraint +## GraphQL comparison + +[Run the correctness-checked PostgreSQL/Hasura and Doublets GraphQL comparison](graphql/README.md). +The harness uses real local servers, records actual k6 measurements, and keeps +its results separate from the Rust results below. + ## Results The results below represent the amount of time (ns) the operation takes per iteration. - First picture shows time in a pixel scale (for doublets just minimum value is shown, otherwise it will be not present on the graph). diff --git a/graphql/.dockerignore b/graphql/.dockerignore new file mode 100644 index 0000000..0ce0173 --- /dev/null +++ b/graphql/.dockerignore @@ -0,0 +1,4 @@ +results +**/.git +**/bin +**/obj diff --git a/graphql/Dockerfile.doublets b/graphql/Dockerfile.doublets new file mode 100644 index 0000000..49c0fe3 --- /dev/null +++ b/graphql/Dockerfile.doublets @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/dotnet/sdk:8.0@sha256:78235e09001f52b6592c458ac010775ebac6725422e80cd0c1650590f67b2743 AS build +WORKDIR /src +COPY source/ . +RUN dotnet publish csharp/Platform.Data.Doublets.Gql.Server/Platform.Data.Doublets.Gql.Server.csproj \ + --configuration Release --framework net6 --no-self-contained --output /app \ + --source https://api.nuget.org/v3/index.json +FROM mcr.microsoft.com/dotnet/aspnet:6.0@sha256:e70c493f8af7f95bf459cb2b15c7e7a6173228929c2b7a9a6836b19377890e78 +WORKDIR /app +COPY --from=build /app/ . +ENTRYPOINT ["dotnet", "Platform.Data.Doublets.Gql.Server.dll", "/data/benchmark.links"] diff --git a/graphql/README.md b/graphql/README.md new file mode 100644 index 0000000..458422b --- /dev/null +++ b/graphql/README.md @@ -0,0 +1,126 @@ +# PostgreSQL + Hasura versus Doublets + GraphQL + +This comparison uses the real PostgreSQL/Hasura and LinksPlatform Doublets GraphQL +servers. It runs a correctness-checked sequence through k6, with 3,000 background +point links and 1,000 sequential iterations per server by default. It does not +call a remote benchmark endpoint or access an existing database. + +## Run locally + +Prerequisites: Git, Python 3.12 or newer, Docker and Docker Compose. No hosted k6 +account, API keys or broker account is used. Images and NuGet packages are +fetched from their official registries on the first build. + +```sh +# Separate, public source checkout; this does not modify this benchmark repository. +git clone --recurse-submodules https://github.com/linksplatform/Data.Doublets.Gql.git /tmp/doublets-gql +python3 graphql/prepare-source.py /tmp/doublets-gql + +# Fast correctness smoke test, with real servers and real k6: +BACKGROUND=30 ITERATIONS=3 ./graphql/run.sh + +# Full configured workload: +./graphql/run.sh +``` + +`prepare-source.py` exports unmodified Doublets commit +`b11f33b4080a7ef6b6d1c056c40bbf758d6cdd7e` and its Settings submodule commit +`f08ce8fc84f8ab7ccdcf9293b5566007305254c1`. It performs no network calls. If your +existing checkout lacks either Git object, fetch the named public revision into +that checkout first. An already prepared `graphql/source` directory is not +overwritten. Docker publishes the upstream .NET 6 server target explicitly; +this preserves the pinned server rather than silently changing its runtime. + +Each invocation creates a unique Compose project, private Docker network and +fresh volumes. No ports are exposed to the host. The `EXIT` trap removes only +that invocation's containers, network and database volumes. A force-killed shell +cannot run a cleanup trap; use the printed `doublets-gql-bench-` project name +to clean that run with `docker compose -p -f graphql/compose.yaml down -v`. + +Measured results are retained separately for each run under +`graphql/results/doublets-gql-bench-/`: + +- `hasura.json` and `doublets.json`: actual k6 summary metrics and sample counts. +- `comparison.md`: mean and p95 HTTP durations, in milliseconds. +- `run.json`: source, script, runtime and workload metadata. + +A failed run exits nonzero. An incomplete run is never converted into a comparison +table. No chart or result in the repository is claimed to be a measurement from +this GraphQL harness until the actual run has completed. + +## Workload and correctness + +The initial dataset contains exactly 3,000 point links: `id = from_id = to_id`. +The default 1,000 iterations each perform the following sequence using one k6 +virtual user; the two servers are measured sequentially: + +| Operation | Behavior / verified result | +|---|---| +| Create | Insert an empty link, then update both endpoints to its returned ID; verify a point link. Two HTTP mutations on **both** systems. | +| Update | Set the new link to `(id, 1)` and check the returned row. | +| Each All | Fetch every row; verify 3,001 unique IDs, exact background values and the active row. | +| Each Identity | Filter by the active ID; verify the exact row. | +| Each Concrete | Filter by source and target; verify the exact row. | +| Each Outgoing | Filter by source; verify the exact row. | +| Each Incoming | Filter by target 1; verify point1 and the active row. | +| Delete | Delete the active ID, check the returned row, and separately verify that it is absent. | + +The default operation timings therefore contain 1,000 samples each, with 3,000 +background links and one active link during reads. This is a single-client CRUD +comparison, not a throughput or saturation test. `BACKGROUND` may be 1..3000 and +`ITERATIONS` 1..1000. The request timeout is 15s, setup is bounded to 5 min, and each +server's iteration phase is bounded to 10 min. + +All measured requests use the servers' actual common fields: +`insert_links`, `update_links`, `delete_links`, and `links(where: ...)`, with +`id`, `from_id`, and `to_id`. PostgreSQL has a primary key, individual source and +target indexes, and a unique source/target pair to match the native link model. +Both endpoints receive equivalent data and the same operations. Inserted IDs +are read from responses rather than assumed for the active workload. + +Every response is checked for HTTP status, GraphQL errors, expected cardinality, +unique IDs and expected field values. The run aborts at the first mismatch and +prints the operation, request and response for request failures. Setup refuses +an already populated database. No error-swallowing continuation or fake response +adapter is used. Report unit tests use clearly synthetic fixtures; these are never +written to the measured-results directory. + +## Relation to graphql-bench #52 + +[The prerequisite issue](https://github.com/hasura/graphql-bench/issues/52) reports +HTTP 400/500 responses and missing request/response diagnostics. Its maintainer +suggested JSON Content-Type headers and debug output. The comparison's sponsor +[also suggested using k6 directly](https://github.com/linksplatform/Comparisons.PostgreSQLVSDoublets/issues/1#issuecomment-910735999). + +This implementation takes that direct k6 path: it explicitly posts JSON with +`Content-Type: application/json`, uses the actual endpoint/schema, and reports +failed requests and response bodies. Successful real-server runs verify that +these requests work. It does not claim to modify, reproduce every old executor's +bug in, or close the separate graphql-bench issue. + +## Reproducibility and interpretation + +The pinned Doublets source is compiled without source changes; runtime image +versions/digests and source revision are recorded by the harness. The baseline +.NET 6 target is inherited from that source. These isolated containers are for +local benchmark work, not a deployment configuration. + +Numbers measure HTTP response time (two durations summed for Create), not pure +storage-engine latency. Validation, seeding, deletion verification and startup +are outside the operation metrics. Container scheduling, caching, host load and +run order can affect results. Repeat runs and compare like-for-like workloads +before drawing performance conclusions. Neither one run nor 1,000 sequential +iterations establishes broad performance superiority. + +The existing Rust benchmark results elsewhere in the repository are separate +and are not replaced by this comparison. + +## Automated verification + +```sh +python3 -m unittest discover -s graphql -v +``` + +The GraphQL workflow runs a real Docker/k6 smoke test on changes and permits the +full bounded workload through manual dispatch. It uploads run artifacts and does +not publish to `gh-pages` or commit generated results. diff --git a/graphql/benchmark.js b/graphql/benchmark.js new file mode 100644 index 0000000..135a91e --- /dev/null +++ b/graphql/benchmark.js @@ -0,0 +1,150 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; +import { Trend } from 'k6/metrics'; +import exec from 'k6/execution'; + +function boundedInteger(value, fallback, maximum) { + const parsed = Number(value || fallback); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > maximum) { + throw new Error(`Expected integer 1..${maximum}, got ${value}`); + } + return parsed; +} + +const background = boundedInteger(__ENV.BACKGROUND, 3000, 3000); +const iterations = boundedInteger(__ENV.ITERATIONS, 1000, 1000); +const target = __ENV.TARGET; +if (target !== 'hasura' && target !== 'doublets') throw new Error('TARGET must be hasura or doublets'); +const endpoint = `http://${target}:8080/v1/graphql`; +const headers = { 'Content-Type': 'application/json' }; +const operationNames = ['Create', 'Update', 'EachAll', 'EachIdentity', 'EachConcrete', 'EachOutgoing', 'EachIncoming', 'Delete']; +const timings = Object.fromEntries(operationNames.map(name => [name, new Trend(`operation_${name}_ms`, true)])); + +export const options = { + vus: 1, + iterations, + maxDuration: '10m', + setupTimeout: '5m', + thresholds: { http_req_failed: ['rate==0'] }, + summaryTrendStats: ['count', 'avg', 'min', 'med', 'max', 'p(95)'], +}; + +function ensure(condition, message) { + if (!condition) exec.test.abort(message); +} + +function query(document, operation = 'setup') { + const response = http.post(endpoint, JSON.stringify({ query: document }), + { headers, timeout: '15s', tags: { name: operation } }); + let body; + try { body = response.json(); } catch (_) { + exec.test.abort(`${operation}: HTTP ${response.status}; request=${document}; response=${response.body}`); + } + ensure(response.status === 200 && body && body.data && !body.errors, + `${operation}: HTTP ${response.status}; request=${document}; response=${response.body}`); + return { data: body.data, duration: response.timings.duration }; +} + +function rows(result, name = 'links') { + const value = result.data[name]; + ensure(Array.isArray(value), `Expected ${name} array: ${JSON.stringify(result.data)}`); + return value.map(row => ({ id: Number(row.id), from_id: Number(row.from_id), to_id: Number(row.to_id) })); +} + +function checkRow(row, id, from, to) { + ensure(row && Number(row.id) === id && Number(row.from_id) === from && Number(row.to_id) === to, + `Wrong row; expected (${id},${from},${to}), got ${JSON.stringify(row)}`); +} + +function mutate(document, name, operation) { + const result = query(document, operation); + const mutation = result.data[name]; + ensure(mutation && mutation.affected_rows === 1 && mutation.returning.length === 1, + `${operation}: expected one affected/returned row: ${JSON.stringify(result.data)}`); + return { row: mutation.returning[0], duration: result.duration }; +} + +export function setup() { + // Wait on these disposable internal services; no external endpoint can be selected. + let ready = false; + for (let attempt = 0; attempt < 90; attempt++) { + const response = http.post(endpoint, JSON.stringify({ query: '{__typename}' }), + { headers, timeout: '2s', tags: { name: 'readiness' }, responseCallback: null }); + if (response.status === 200) { ready = true; break; } + sleep(1); + } + ensure(ready, `${target} did not become ready`); + if (target === 'hasura') { + const response = http.post('http://hasura:8080/v1/metadata', JSON.stringify({ + type: 'pg_track_table', args: { source: 'default', table: { schema: 'public', name: 'links' } }, + }), { headers }); + ensure(response.status === 200, `Cannot track local links table: ${response.body}`); + } + ensure(rows(query('{links(limit:1){id from_id to_id}}')).length === 0, + 'Benchmark requires an empty dedicated database; refusing to modify an existing dataset'); + for (let start = 1; start <= background; start += 100) { + const objects = []; + for (let id = start; id <= Math.min(start + 99, background); id++) objects.push(`{from_id:${id},to_id:${id}}`); + const result = query(`mutation{insert_links(objects:[${objects.join(',')}]){affected_rows returning{id from_id to_id}}}`); + const inserted = result.data.insert_links; + ensure(inserted.affected_rows === objects.length && inserted.returning.length === objects.length, 'Background row count mismatch'); + inserted.returning.forEach((row, index) => checkRow(row, start + index, start + index, start + index)); + } + const seeded = rows(query('{links{id from_id to_id}}')); + ensure(seeded.length === background, 'Seeded database cardinality mismatch'); + seeded.sort((a, b) => a.id - b.id).forEach((row, index) => checkRow(row, index + 1, index + 1, index + 1)); + return { seeded: background }; +} + +export default function () { + // Create a point through the same two HTTP mutations on both servers. + const created = mutate('mutation{insert_links(objects:[{from_id:0,to_id:0}]){affected_rows returning{id from_id to_id}}}', + 'insert_links', 'Create'); + const id = Number(created.row.id); + ensure(Number.isSafeInteger(id) && id > background, 'New link ID must be outside the background dataset'); + const pointed = mutate(`mutation{update_links(where:{id:{_eq:${id}}},_set:{from_id:${id},to_id:${id}}){affected_rows returning{id from_id to_id}}}`, + 'update_links', 'Create'); + checkRow(pointed.row, id, id, id); + timings.Create.add(created.duration + pointed.duration); + + const updated = mutate(`mutation{update_links(where:{id:{_eq:${id}}},_set:{from_id:${id},to_id:1}){affected_rows returning{id from_id to_id}}}`, + 'update_links', 'Update'); + checkRow(updated.row, id, id, 1); + timings.Update.add(updated.duration); + + const reads = [ + ['EachAll', '{links{id from_id to_id}}', background + 1], + ['EachIdentity', `{links(where:{id:{_eq:${id}}}){id from_id to_id}}`, 1], + ['EachConcrete', `{links(where:{from_id:{_eq:${id}},to_id:{_eq:1}}){id from_id to_id}}`, 1], + ['EachOutgoing', `{links(where:{from_id:{_eq:${id}}}){id from_id to_id}}`, 1], + ['EachIncoming', '{links(where:{to_id:{_eq:1}}){id from_id to_id}}', 2], + ]; + for (const [name, document, count] of reads) { + const result = query(document, name); + const actual = rows(result); + ensure(new Set(actual.map(row => row.id)).size === actual.length, `${name}: duplicate row IDs`); + ensure(actual.length === count, `${name}: expected ${count} rows, got ${actual.length}`); + actual.forEach(row => { + if (row.id === id) checkRow(row, id, id, 1); + else { ensure((name === 'EachAll' && row.id >= 1 && row.id <= background) || (name === 'EachIncoming' && row.id === 1), `${name}: unexpected row`); checkRow(row, row.id, row.id, row.id); } + }); + ensure(actual.some(row => row.id === id), `${name}: current link is missing`); + timings[name].add(result.duration); + } + + const removed = mutate(`mutation{delete_links(where:{id:{_eq:${id}}}){affected_rows returning{id from_id to_id}}}`, + 'delete_links', 'Delete'); + checkRow(removed.row, id, id, 1); + ensure(rows(query(`{links(where:{id:{_eq:${id}}}){id from_id to_id}}`, 'verify-delete')).length === 0, + 'Deleted link still exists'); + timings.Delete.add(removed.duration); +} + +export function handleSummary(data) { + const measured = Object.fromEntries(operationNames.map(name => [name, data.metrics[`operation_${name}_ms`]?.values])); + const complete = operationNames.every(name => measured[name] && measured[name].count === iterations); + const report = { target, background, iterations, complete, unit: 'milliseconds', + create_http_requests: 2, other_operation_http_requests: 1, operations: measured }; + return { [`/results/${target}.json`]: JSON.stringify(report, null, 2), + stdout: JSON.stringify(report, null, 2) + '\n' }; +} diff --git a/graphql/compose.yaml b/graphql/compose.yaml new file mode 100644 index 0000000..58fa750 --- /dev/null +++ b/graphql/compose.yaml @@ -0,0 +1,47 @@ +services: + postgres: + image: postgres:15-alpine@sha256:f7d23353e1b15400d22ebe31189f4d314b87a4c129cc400c8c2d8d4ca127bf81 + environment: + POSTGRES_USER: benchmark + POSTGRES_PASSWORD: benchmark-local-only + POSTGRES_DB: benchmark + volumes: + - postgres-data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U benchmark -d benchmark"] + interval: 2s + timeout: 2s + retries: 30 + hasura: + image: hasura/graphql-engine:v2.44.0@sha256:4d34476840601fa0c372bce9b1ed15aac6ffc3f0a0db677e81062764c08e1dc5 + environment: + HASURA_GRAPHQL_DATABASE_URL: postgres://benchmark:benchmark-local-only@postgres:5432/benchmark + HASURA_GRAPHQL_ENABLE_CONSOLE: "false" + HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup + depends_on: + postgres: + condition: service_healthy + doublets: + build: + context: . + dockerfile: Dockerfile.doublets + environment: + ASPNETCORE_URLS: http://0.0.0.0:8080 + ASPNETCORE_ENVIRONMENT: Production + Logging__LogLevel__Default: Warning + volumes: + - doublets-data:/data + k6: + image: grafana/k6:0.54.0@sha256:1f40432b1cbe7234e977f96c362c9bc550a2d2b583d014dd8669fe40d3e9e755 + profiles: [benchmark] + user: "${BENCH_UID:-1000}:${BENCH_GID:-1000}" + environment: + BACKGROUND: "${BACKGROUND:-3000}" + ITERATIONS: "${ITERATIONS:-1000}" + volumes: + - ./benchmark.js:/scripts/benchmark.js:ro + - ${BENCH_RESULTS:-./results}:/results +volumes: + postgres-data: + doublets-data: diff --git a/graphql/init.sql b/graphql/init.sql new file mode 100644 index 0000000..6874b0b --- /dev/null +++ b/graphql/init.sql @@ -0,0 +1,9 @@ +-- Dedicated disposable benchmark database, never applied to an existing service. +CREATE TABLE links ( + id BIGSERIAL PRIMARY KEY, + from_id BIGINT NOT NULL, + to_id BIGINT NOT NULL, + UNIQUE (from_id, to_id) +); +CREATE INDEX links_from_id_idx ON links (from_id); +CREATE INDEX links_to_id_idx ON links (to_id); diff --git a/graphql/prepare-source.py b/graphql/prepare-source.py new file mode 100644 index 0000000..a45e011 --- /dev/null +++ b/graphql/prepare-source.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Export the pinned public Doublets source from an existing Git checkout.""" +import argparse +import io +from pathlib import Path +import subprocess +import tarfile + +REVISION = "b11f33b4080a7ef6b6d1c056c40bbf758d6cdd7e" +SETTINGS_REVISION = "f08ce8fc84f8ab7ccdcf9293b5566007305254c1" + + +def export(checkout, revision, destination): + subprocess.run(["git", "-C", str(checkout), "cat-file", "-e", revision + "^{commit}"], check=True) + archive = subprocess.check_output(["git", "-C", str(checkout), "archive", revision]) + destination.mkdir(parents=True, exist_ok=True) + with tarfile.open(fileobj=io.BytesIO(archive)) as tar: + tar.extractall(destination, filter="data") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("checkout", type=Path, help="Existing Data.Doublets.Gql checkout with its pinned Settings submodule") + args = parser.parse_args() + destination = Path(__file__).resolve().parent / "source" + if destination.exists(): + raise SystemExit("graphql/source already exists; use a fresh checkout/output directory.") + # Verify both objects before creating output. No network calls or checkout modifications. + for checkout, revision in [(args.checkout, REVISION), (args.checkout / "Settings", SETTINGS_REVISION)]: + subprocess.run(["git", "-C", str(checkout), "cat-file", "-e", revision + "^{commit}"], check=True) + export(args.checkout, REVISION, destination) + export(args.checkout / "Settings", SETTINGS_REVISION, destination / "Settings") + print(f"Prepared unmodified Doublets {REVISION} with Settings {SETTINGS_REVISION}") + + +if __name__ == "__main__": + main() diff --git a/graphql/record-run.py b/graphql/record-run.py new file mode 100644 index 0000000..810714f --- /dev/null +++ b/graphql/record-run.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Record non-secret provenance for a local benchmark run.""" +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import platform +import subprocess +import sys + + +def main(): + directory = Path(__file__).resolve().parent + output = Path(sys.argv[1]) + references = [line.split('image:', 1)[1].strip() for line in (directory / 'compose.yaml').read_text().splitlines() + if line.lstrip().startswith('image:')] + images = subprocess.check_output(['docker', 'image', 'inspect', *references], text=True) + image_info = [{'tags': image['RepoTags'], 'digests': image['RepoDigests'], 'id': image['Id']} + for image in json.loads(images)] + record = { + 'started_at_utc': datetime.now(timezone.utc).isoformat(), + 'doublets_commit': 'b11f33b4080a7ef6b6d1c056c40bbf758d6cdd7e', + 'settings_commit': 'f08ce8fc84f8ab7ccdcf9293b5566007305254c1', + 'benchmark_sha256': hashlib.sha256((directory / 'benchmark.js').read_bytes()).hexdigest(), + 'compose_sha256': hashlib.sha256((directory / 'compose.yaml').read_bytes()).hexdigest(), + 'doublets_dockerfile': (directory / 'Dockerfile.doublets').read_text(), + 'platform': platform.platform(), 'logical_cpus': os.cpu_count(), + 'docker_version': subprocess.check_output(['docker', 'version', '--format', '{{.Server.Version}}'], text=True).strip(), + 'background': int(os.environ.get('BACKGROUND', 3000)), + 'iterations': int(os.environ.get('ITERATIONS', 1000)), + 'server_order': ['hasura', 'doublets'], 'virtual_users': 1, + 'images': image_info, + 'measurement': 'HTTP response duration, milliseconds; Create sums two requests', + } + (output / 'run.json').write_text(json.dumps(record, indent=2) + '\n') + + +if __name__ == '__main__': + main() diff --git a/graphql/report.py b/graphql/report.py new file mode 100644 index 0000000..1690fa5 --- /dev/null +++ b/graphql/report.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Compare only complete, compatible, measured benchmark summaries.""" +import argparse +import json +import math +from pathlib import Path + +OPERATIONS = ('Create', 'Update', 'EachAll', 'EachIdentity', 'EachConcrete', 'EachOutgoing', 'EachIncoming', 'Delete') + + +def validate(result, target): + if result.get('target') != target or result.get('complete') is not True or result.get('unit') != 'milliseconds': + raise ValueError(f'{target}: incorrect target/unit or incomplete run') + background = result.get('background') + if type(background) is not int or not 1 <= background <= 3000: + raise ValueError(f'{target}: invalid background count') + if result.get('create_http_requests') != 2 or result.get('other_operation_http_requests') != 1: + raise ValueError(f'{target}: unexpected HTTP operation counts') + iterations = result.get('iterations') + if type(iterations) is not int or not 1 <= iterations <= 1000: + raise ValueError(f'{target}: invalid iteration count') + for name in OPERATIONS: + metric = result.get('operations', {}).get(name, {}) + if type(metric.get('count')) is not int or metric.get('count') != iterations: + raise ValueError(f'{target}: incomplete {name} samples') + for statistic in ['avg', 'med', 'p(95)']: + value = metric.get(statistic) + if type(value) not in [int, float] or not math.isfinite(value) or value < 0: + raise ValueError(f'{target}: invalid {name} {statistic}') + + +def compare(hasura, doublets): + validate(hasura, 'hasura') + validate(doublets, 'doublets') + for key in ['background', 'iterations', 'create_http_requests', 'other_operation_http_requests']: + if hasura.get(key) != doublets.get(key): + raise ValueError(f'Incompatible runs: {key} differs') + lines = [ + '# Measured GraphQL comparison', '', + f"{hasura['background']} background point links; {hasura['iterations']} sequential iterations per server; one virtual user.", + 'Times are HTTP response duration in milliseconds, not database-only time.', + 'Create includes two HTTP mutations on both servers; other operations use one.', '', + '| Operation | Hasura mean | Doublets mean | Hasura p95 | Doublets p95 |', + '|---|---:|---:|---:|---:|', + ] + for operation in OPERATIONS: + left, right = hasura['operations'][operation], doublets['operations'][operation] + lines.append(f"| {operation} | {left['avg']:.4f} | {right['avg']:.4f} | {left['p(95)']:.4f} | {right['p(95)']:.4f} |") + lines.extend(['', 'Each response was checked for GraphQL errors, row count and returned values.', + 'These local measurements describe this run and host; repeat runs before drawing performance conclusions.']) + return '\n'.join(lines) + '\n' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('hasura', type=Path) + parser.add_argument('doublets', type=Path) + args = parser.parse_args() + try: + print(compare(json.loads(args.hasura.read_text()), json.loads(args.doublets.read_text())), end='') + except (ValueError, KeyError, TypeError) as error: + parser.exit(1, f'Cannot compare results: {error}\n') + + +if __name__ == '__main__': + main() diff --git a/graphql/run.sh b/graphql/run.sh new file mode 100755 index 0000000..2a39d74 --- /dev/null +++ b/graphql/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +if [[ ! -d source/csharp/Platform.Data.Doublets.Gql.Server ]]; then + echo 'Prepare the pinned Doublets source first; see graphql/README.md.' >&2 + exit 1 +fi +export BENCH_UID="$(id -u)" BENCH_GID="$(id -g)" +# Unique project name gives this run its own network and fresh database volumes. +project="doublets-gql-bench-$$" +export BENCH_RESULTS="$PWD/results/$project" +mkdir -p "$BENCH_RESULTS" +compose=(docker compose --project-name "$project" --file compose.yaml) +cleanup() { "${compose[@]}" down --volumes --remove-orphans; } +trap cleanup EXIT +"${compose[@]}" pull k6 +"${compose[@]}" up --detach --build postgres hasura doublets +python3 record-run.py "$BENCH_RESULTS" +"${compose[@]}" run --rm -e TARGET=hasura k6 run /scripts/benchmark.js +"${compose[@]}" run --rm -e TARGET=doublets k6 run /scripts/benchmark.js +python3 report.py "$BENCH_RESULTS/hasura.json" "$BENCH_RESULTS/doublets.json" > "$BENCH_RESULTS/comparison.md" +cat "$BENCH_RESULTS/comparison.md" +printf 'Saved results: %s\n' "$BENCH_RESULTS" diff --git a/graphql/sample-results/README.md b/graphql/sample-results/README.md new file mode 100644 index 0000000..991b135 --- /dev/null +++ b/graphql/sample-results/README.md @@ -0,0 +1,18 @@ +# Measured sample — 2026-09-28 UTC + +These files are actual outputs from the local Docker/k6 implementation at the +script SHA-256 in `run.json`. Both servers completed 1,000 sequential iterations +with 3,000 background point links, validating returned rows during every operation. + +- `hasura.json` and `doublets.json`: raw measured operation summaries. +- `comparison.md`: derived table in milliseconds. +- `run.json`: source revisions, official image digests, script hash, runtime and workload. + +This is one host and one sequential run. Startup/seeding and correctness-checking +requests are excluded from the operation trends. Create sums two HTTP requests +on both servers. These results describe this workload; they do not establish +broad storage-engine or production performance. The result-validation unit tests +use separate synthetic fixtures and did not produce any of these measurements. + +To reproduce a fresh run, follow `../README.md`; output goes into a separate +ignored run directory. This sample is retained as evidence, not overwritten. diff --git a/graphql/sample-results/comparison.md b/graphql/sample-results/comparison.md new file mode 100644 index 0000000..2524c9d --- /dev/null +++ b/graphql/sample-results/comparison.md @@ -0,0 +1,19 @@ +# Measured GraphQL comparison + +3000 background point links; 1000 sequential iterations per server; one virtual user. +Times are HTTP response duration in milliseconds, not database-only time. +Create includes two HTTP mutations on both servers; other operations use one. + +| Operation | Hasura mean | Doublets mean | Hasura p95 | Doublets p95 | +|---|---:|---:|---:|---:| +| Create | 2.9257 | 0.3084 | 3.1794 | 0.4237 | +| Update | 1.4204 | 0.1456 | 1.6089 | 0.2068 | +| EachAll | 1.6278 | 2.5068 | 1.7994 | 3.4777 | +| EachIdentity | 0.7522 | 0.3697 | 0.9754 | 0.5670 | +| EachConcrete | 0.4893 | 0.2305 | 0.6308 | 0.3423 | +| EachOutgoing | 0.4151 | 0.1764 | 0.5163 | 0.2585 | +| EachIncoming | 0.4043 | 0.1571 | 0.4809 | 0.2285 | +| Delete | 1.5389 | 0.1797 | 1.7309 | 0.2290 | + +Each response was checked for GraphQL errors, row count and returned values. +These local measurements describe this run and host; repeat runs before drawing performance conclusions. diff --git a/graphql/sample-results/doublets.json b/graphql/sample-results/doublets.json new file mode 100644 index 0000000..9973887 --- /dev/null +++ b/graphql/sample-results/doublets.json @@ -0,0 +1,75 @@ +{ + "target": "doublets", + "background": 3000, + "iterations": 1000, + "complete": true, + "unit": "milliseconds", + "create_http_requests": 2, + "other_operation_http_requests": 1, + "operations": { + "Create": { + "avg": 0.3084250110000001, + "min": 0.17527399999999999, + "med": 0.29869999999999997, + "max": 3.2131879999999997, + "p(95)": 0.42368304999999995, + "count": 1000 + }, + "Update": { + "count": 1000, + "avg": 0.14562250699999982, + "min": 0.065956, + "med": 0.138359, + "max": 3.319451, + "p(95)": 0.2068 + }, + "EachAll": { + "count": 1000, + "avg": 2.506824295, + "min": 2.238549, + "med": 2.3655169999999996, + "max": 7.253766, + "p(95)": 3.477690499999999 + }, + "EachIdentity": { + "count": 1000, + "avg": 0.3697235870000002, + "min": 0.195493, + "med": 0.34467250000000005, + "max": 0.955031, + "p(95)": 0.5670331999999999 + }, + "EachConcrete": { + "count": 1000, + "avg": 0.23045063099999996, + "min": 0.100793, + "med": 0.222795, + "max": 0.864599, + "p(95)": 0.34228 + }, + "EachOutgoing": { + "med": 0.179222, + "max": 0.537085, + "p(95)": 0.25854805, + "count": 1000, + "avg": 0.17639538600000004, + "min": 0.076877 + }, + "EachIncoming": { + "max": 0.8229, + "p(95)": 0.228486, + "count": 1000, + "avg": 0.15709311300000003, + "min": 0.065254, + "med": 0.16208899999999998 + }, + "Delete": { + "min": 0.070424, + "med": 0.161287, + "max": 9.458471, + "p(95)": 0.22903454999999995, + "count": 1000, + "avg": 0.1797003800000001 + } + } +} \ No newline at end of file diff --git a/graphql/sample-results/hasura.json b/graphql/sample-results/hasura.json new file mode 100644 index 0000000..597b468 --- /dev/null +++ b/graphql/sample-results/hasura.json @@ -0,0 +1,75 @@ +{ + "target": "hasura", + "background": 3000, + "iterations": 1000, + "complete": true, + "unit": "milliseconds", + "create_http_requests": 2, + "other_operation_http_requests": 1, + "operations": { + "Create": { + "count": 1000, + "avg": 2.925683175, + "min": 2.514245, + "med": 2.8807095, + "max": 10.405427, + "p(95)": 3.1794342499999995 + }, + "Update": { + "count": 1000, + "avg": 1.4203654069999985, + "min": 1.207302, + "med": 1.383138, + "max": 9.060662, + "p(95)": 1.6088575499999997 + }, + "EachAll": { + "avg": 1.6278293820000036, + "min": 1.481135, + "med": 1.5890360000000001, + "max": 7.606658, + "p(95)": 1.7993676, + "count": 1000 + }, + "EachIdentity": { + "p(95)": 0.975372, + "count": 1000, + "avg": 0.7521985829999999, + "min": 0.51353, + "med": 0.717058, + "max": 6.468928 + }, + "EachConcrete": { + "p(95)": 0.63077155, + "count": 1000, + "avg": 0.489330952, + "min": 0.317676, + "med": 0.468123, + "max": 2.24386 + }, + "EachOutgoing": { + "p(95)": 0.5162871999999998, + "count": 1000, + "avg": 0.41510640400000043, + "min": 0.275796, + "med": 0.403129, + "max": 2.256414 + }, + "EachIncoming": { + "p(95)": 0.48089859999999995, + "count": 1000, + "avg": 0.4043488350000004, + "min": 0.287278, + "med": 0.391347, + "max": 4.153713 + }, + "Delete": { + "count": 1000, + "avg": 1.5389162379999997, + "min": 1.329476, + "med": 1.5135014999999998, + "max": 4.782442, + "p(95)": 1.730903 + } + } +} \ No newline at end of file diff --git a/graphql/sample-results/run.json b/graphql/sample-results/run.json new file mode 100644 index 0000000..2fc2b8c --- /dev/null +++ b/graphql/sample-results/run.json @@ -0,0 +1,49 @@ +{ + "started_at_utc": "2026-09-28T04:41:10.251697+00:00", + "doublets_commit": "b11f33b4080a7ef6b6d1c056c40bbf758d6cdd7e", + "settings_commit": "f08ce8fc84f8ab7ccdcf9293b5566007305254c1", + "benchmark_sha256": "6432ed55223f71d1c6aae01a5d2c120da221776b4446ccb8200c5b5dde3c7264", + "compose_sha256": "30ce2ea1854649867e77e7199eb3daff0256d51ac06eb5c378736b0f6b477bc3", + "doublets_dockerfile": "FROM mcr.microsoft.com/dotnet/sdk:8.0@sha256:78235e09001f52b6592c458ac010775ebac6725422e80cd0c1650590f67b2743 AS build\nWORKDIR /src\nCOPY source/ .\nRUN dotnet publish csharp/Platform.Data.Doublets.Gql.Server/Platform.Data.Doublets.Gql.Server.csproj \\\n --configuration Release --framework net6 --no-self-contained --output /app \\\n --source https://api.nuget.org/v3/index.json\nFROM mcr.microsoft.com/dotnet/aspnet:6.0@sha256:e70c493f8af7f95bf459cb2b15c7e7a6173228929c2b7a9a6836b19377890e78\nWORKDIR /app\nCOPY --from=build /app/ .\nENTRYPOINT [\"dotnet\", \"Platform.Data.Doublets.Gql.Server.dll\", \"/data/benchmark.links\"]\n", + "platform": "Linux-7.2.8-1-cachyos-x86_64-with-glibc2.44", + "logical_cpus": 16, + "docker_version": "29.8.1", + "background": 3000, + "iterations": 1000, + "server_order": [ + "hasura", + "doublets" + ], + "virtual_users": 1, + "images": [ + { + "tags": [ + "postgres:15-alpine" + ], + "digests": [ + "postgres@sha256:f7d23353e1b15400d22ebe31189f4d314b87a4c129cc400c8c2d8d4ca127bf81" + ], + "id": "sha256:f7d23353e1b15400d22ebe31189f4d314b87a4c129cc400c8c2d8d4ca127bf81" + }, + { + "tags": [ + "hasura/graphql-engine:v2.44.0" + ], + "digests": [ + "hasura/graphql-engine@sha256:4d34476840601fa0c372bce9b1ed15aac6ffc3f0a0db677e81062764c08e1dc5" + ], + "id": "sha256:4d34476840601fa0c372bce9b1ed15aac6ffc3f0a0db677e81062764c08e1dc5" + }, + { + "tags": [ + "grafana/k6:0.54.0", + "grafana/k6@sha256:1f40432b1cbe7234e977f96c362c9bc550a2d2b583d014dd8669fe40d3e9e755" + ], + "digests": [ + "grafana/k6@sha256:1f40432b1cbe7234e977f96c362c9bc550a2d2b583d014dd8669fe40d3e9e755" + ], + "id": "sha256:1f40432b1cbe7234e977f96c362c9bc550a2d2b583d014dd8669fe40d3e9e755" + } + ], + "measurement": "HTTP response duration, milliseconds; Create sums two requests" +} diff --git a/graphql/test_report.py b/graphql/test_report.py new file mode 100644 index 0000000..6447e75 --- /dev/null +++ b/graphql/test_report.py @@ -0,0 +1,48 @@ +import unittest +from report import compare, OPERATIONS + + +def fixture(target): + return {'target': target, 'complete': True, 'unit': 'milliseconds', 'background': 3000, + 'iterations': 2, 'create_http_requests': 2, 'other_operation_http_requests': 1, + 'operations': {name: {'count': 2, 'avg': 1.0, 'med': 1.0, 'p(95)': 2.0} for name in OPERATIONS}} + + +class ReportTests(unittest.TestCase): + def test_complete_synthetic_fixture_formats(self): + self.assertIn('| EachIdentity |', compare(fixture('hasura'), fixture('doublets'))) + + def test_rejects_incomplete(self): + left = fixture('hasura') + left['complete'] = False + with self.assertRaises(ValueError): compare(left, fixture('doublets')) + + def test_rejects_failed_sample_count(self): + left = fixture('hasura') + left['operations']['Delete']['count'] = 1 + with self.assertRaises(ValueError): compare(left, fixture('doublets')) + + def test_rejects_mismatched_background(self): + right = fixture('doublets') + right['background'] = 100 + with self.assertRaises(ValueError): compare(fixture('hasura'), right) + + def test_rejects_invalid_number(self): + for value in [float('nan'), float('inf'), -1, None, True]: + left = fixture('hasura') + left['operations']['Create']['avg'] = value + with self.assertRaises(ValueError): compare(left, fixture('doublets')) + + def test_rejects_invalid_background_or_request_count(self): + for key, value in [('background', -1), ('background', True), ('create_http_requests', 1)]: + left = fixture('hasura'); left[key] = value + with self.assertRaises(ValueError): compare(left, fixture('doublets')) + + def test_rejects_wrong_target_or_unit(self): + for key, value in [('target', 'doublets'), ('unit', 'nanoseconds')]: + left = fixture('hasura'); left[key] = value + with self.assertRaises(ValueError): compare(left, fixture('doublets')) + + +if __name__ == '__main__': + unittest.main() From 1a436c4c391d616eddc6f2f8f0795759bc506e01 Mon Sep 17 00:00:00 2001 From: AIVensk <194205757+AIVensk@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:55:19 -0400 Subject: [PATCH 2/2] style: split report test assignments --- graphql/test_report.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/graphql/test_report.py b/graphql/test_report.py index 6447e75..edbe6cf 100644 --- a/graphql/test_report.py +++ b/graphql/test_report.py @@ -35,12 +35,14 @@ def test_rejects_invalid_number(self): def test_rejects_invalid_background_or_request_count(self): for key, value in [('background', -1), ('background', True), ('create_http_requests', 1)]: - left = fixture('hasura'); left[key] = value + left = fixture('hasura') + left[key] = value with self.assertRaises(ValueError): compare(left, fixture('doublets')) def test_rejects_wrong_target_or_unit(self): for key, value in [('target', 'doublets'), ('unit', 'nanoseconds')]: - left = fixture('hasura'); left[key] = value + left = fixture('hasura') + left[key] = value with self.assertRaises(ValueError): compare(left, fixture('doublets'))