Omnihedron

Migration from @subql/query

How to switch from the TypeScript query service to omnihedron

1. Overview

omnihedron is a high-performance Rust reimplementation of @subql/query, the GraphQL query service for SubQuery Network indexers. The TypeScript original uses PostGraphile to auto-generate a GraphQL API from a live PostgreSQL schema. omnihedron replicates that behaviour entirely in Rust, producing an identical external API.

Why migrate?

  • 6-12x higher throughput under concurrent load (benchmarked on real SubQuery databases)
  • 2-5x lower p99 tail latency — Rust p99 stays under 2s at 5,000 concurrency; TypeScript hits 30s timeout
  • 8.7x less memory under load (47 MB vs 408 MB peak RSS at concurrency=500)
  • No event loop saturation — TypeScript collapses at ~5,000 concurrent connections; Rust maintains stable throughput past 10,000
  • No GC pauses or JIT deoptimisations
  • Native multi-core dispatch via Tokio

What stays identical

  • The generated GraphQL schema is the same (verified by CI introspection tests comparing 716+ types)
  • Query syntax — all existing queries, filters, ordering, pagination, aggregates work unchanged
  • Database compatibility — connects to the same PostgreSQL database with the same schema
  • Cursor format — base64-encoded JSON, fully compatible
  • nodeId format — base64-encoded ["TypeName", pkValue], fully compatible
  • Environment variables for database connection (DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_DATABASE)

2. Quick start — drop-in replacement

Step 1: Stop the TypeScript service

# If running via docker compose
docker compose stop subql-query-ts

# If running directly
kill $(pgrep -f "subql-query")

Step 2: Pull the omnihedron image (or build from source)

# Docker Hub
docker pull polytopelabs/omnihedron:latest

# Or build from source
cargo build --release

Step 3: Start omnihedron with equivalent flags

# TypeScript command:
#   subql-query --name my_project --port 3000 --unsafe

# Rust equivalent:
omnihedron --name my_project --port 3000 --unsafe-mode

Step 4: Verify

# Health check
curl http://localhost:3000/health

# Run a test query
curl -X POST http://localhost:3000 \
  -H "Content-Type: application/json" \
  -d '{"query": "{ _metadata { lastProcessedHeight } }"}'

No client-side changes are needed. The GraphQL schema, endpoints, and response format are identical.


3. CLI flag mapping

@subql/query flagomnihedron flagomnihedron env varNotes
--name / -n--name / -nOMNIHEDRON_NAMEIdentical — PostgreSQL schema name
--port--port / -pOMNIHEDRON_PORTIdentical
--playground--playgroundOMNIHEDRON_PLAYGROUNDIdentical — enables GraphiQL
--unsafe--unsafe-modeOMNIHEDRON_UNSAFERenamed — disables all query limits
--subscription--subscriptionOMNIHEDRON_SUBSCRIPTIONIdentical — enables WebSocket subscriptions
--aggregate--aggregateOMNIHEDRON_AGGREGATEIdentical — enables aggregation queries (default: on)
--query-limit--query-limitOMNIHEDRON_QUERY_LIMITIdentical — max records per query (default: 100)
--query-batch-limit--query-batch-limitOMNIHEDRON_QUERY_BATCH_LIMITIdentical — max queries per batch request
--query-depth-limit--query-depth-limitOMNIHEDRON_QUERY_DEPTH_LIMITIdentical — max query nesting depth
--query-alias-limit--query-alias-limitOMNIHEDRON_QUERY_ALIAS_LIMITIdentical — max field aliases per query
--query-complexity--query-complexityOMNIHEDRON_QUERY_COMPLEXITYIdentical — max query complexity score
--query-timeout--query-timeoutOMNIHEDRON_QUERY_TIMEOUTIdentical — timeout in ms (default: 10000). Enforced via PostgreSQL statement_timeout
--max-connection--max-connectionOMNIHEDRON_MAX_CONNECTIONIdentical — PostgreSQL connection pool size (default: 10)
--indexer--indexerOMNIHEDRON_INDEXERIdentical — indexer URL for metadata fallback
--dictionary-optimisation--dictionary-optimisationOMNIHEDRON_DICTIONARY_OPTIMISATIONIdentical
--log-level--log-levelOMNIHEDRON_LOG_LEVELIdentical — fatal|error|warn|info|debug|trace
--output-fmt--output-fmtOMNIHEDRON_OUTPUT_FMTIdentical — json|colored
N/A--metricsOMNIHEDRON_METRICSNew — enable Prometheus /metrics endpoint
N/A--query-explainOMNIHEDRON_QUERY_EXPLAINNew — log SQL EXPLAIN for each query
N/A--disable-hot-schemaOMNIHEDRON_DISABLE_HOT_SCHEMANew — disable schema hot reload via LISTEN/NOTIFY
N/A--sl-keep-alive-intervalOMNIHEDRON_SL_KEEP_ALIVE_INTERVALNew — schema listener keep-alive interval in ms (default: 180000)
N/A--pg-caOMNIHEDRON_PG_CANew — path to PostgreSQL CA certificate
N/A--pg-keyOMNIHEDRON_PG_KEYNew — path to PostgreSQL client key
N/A--pg-certOMNIHEDRON_PG_CERTNew — path to PostgreSQL client certificate
N/A--log-pathOMNIHEDRON_LOG_PATHNew — path to log file
N/A--log-rotateOMNIHEDRON_LOG_ROTATENew — enable log file rotation

