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. The foreign-key column of every single link is indexed automatically (idx_<table>_<link>_id), so Post filter .author = <uuid>$id already uses an index and a plain index on (.author) is skipped as redundant. Composite lookups need their own index: filter .author = <uuid>$a and .status = <str>$s wants index on ((.author, .status)), and a type-level constraint exclusive on ((.program, .object_id)) gives you a unique composite index for free that filter .program.id = <uuid>$p and .object_id in array_unpack(<array<str>>$ids) will use once the table has enough rows for the planner to prefer it. 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, and it is the pool the query handler and open transactions draw from — an open transaction pins one connection until it commits or rolls back. The cap is enforced under concurrent load: callers beyond it queue (up to maxWaitQueueSize, each for at most connectionTimeout) rather than opening extra connections, and past the queue limit acquire() throws Connection pool wait queue is full.
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