Synchronous Query Execution
Synchronous execution is the primary mode for interactive queries. The client submits a SQL statement and blocks until results are returned or the timeout is reached. This mode is used by dashboard queries, ad-hoc exploration, and the AI Service's SQL execution pipeline.
Endpoint
POST /v1/queries/executeRequired Headers
| Header | Type | Description |
|---|---|---|
X-Tenant-ID | UUID | The tenant identifier for data isolation |
X-User-ID | UUID | The user performing the query |
Authorization | String | Bearer JWT token |
Execution Flow
The QueryExecutionService.executeSync() method orchestrates the synchronous execution pipeline. The following sequence occurs for every synchronous query:
Client Request
|
v
1. Validate concurrent query limit
|
v
2. Calculate query hash (SHA-256)
|
v
3. Check cache (if useCache=true)
|--- Cache hit ---> Return cached result (cacheHit=true)
|
v
4. Route to engine (SmartQueryRouter)
|
v
5. Create QueryExecution record (status=PENDING)
|
v
6. Mark RUNNING, execute via engine strategy
|
v
7. Apply result limit
|
v
8. Mark COMPLETED, cache result
|
v
9. Return QueryResponseSource Implementation
From QueryExecutionService.java:
@Transactional
public QueryResponse executeSync(UUID tenantId, UUID userId, QueryRequest request) {
log.info("Executing sync query for tenant: {}, user: {}", tenantId, userId);
// Check concurrent query limit
validateConcurrentQueryLimit(tenantId);
// Calculate query hash for caching
String queryHash = calculateQueryHash(request.getSql());
// Check cache first
if (Boolean.TRUE.equals(request.getUseCache())) {
QueryResponse cachedResult = cacheService.get(tenantId, queryHash);
if (cachedResult != null) {
log.debug("Cache hit for query hash: {}", queryHash);
recordCacheHit(tenantId);
return cachedResult.toBuilder().cacheHit(true).build();
}
}
// Route to appropriate engine
EngineType engineType = queryRouter.route(request);
// Create execution record
QueryExecution execution = createExecution(tenantId, userId, request, queryHash, engineType);
execution = executionRepository.save(execution);
// Execute query
Timer.Sample timer = Timer.start(meterRegistry);
try {
execution.markRunning();
executionRepository.save(execution);
QueryEngineStrategy strategy = strategyFactory.getStrategy(engineType);
int timeout = request.getTimeoutSeconds() != null
? request.getTimeoutSeconds()
: syncTimeoutSeconds;
QueryResponse result = strategy.execute(request, timeout);
// ... mark completed, cache result, return
} catch (QueryTimeoutException e) {
// ... handle timeout
} catch (QueryCancelledException e) {
// ... handle cancellation
}
}Timeout Handling
The default synchronous timeout is controlled by the query.execution.sync-timeout-seconds property (default: 300 seconds). Clients can override this per-query via the timeoutSeconds field in the request.
# application.yml
query:
execution:
sync-timeout-seconds: 300
max-concurrent-queries: 100When a query exceeds the timeout:
- The engine strategy throws
QueryTimeoutException - The execution record is marked as
FAILEDwith a timeout message - A Micrometer timer is recorded with
status=timeout - The exception propagates to the client as an HTTP 408 or 500 response
curl Example
# Execute a synchronous query with custom timeout
curl -X POST http://query-engine:8080/v1/queries/execute \
-H "Content-Type: application/json" \
-H "X-Tenant-ID: 550e8400-e29b-41d4-a716-446655440000" \
-H "X-User-ID: 6ba7b810-9dad-11d1-80b4-00c04fd430c8" \
-H "Authorization: Bearer $JWT_TOKEN" \
-d '{
"sql": "SELECT customer_id, COUNT(*) as order_count FROM orders GROUP BY customer_id ORDER BY order_count DESC LIMIT 10",
"catalog": "iceberg",
"schema": "sales",
"useCache": true,
"timeoutSeconds": 60
}'Response (Success)
{
"executionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "COMPLETED",
"engineType": "TRINO",
"columns": [
{ "name": "customer_id", "type": "VARCHAR", "nullable": true },
{ "name": "order_count", "type": "BIGINT", "nullable": false }
],
"data": [
{ "customer_id": "C-1042", "order_count": 156 },
{ "customer_id": "C-0887", "order_count": 143 }
],
"rowCount": 10,
"totalRows": 10,
"bytesScanned": 52428800,
"executionTimeMs": 1847,
"cacheHit": false,
"hasMore": false,
"submittedAt": "2026-02-12T10:15:00Z",
"completedAt": "2026-02-12T10:15:01.847Z"
}Concurrent Query Limit
Before execution begins, the service validates that the tenant has not exceeded the concurrent query limit:
private void validateConcurrentQueryLimit(UUID tenantId) {
long activeQueries = executionRepository.countActiveQueries(tenantId);
if (activeQueries >= maxConcurrentQueries) {
throw new QueryExecutionException(
"Too many concurrent queries. Limit: " + maxConcurrentQueries);
}
}The default limit of 100 concurrent queries per tenant can be configured via query.execution.max-concurrent-queries.
Cache Integration
When useCache is true (the default), the execution service checks the cache before routing to an engine. The query hash is computed as a SHA-256 digest of the raw SQL text:
private String calculateQueryHash(String sql) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(sql.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
}On a cache hit, the cached QueryResponse is returned immediately with cacheHit: true. On a cache miss, the result is cached after successful execution for future lookups.
Metrics Emitted
Every synchronous execution emits the following Micrometer metrics:
| Metric | Type | Tags | Description |
|---|---|---|---|
query.execution.time | Timer | engine, status | Total execution time |
query.cache.hit | Counter | tenant | Cache hit count |
query.cancelled | Counter | tenant | Cancellation count |
Error Handling
| Exception | HTTP Status | Cause |
|---|---|---|
QueryTimeoutException | 408 | Query exceeded timeout |
QueryCancelledException | 499 | Query was cancelled |
QueryExecutionException | 500 | General execution failure |
| Concurrent limit exceeded | 429 | Too many active queries for tenant |