4. Feature parity table

Feature@subql/queryomnihedronNotes
Connection queries (list + pagination)YesYesIdentical schema
Single record by IDYesYes
Single record by nodeIdYesYes
Node interface (node(nodeId: ...))YesYes
Cursor-based pagination (after/before)YesYesSame base64 JSON cursor format
Offset paginationYesYes
first/last paginationYesYes
Filter operators (equalTo, in, like, isNull, etc.)YesYesAll operators supported
Logical filters (and/or/not)YesYes
Relation filters (some/none/every, Exists)YesYes
Multi-column orderingYesYes
NullOrder (NULLS_FIRST/NULLS_LAST)YesYes
DISTINCT ONYesYes
Forward relation fieldsYesYesSame PostGraphile inflector naming
Backward relation fieldsYesYes{childPlural}By{FkCol} naming
One-to-one backward relationsYesYesDetected via unique FK constraint
Aggregates (sum, min, max, avg, count)YesYesSee Known Divergences for count
Grouped aggregates (groupedAggregates)YesYesWith time-truncation variants
Statistical aggregates (stddev, variance)YesYes
Distinct count aggregatesYesYes
Historical block height queriesYesYesblockHeight and timestamp modes
_metadata / _metadatas queriesYesYesMulti-chain support
Batch queries (JSON array POST)YesYes
GraphQL variablesYesYes
GraphiQL playgroundYesYes--playground flag
WebSocket subscriptionsYesYes--subscription flag
Schema hot reload (LISTEN/NOTIFY)YesYes
Query depth limitingYesYes
Query complexity limitingYesYesReturns query-complexity response header
Query alias limitingYesYes
Batch query limitingYesYes
Health endpointYesYes/health — includes DB pool status in omnihedron
Fulltext search (@fullText directive)NoYesNewsearch_{table} root query fields
Prometheus metricsNoYesNew--metrics enables /metrics
SQL EXPLAIN loggingNoYesNew--query-explain
Read replicasNoYesNewDB_HOST_READ env var
PostgreSQL TLS (mTLS)NoYesNew--pg-ca, --pg-key, --pg-cert
Log file output + rotationNoYesNew--log-path, --log-rotate
Selective column fetchingNoYesNew — only fetches columns in the selection set
Selective aggregate computationNoYesNew — only computes requested aggregate functions
Count-only fast pathNoYesNew — queries with only totalCount skip row fetching
Forward-relation scalar orderingYesYes{ENTITY}_BY_{FK}__{COL}_ASC/DESC enum values
Response compression (gzip)NoYesAutomatic via CompressionLayer
Cache-Control headerNoYespublic, max-age=5
Request tracing with request IDNoYesUUID-prefixed spans in logs

