This guide covers the practical levers Disc exposes for query performance: indexing strategies, EXPLAIN-based diagnosis, the parse/compile/EXPLAIN caches, the connection pool, and the Prometheus gauges that reveal pressure on each of those subsystems.
Related documentation: Schema | Server Configuration | Production Deployment | Bundled PostgreSQL
Disc translates SDL index on (...) clauses into PostgreSQL indexes during migration. The differ supports btree (default), hash, gist, gin, and brin methods (migration/types.ts IndexDefinition.method). Pick the method based on the access pattern:
| Method | Best for |
|---|---|
btree |
Equality and range queries on scalars (=, <, >, between, order by). The default. |
hash |
Equality lookups on a single column where range scans are never used. |
gin |
Multi-valued types: arrays, JSON keys, full-text search vectors, trigram fuzzy match. |
gist |
Geometric/range types and nearest-neighbor queries (PostGIS, tstzrange, daterange). |
brin |
Very large tables where rows are physically clustered by an indexed column (e.g. append-only logs). |
Single-property index on User.name:
type User {
required name: str;
index on (.name);
};
Expression index on lower(email):
type User {
required email: str;
index on (str_lower(.email));
};
Composite index on (last_name, first_name):
type Person {
required first_name: str;
required last_name: str;
index on ((.last_name, .first_name));
};
See Schema → Indexes for the full SDL grammar (named indexes, annotated indexes, multi-property composites).
Index the columns that appear in filter, order by, and group by clauses on hot queries. Foreign-key columns generated for link properties are not indexed automatically — add an explicit index on (.author) if you query Post filter .author = <uuid>$id. Don’t over-index: every additional index slows down writes and consumes disk.
Use EXPLAIN (below) to confirm PostgreSQL actually picks the index for the planned query. A Seq Scan on a column you indexed usually means the predicate isn’t sargable (e.g. lower(email) = $1 against an index on email — switch to an expression index).
EdgeQL’s analyze keyword produces the underlying PostgreSQL EXPLAIN ANALYZE for a query, surfaced through Disc’s compiler. Use it to diagnose slow queries.
disc> analyze select User { email, name } filter .email = "ada@example.com";
Seq Scan on user u (cost=0.00..18.50 rows=1 width=64)
Filter: (email = 'ada@example.com'::text)
const plan = await client.explain(
`select User { email, name } filter .email = <str>$email`,
{ email: "ada@example.com" }
);
console.log(plan);
Disc caches EXPLAIN plan results in lib/explain-cache.ts to keep the admin UI’s query analyzer responsive. The TTL is configurable via DISC_EXPLAIN_CACHE_TTL (default 300 000 ms / 5 minutes). The cache is keyed by the compiled SQL plus parameter signature — semantically identical EdgeQL with different literal arguments shares one entry.
For ad-hoc diagnosis on a stale query plan, restart the server or wait for the TTL to expire. EXPLAIN cache hits are not currently exposed as a metric; the cache is best-effort.
Look for these signals in the plan output:
| Signal | Meaning |
|---|---|
Seq Scan on a large table |
Missing or unused index. Add an index, or rewrite the predicate to be sargable. |
Hash Join with high Buckets |
Joining on an unindexed FK. Add an index on the link’s column. |
actual rows >> rows |
PostgreSQL’s row estimate is wrong; run ANALYZE on the table or check pg_stats freshness. |
Repeated Index Scan in a loop |
N+1 from nested shapes. Hoist the inner query into a WITH block or denormalize. |
For complex EdgeQL with nested shapes, the generated SQL uses lateral subqueries; EXPLAIN ANALYZE reflects those as nested loops. That’s expected — the goal is not avoiding loops but ensuring each iteration uses an index.
Disc caches both EdgeQL parse trees and compiled SQL output. A repeated query with the same shape reuses both stages.
DISC_CACHE_MAX_SIZE=1000 # Default: 1000 entries each for parse + compile
Each entry holds a parsed AST or a compiled SQL string. Increase for large schemas with many distinct queries; reduce on memory-constrained hosts.
The compiled-SQL cache is keyed by EdgeQL source plus role-derived access context (lib/query-cache.ts hashAccessContext). The key intentionally hashes only the role, not userId — different users with the same role share one compiled-SQL entry, since user identity is bound at parameter time ($user_id), not compile time. This dramatically reduces cache pressure for multi-tenant deployments where every user runs the same select Post filter .author = $self.
Both caches expose Prometheus counters via /metrics:
disc_parse_cache_hits_total, disc_parse_cache_misses_total, disc_parse_cache_sizedisc_query_cache_hits_total, disc_query_cache_misses_total, disc_query_cache_evictions_total, disc_query_cache_sizeAim for a disc_query_cache_hits_total / (hits + misses) ratio above 0.7. Lower than that suggests churn — either cacheMaxSize is too small, or the schema is being reloaded faster than entries can warm up.
Caches are flushed on schema reload (SIGHUP, disc migrate, or admin-UI schema edit). Plan accordingly: a quiet redeploy followed by a migration causes a brief cold-cache window where the first request for each query pays the parse + compile cost.
The PostgreSQL connection pool (lib/connection-pool.ts) sizes itself between minConnections (default 2) and maxConnections (default 10 inside the library, 100 on the server). The server’s pool is configured via DISC_MAX_CONNECTIONS.
Total Disc connections across all instances must stay below PostgreSQL’s max_connections:
DISC_MAX_CONNECTIONS * disc_instance_count <= pg_max_connections - 5
See Production Deployment → Connection Pool Tuning for a deployment-size matrix.
When constructing a pool programmatically (new ConnectionPool(...)), these knobs control behavior:
| Field | Default | Meaning |
|---|---|---|
minConnections |
2 |
Minimum idle connections kept warm. |
maxConnections |
10 |
Hard cap on simultaneously active connections. |
connectionTimeout |
30000 ms |
Max wait for a free connection before throwing QueryTimeoutError. |
idleTimeout |
600000 ms |
Idle connections older than this are closed during cleanup. |
validateOnAcquire |
true |
Run SELECT 1 on each acquired connection to evict stale ones. |
maxWaitQueueSize |
50 |
Reject new acquisitions when the wait queue is full (back-pressure). |
cleanupInterval |
60000 ms |
How often the pool reaps idle/stale connections. |
leakWarningTimeout |
30000 ms |
Log a warning when a connection has been held for longer than this. |
Surface via /metrics:
disc_pool_connections_total — total connections (active + idle).disc_pool_connections_active — currently acquired.disc_pool_connections_idle — pooled but not in use.disc_pool_waiters — requests blocked waiting for a connection.Sustained disc_pool_waiters > 0 is the canonical pool-pressure signal. When you see it:
DISC_MAX_CONNECTIONS if the database has headroom.The full troubleshooting recipe lives in Production Deployment → Connection Pool Exhaustion.
Set DISC_SLOW_QUERY_MS to log queries that exceed a threshold:
DISC_SLOW_QUERY_MS=200 # Log queries slower than 200ms
Each slow query emits a log line with the query hash, duration, parse/compile/execute split, and (when log level is DEBUG) the full EdgeQL text. Pair with the structured-JSON log format in production so an aggregator can index by query_hash and surface trends.
DISC_SLOW_QUERY_MS=0 disables the threshold.
A complete production dashboard should track these gauges over time:
| Metric | Type | What to watch for |
|---|---|---|
disc_http_requests_total |
counter | Sudden drop = upstream outage. Steady growth = healthy traffic. |
disc_http_requests_failed_total |
counter | Rising error rate vs. successful_total > 1% sustained. |
disc_pool_connections_active |
gauge | Approaches DISC_MAX_CONNECTIONS → scale out. |
disc_pool_waiters |
gauge | Sustained > 0 = pool pressure. |
disc_query_cache_hits_total |
counter | Combined with misses, derive cache hit rate (target ≥ 0.7). |
disc_query_cache_evictions_total |
counter | Rising evictions = cacheMaxSize too small for query churn. |
disc_rate_limit_rejected_total |
counter | Spikes = abuse, runaway client, or RPM set too low. |
disc_process_memory_heap_used_bytes |
gauge | Sustained growth = leak. Compare against heap_total. |
disc_uptime_seconds |
gauge | Resets on every restart — pair with restart-count alerts. |
The disc_tls_certificate_seconds_until_expiry gauge from Production Deployment → TLS is the alerting hook for cert renewal.
analyze <query> from disc shell./stats for cache hit rate and pool waiters.disc_query_avg_compile_ms over time — a rising trend means schema growth or cache churn.EXPLAIN ANALYZE directly.Most performance work in Disc is one of: missing index, undersized pool, or cache thrash. Identify which one with the metrics above before reaching for code changes.
Disc ships a deno bench suite in benchmarks/ for measuring the pure-CPU stages of the pipeline -- parsing, compilation, codegen, schema diffing, and cache behaviour. Use it to catch regressions in the compiler when you change it; use the EXPLAIN and Prometheus tooling above for query-level work against real data.
deno task bench
The bench:pg task runs the same suite with DISC_PG_AUTO=1, matching the test:pg convention so PG-backed benchmarks can be added under the same harness:
deno task bench:pg
| File | Measures |
|---|---|
benchmarks/cache.bench.ts |
Cache hit, miss, fill, eviction-on-overflow, and mixed hit/miss paths |
benchmarks/codegen.bench.ts |
Schema-to-TypeScript generation |
benchmarks/compiler.bench.ts |
EdgeQL AST to SQL AST compilation, per query shape |
benchmarks/migration.bench.ts |
Schema differ: empty-to-simple, add/remove types, no-op diffs, and 20-type schemas |
benchmarks/parser.bench.ts |
EdgeQL tokenizing and parsing, per query shape |
benchmarks/pipeline.bench.ts |
End-to-end parse to SQL string, per query shape |
The compiler, parser, and pipeline suites all run the same query set -- simple select, filtered select, nested shape, insert, update, delete, and ordered-and-limited -- so a slowdown can be attributed to a stage by comparing the three.
Benchmarks are plain Deno.bench() calls; the suite runs with --allow-all --no-check. Name cases "<stage>: <case>" (for example "compile: nested shape") so results group readably in the output table, and build fixtures from createTestSchema() in compiler/context.ts rather than hitting a live database -- these suites are deliberately in-process so they stay deterministic.
analyze semantics