5. Known divergences

5.1 Aggregates: count field

@subql/query (PostGraphile)omnihedron
aggregates { count }count field does not exist on {Entity}Aggregatescount: BigInt! is present
Getting a filtered countUse { entities { totalCount } }Same — or use aggregates { count }

Impact: Queries using aggregates { count } will work on omnihedron but fail on the TypeScript service. If you need to support both during migration, use totalCount on the connection instead.

5.2 Aggregate helper types

omnihedron does not generate certain PostGraphile aggregate helper types that are present in the TypeScript schema but unused by standard queries:

  • Having* types
  • *AggregatesFilter types
  • *AggregateFilter types
  • *ToMany* filter helper types

These types exist in the PostGraphile schema for advanced pg-aggregates plugin features that are not used by SubQuery indexer consumers. If your client introspects the schema and caches type definitions, be aware the type set is slightly smaller.

5.3 Naming conventions

Both services use identical PostGraphile-compatible naming:

  • snake_case columns become camelCase fields
  • Table names are singularized for type names (transfers -> Transfer)
  • Leading underscores are preserved (_global -> _Global)
  • Consecutive uppercase runs are normalised (cumulative_volume_u_s_ds -> CumulativeVolumeUsds)
  • Latin neuter irregulars handled (metadata -> Metadatum)

The inflection logic in omnihedron has been verified against PostGraphile's exact output for all 716+ types in the test database. No naming divergences are expected.

5.4 Error message format

Both services return standard GraphQL error responses ({ "errors": [...] }), but the error message text may differ:

  • Query validation errors (depth, complexity, alias limits) use omnihedron-specific wording
  • Database errors surface the PostgreSQL error message directly in both, but omnihedron may include different context
  • Batch request errors are returned per-item in the response array on both services

5.5 Subscriptions

Both services support WebSocket subscriptions via the graphql-ws protocol. omnihedron uses the async-graphql-axum WebSocket handler. Subscription behaviour is functionally equivalent but the underlying implementation differs (PostGraphile LISTEN/NOTIFY vs async-graphql's subscription engine).

5.6 Query timeout enforcement

  • @subql/query: enforces timeout at the application layer (Node.js)
  • omnihedron: enforces timeout via PostgreSQL's statement_timeout set on every pool connection, letting the database kill long-running queries directly

5.7 first: 0 behaviour

omnihedron treats first: 0 as unset (matching JavaScript's truthiness semantics where 0 is falsy). This matches the TypeScript service behaviour.


6. New features in omnihedron

6.1 Query validation with response headers

omnihedron returns query complexity information in response headers:

query-complexity: 42
max-query-complexity: 1000

This allows clients to monitor how close their queries are to the complexity limit without hitting errors.

6.2 Schema hot reload

Enabled by default. A dedicated PostgreSQL connection listens on the SubQuery schema channel. When a schema_updated notification arrives, introspection reruns and the schema is atomically swapped. In-flight requests are unaffected.

Disable with --disable-hot-schema if you prefer manual restarts. Configure the keep-alive interval with --sl-keep-alive-interval (default: 180,000 ms).

6.3 Prometheus metrics

Enable with --metrics. Exposes a /metrics endpoint with:

  • omnihedron_http_requests_total (counter) — labeled by method, path, status
  • omnihedron_http_request_duration_seconds (histogram) — labeled by method, path
  • GraphQL-level counters for queries and errors

6.4 SQL EXPLAIN logging

Enable with --query-explain to log the PostgreSQL EXPLAIN output for every query at trace level. Useful for identifying slow queries and missing indexes during migration testing.

omnihedron supports SubQuery's @fullText directive. PostgreSQL functions created by the indexer (pattern: search_{hash}(search text) RETURNS SETOF table) are automatically detected and exposed as root query fields with the GraphQL name from the function comment (@name search_{table}).

6.6 Read replicas

Set the DB_HOST_READ environment variable to route read queries to a replica:

DB_HOST=primary.db.example.com
DB_HOST_READ=replica.db.example.com

Subscription connections always use the primary host.

6.7 PostgreSQL TLS (mTLS)

omnihedron --name my_project \
  --pg-ca /path/to/ca.crt \
  --pg-key /path/to/client.key \
  --pg-cert /path/to/client.crt

6.8 Selective query optimisation

omnihedron automatically optimises queries:

  • Selective column fetching — only columns referenced in the GraphQL selection set are included in the SQL SELECT
  • Selective aggregate computation — only requested aggregate functions (sum, min, max, etc.) are computed in SQL
  • Count-only fast path — queries requesting only totalCount (no nodes/edges) skip row fetching entirely
  • Window functionCOUNT(*) OVER() fetches total count and rows in a single SQL round-trip

6.9 Response compression and caching

  • Automatic gzip/deflate compression via Tower's CompressionLayer
  • Cache-Control: public, max-age=5 header on all responses

7. Environment variables mapping

Database connection (identical)

VariableDefaultDescription
DB_HOSTlocalhostPostgreSQL host
DB_PORT5432PostgreSQL port
DB_USERpostgresPostgreSQL user
DB_PASS(empty)PostgreSQL password
DB_DATABASEpostgresPostgreSQL database name
DB_HOST_READ(none)New — Read replica host

Application configuration

@subql/query env varomnihedron env varNotes
SUBQL_QUERY_NAMEOMNIHEDRON_NAMESchema name
SUBQL_QUERY_PORTOMNIHEDRON_PORTHTTP port
SUBQL_QUERY_PLAYGROUNDOMNIHEDRON_PLAYGROUNDGraphiQL toggle
SUBQL_QUERY_UNSAFEOMNIHEDRON_UNSAFEDisable query limits
SUBQL_QUERY_SUBSCRIPTIONOMNIHEDRON_SUBSCRIPTIONWebSocket subscriptions
SUBQL_QUERY_AGGREGATEOMNIHEDRON_AGGREGATEAggregation queries
SUBQL_QUERY_LIMITOMNIHEDRON_QUERY_LIMITMax records per query
SUBQL_QUERY_BATCH_LIMITOMNIHEDRON_QUERY_BATCH_LIMITMax batch size
SUBQL_QUERY_DEPTH_LIMITOMNIHEDRON_QUERY_DEPTH_LIMITMax query depth
SUBQL_QUERY_ALIAS_LIMITOMNIHEDRON_QUERY_ALIAS_LIMITMax aliases
SUBQL_QUERY_COMPLEXITYOMNIHEDRON_QUERY_COMPLEXITYMax complexity
SUBQL_QUERY_TIMEOUTOMNIHEDRON_QUERY_TIMEOUTQuery timeout (ms)
SUBQL_QUERY_MAX_CONNECTIONOMNIHEDRON_MAX_CONNECTIONPool size
SUBQL_QUERY_INDEXEROMNIHEDRON_INDEXERIndexer URL
N/AOMNIHEDRON_METRICSPrometheus metrics
N/AOMNIHEDRON_QUERY_EXPLAINSQL EXPLAIN logging
N/AOMNIHEDRON_DISABLE_HOT_SCHEMADisable hot reload
N/AOMNIHEDRON_PG_CAPostgreSQL CA cert path
N/AOMNIHEDRON_PG_KEYPostgreSQL client key path
N/AOMNIHEDRON_PG_CERTPostgreSQL client cert path
N/AOMNIHEDRON_LOG_PATHLog file path
N/AOMNIHEDRON_LOG_ROTATELog rotation
N/AOMNIHEDRON_OUTPUT_FMTOutput format (json/colored)
N/AOMNIHEDRON_LOG_LEVELLog level
N/ATOKIO_WORKER_THREADSTokio worker thread count (see Performance tips)

Performance tip: Tokio worker threads

Tokio defaults to one worker thread per CPU core. On many-core machines this inflates memory from thread stacks (128 cores x 2 MB = 256 MB idle). For a PostgreSQL-bound service with a pool of 10 connections, 8-16 worker threads is optimal:

TOKIO_WORKER_THREADS=8

8. Docker migration

Before: @subql/query

services:
  subql-query:
    image: subquerynetwork/subql-query:latest
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_USER: postgres
      DB_PASS: postgres
      DB_DATABASE: indexer
    ports:
      - "3000:3000"
    command: ["--name", "my_project", "--port", "3000", "--unsafe"]

After: omnihedron

services:
  omnihedron:
    image: polytopelabs/omnihedron:latest
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_USER: postgres
      DB_PASS: postgres
      DB_DATABASE: indexer
      OMNIHEDRON_LOG_LEVEL: info
      # Optional: limit worker threads on many-core machines
      # TOKIO_WORKER_THREADS: "8"
    ports:
      - "3000:3000"
    command: ["--name", "my_project", "--port", "3000", "--unsafe-mode"]
    restart: unless-stopped

Key changes in docker-compose.yml

  1. Image: subquerynetwork/subql-query:latest -> polytopelabs/omnihedron:latest
  2. --unsafe -> --unsafe-mode (the only renamed flag)
  3. DB env vars stay the same (DB_HOST, DB_PORT, DB_USER, DB_PASS, DB_DATABASE)
  4. Optional: Add OMNIHEDRON_LOG_LEVEL, OMNIHEDRON_METRICS, TOKIO_WORKER_THREADS

Building from source with Docker

services:
  omnihedron:
    build:
      context: .
      dockerfile: docker/Dockerfile
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_USER: postgres
      DB_PASS: postgres
      DB_DATABASE: indexer
    ports:
      - "3000:3000"
    command: ["--name", "my_project", "--port", "3000", "--unsafe-mode"]

9. Testing the migration — side-by-side comparison

The recommended approach is to run both services in parallel and compare responses before cutting over.

Step 1: Run both services

# docker-compose.yml
services:
  postgres:
    image: postgres:17
    environment:
      POSTGRES_DB: indexer
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"

  subql-query-ts:
    image: subquerynetwork/subql-query:latest
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_USER: postgres
      DB_PASS: postgres
      DB_DATABASE: indexer
    ports:
      - "3001:3001"
    command: ["--name", "my_project", "--port", "3001", "--unsafe"]

  omnihedron:
    image: polytopelabs/omnihedron:latest
    environment:
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_USER: postgres
      DB_PASS: postgres
      DB_DATABASE: indexer
    ports:
      - "3000:3000"
    command: ["--name", "my_project", "--port", "3000", "--unsafe-mode"]

Step 2: Compare responses

# Query both services with the same request and diff the output
QUERY='{"query": "{ transfers(first: 5, orderBy: [ID_ASC]) { nodes { id blockNumber } } }"}'

TS_RESP=$(curl -s http://localhost:3001 -H "Content-Type: application/json" -d "$QUERY")
RS_RESP=$(curl -s http://localhost:3000 -H "Content-Type: application/json" -d "$QUERY")

# Compare (should be identical)
diff <(echo "$TS_RESP" | jq -S .) <(echo "$RS_RESP" | jq -S .)

Step 3: Compare schema introspection

INTROSPECTION='{"query": "{ __schema { types { name fields { name type { name kind ofType { name } } } } } }"}'

TS_TYPES=$(curl -s http://localhost:3001 -H "Content-Type: application/json" -d "$INTROSPECTION" | jq -S '.data.__schema.types | sort_by(.name)')
RS_TYPES=$(curl -s http://localhost:3000 -H "Content-Type: application/json" -d "$INTROSPECTION" | jq -S '.data.__schema.types | sort_by(.name)')

diff <(echo "$TS_TYPES") <(echo "$RS_TYPES")

Note: Some PostGraphile-internal types (e.g., Having*, *AggregatesFilter) will be absent from the omnihedron schema. Filter these from the diff if needed.

Step 4: Run the omnihedron integration tests

The project includes 62 integration tests that verify correctness:

# Clone the omnihedron repo and run its test suite against your database
git clone https://github.com/polytope-labs/omnihedron.git
cd omnihedron
cargo test

10. Troubleshooting

"Unknown flag --unsafe"

omnihedron renamed --unsafe to --unsafe-mode. Update your command or set OMNIHEDRON_UNSAFE=true.

Schema types are missing or different

  1. Verify both services connect to the same PostgreSQL schema:
    curl http://localhost:3000/health | jq .
  2. Check that the --name flag matches your PostgreSQL schema name exactly.
  3. If using hot reload, wait a few seconds for the schema to refresh, or restart omnihedron.

Queries return fewer/more results than expected

  • Check --query-limit (default: 100). If you were previously using --unsafe with no limit, set --unsafe-mode on omnihedron or specify an explicit --query-limit.
  • Ensure first/last/offset arguments in your queries match expectations. omnihedron clamps negative values to zero and treats first: 0 as unset (same as TypeScript).

aggregates { count } works on omnihedron but fails on TypeScript

This is expected. omnihedron adds a count field to {Entity}Aggregates that PostGraphile does not expose. If you need to work with both services, use totalCount on the connection instead.

Connection pool exhaustion

omnihedron defaults to a pool of 10 connections (--max-connection 10). Under very high load, increase this value. Also consider setting TOKIO_WORKER_THREADS to match your pool size (8-16 is optimal for most deployments).

High memory usage on many-core machines

Tokio defaults to one worker thread per CPU core. Each thread allocates up to 2 MB of stack. On a 128-core machine, this alone accounts for ~256 MB of idle memory. Set TOKIO_WORKER_THREADS=8 (or 16) to reduce this.

Query timeout errors

omnihedron enforces timeouts via PostgreSQL's statement_timeout (default: 10,000 ms). If you see canceling statement due to statement timeout errors:

# Increase to 30 seconds
omnihedron --name my_project --query-timeout 30000

WebSocket subscriptions not working

Ensure --subscription is passed (it is off by default). The WebSocket endpoint is at /ws:

ws://localhost:3000/ws

Log output is hard to read

Switch to structured JSON logging for production:

omnihedron --output-fmt json --log-level info

Or write to a file with rotation:

omnihedron --log-path /var/log/omnihedron.log --log-rotate

PostgreSQL TLS connection errors

If your database requires TLS, provide the certificate paths:

omnihedron --name my_project \
  --pg-ca /path/to/ca.crt \
  --pg-key /path/to/client.key \
  --pg-cert /path/to/client.crt

Debugging query performance

Enable SQL EXPLAIN logging to identify slow queries:

omnihedron --name my_project --query-explain --log-level trace

Enable Prometheus metrics for ongoing monitoring:

omnihedron --name my_project --metrics
# Then scrape http://localhost:3000/metrics

On this page

1. OverviewWhy migrate?What stays identical2. Quick start — drop-in replacementStep 1: Stop the TypeScript serviceStep 2: Pull the omnihedron image (or build from source)Step 3: Start omnihedron with equivalent flagsStep 4: Verify3. CLI flag mapping4. Feature parity table5. Known divergences5.1 Aggregates: count field5.2 Aggregate helper types5.3 Naming conventions5.4 Error message format5.5 Subscriptions5.6 Query timeout enforcement5.7 first: 0 behaviour6. New features in omnihedron6.1 Query validation with response headers6.2 Schema hot reload6.3 Prometheus metrics6.4 SQL EXPLAIN logging6.5 Fulltext search6.6 Read replicas6.7 PostgreSQL TLS (mTLS)6.8 Selective query optimisation6.9 Response compression and caching7. Environment variables mappingDatabase connection (identical)Application configurationPerformance tip: Tokio worker threads8. Docker migrationBefore: @subql/queryAfter: omnihedronKey changes in docker-compose.ymlBuilding from source with Docker9. Testing the migration — side-by-side comparisonStep 1: Run both servicesStep 2: Compare responsesStep 3: Compare schema introspectionStep 4: Run the omnihedron integration tests10. Troubleshooting"Unknown flag --unsafe"Schema types are missing or differentQueries return fewer/more results than expectedaggregates { count } works on omnihedron but fails on TypeScriptConnection pool exhaustionHigh memory usage on many-core machinesQuery timeout errorsWebSocket subscriptions not workingLog output is hard to readPostgreSQL TLS connection errorsDebugging query performance