From 3427c1d4083d8b1345191d4ac0e15cc3773c0672 Mon Sep 17 00:00:00 2001
From: JinBa1 <72070041+JinBa1@users.noreply.github.com>
Date: Sun, 14 Jun 2026 01:34:05 +0100
Subject: [PATCH 1/2] feat(server): add Spring Boot REST gateway over the query
engine
POST /queries plans and synchronously executes read-only SQL, returning a
structured JSON result (positional column arrays + name/qualifiedName/type
metadata, truncated/hint) or EXPLAIN plan text. The /tables endpoints list
tables, expose a table's static typed schema, and accept opt-in CSV upload
(disabled by default).
A single QueryService choke point plans, attaches an always-present
fail-closed budget (clamped to hard caps), bounds concurrency with a
semaphore, branches EXPLAIN with zero budget, and audits every request, so
governance cannot be bypassed. GlobalExceptionHandler maps engine ErrorCodes
to HTTP: 4xx where the caller can act, scrubbed 5xx (generic message + id,
paths logged server-side only) for engine faults. Strict table-name
validation, work-dir path containment, and a streamed upload size cap guard
the write surface.
Spring enters only through the server module (spring-boot-dependencies BOM
import, no reparent); the parent POM and engine stay Spring-free. JUnit/JMH
version management moves to the engine module so the server takes JUnit from
the Spring BOM. CI switches to `mvnw clean package` to exercise the boot jar.
Engine: add BudgetKind{TUPLES,TIME} on QueryBudgetExceededException so a
tuple-budget breach maps to 429 and a wall-clock breach to 504.
---
.github/workflows/ci.yml | 8 +-
engine/pom.xml | 4 +
.../github/jinba1/cuckoodb/BudgetKind.java | 15 ++
.../github/jinba1/cuckoodb/QueryBudget.java | 6 +-
.../QueryBudgetExceededException.java | 13 +-
.../jinba1/cuckoodb/QueryBudgetTest.java | 26 +++
pom.xml | 26 +--
server/pom.xml | 67 +++++-
.../server/CuckooDbServerApplication.java | 20 ++
.../cuckoodb/server/ServerPlaceholder.java | 28 ---
.../cuckoodb/server/audit/AuditEvent.java | 42 ++++
.../cuckoodb/server/audit/AuditSink.java | 10 +
.../cuckoodb/server/audit/NoOpAuditSink.java | 20 ++
.../server/catalog/CatalogFacade.java | 76 ++++++
.../server/config/CatalogInitializer.java | 57 +++++
.../server/config/CuckooDbProperties.java | 81 +++++++
.../cuckoodb/server/config/OpenApiConfig.java | 27 +++
.../server/query/ApiPrincipalResolver.java | 37 +++
.../cuckoodb/server/query/BudgetPolicy.java | 51 +++++
.../ConcurrencyLimitExceededException.java | 11 +
.../server/query/ConcurrencyLimiter.java | 54 +++++
.../cuckoodb/server/query/QueryService.java | 84 +++++++
.../server/query/QueryServiceResult.java | 26 +++
.../server/web/GlobalExceptionHandler.java | 157 +++++++++++++
.../server/web/InvalidUploadException.java | 12 +
.../server/web/PayloadTooLargeException.java | 11 +
.../cuckoodb/server/web/QueryController.java | 42 ++++
.../web/TableAlreadyExistsException.java | 11 +
.../cuckoodb/server/web/TableController.java | 181 +++++++++++++++
.../web/TableLimitExceededException.java | 12 +
.../server/web/TableNotFoundException.java | 11 +
.../server/web/UploadDisabledException.java | 11 +
.../cuckoodb/server/web/dto/ColumnDto.java | 16 ++
.../server/web/dto/ErrorResponse.java | 26 +++
.../cuckoodb/server/web/dto/QueryRequest.java | 11 +
.../server/web/dto/QueryResponse.java | 73 ++++++
.../server/web/dto/TableColumnDto.java | 10 +
.../server/web/dto/TableSchemaResponse.java | 13 ++
.../server/web/dto/UploadResponse.java | 13 ++
.../src/main/resources/application.properties | 36 +++
.../server/query/BudgetPolicyTest.java | 48 ++++
.../server/query/ConcurrencyLimiterTest.java | 38 +++
.../server/web/BudgetIntegrationTest.java | 111 +++++++++
.../server/web/QueryApiIntegrationTest.java | 185 +++++++++++++++
.../server/web/QueryControllerTest.java | 216 ++++++++++++++++++
.../server/web/TableControllerTest.java | 87 +++++++
.../server/web/UploadApiIntegrationTest.java | 151 ++++++++++++
.../web/UploadLimitIntegrationTest.java | 92 ++++++++
48 files changed, 2304 insertions(+), 59 deletions(-)
create mode 100644 engine/src/main/java/com/github/jinba1/cuckoodb/BudgetKind.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/CuckooDbServerApplication.java
delete mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/ServerPlaceholder.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditEvent.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditSink.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/audit/NoOpAuditSink.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogFacade.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/config/CatalogInitializer.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/config/CuckooDbProperties.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/config/OpenApiConfig.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/query/ApiPrincipalResolver.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/query/BudgetPolicy.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimitExceededException.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimiter.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryService.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryServiceResult.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/GlobalExceptionHandler.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/InvalidUploadException.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/PayloadTooLargeException.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/QueryController.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableAlreadyExistsException.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableController.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableLimitExceededException.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableNotFoundException.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/UploadDisabledException.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/dto/ColumnDto.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/dto/ErrorResponse.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/dto/QueryRequest.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/dto/QueryResponse.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/dto/TableColumnDto.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/dto/TableSchemaResponse.java
create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/web/dto/UploadResponse.java
create mode 100644 server/src/main/resources/application.properties
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/query/BudgetPolicyTest.java
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimiterTest.java
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/web/BudgetIntegrationTest.java
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/web/QueryApiIntegrationTest.java
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/web/QueryControllerTest.java
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/web/TableControllerTest.java
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/web/UploadApiIntegrationTest.java
create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/web/UploadLimitIntegrationTest.java
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 848ffc2..6f40e66 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,10 +20,10 @@ jobs:
path: ~/.m2/repository
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
restore-keys: ${{ runner.os }}-m2
- - name: Build
- run: ./mvnw clean compile
- - name: Test
- run: ./mvnw test
+ - name: Build, test and package
+ # package subsumes compile + test for both modules and exercises the
+ # spring-boot-maven-plugin repackage, so a broken boot jar can't ship.
+ run: ./mvnw clean package
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
with:
diff --git a/engine/pom.xml b/engine/pom.xml
index dc1745d..af534c9 100644
--- a/engine/pom.xml
+++ b/engine/pom.xml
@@ -28,21 +28,25 @@
org.junit.jupiterjunit-jupiter-api
+ 5.10.2testorg.junit.jupiterjunit-jupiter-engine
+ 5.10.2testorg.openjdk.jmhjmh-core
+ ${jmh.version}testorg.openjdk.jmhjmh-generator-annprocess
+ ${jmh.version}test
diff --git a/engine/src/main/java/com/github/jinba1/cuckoodb/BudgetKind.java b/engine/src/main/java/com/github/jinba1/cuckoodb/BudgetKind.java
new file mode 100644
index 0000000..9aec563
--- /dev/null
+++ b/engine/src/main/java/com/github/jinba1/cuckoodb/BudgetKind.java
@@ -0,0 +1,15 @@
+package com.github.jinba1.cuckoodb;
+
+/**
+ * Which limit of a {@link QueryBudget} was breached. The engine throws one
+ * {@link QueryBudgetExceededException} type for both, but downstream surfaces treat them
+ * differently: a network gateway maps a {@link #TUPLES} breach to "retry with a narrower
+ * scope" (429) and a {@link #TIME} breach to "the query took too long" (504). Carried as a
+ * field so consumers classify by enum, never by parsing the message.
+ */
+public enum BudgetKind {
+ /** The tuple-count limit was exceeded. */
+ TUPLES,
+ /** The wall-clock time limit was exceeded. */
+ TIME
+}
diff --git a/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudget.java b/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudget.java
index 24c9413..db44bd2 100644
--- a/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudget.java
+++ b/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudget.java
@@ -39,13 +39,13 @@ public void charge() {
}
processed++;
if (maxTuples != null && processed > maxTuples) {
- throw new QueryBudgetExceededException(
+ throw new QueryBudgetExceededException(BudgetKind.TUPLES,
"Tuple budget exceeded: limit " + maxTuples + ", query processed "
+ processed + " tuples");
}
// >= so an already-expired deadline (timeout 0) trips regardless of clock resolution
if (timeoutMs != null && System.nanoTime() >= deadlineNanos) {
- throw new QueryBudgetExceededException(
+ throw new QueryBudgetExceededException(BudgetKind.TIME,
"Time budget exceeded: limit " + timeoutMs + " ms");
}
}
@@ -67,7 +67,7 @@ public void checkDeadline() {
deadlineNanos = System.nanoTime() + timeoutMs * 1_000_000;
}
if (System.nanoTime() >= deadlineNanos) {
- throw new QueryBudgetExceededException(
+ throw new QueryBudgetExceededException(BudgetKind.TIME,
"Time budget exceeded: limit " + timeoutMs + " ms");
}
}
diff --git a/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudgetExceededException.java b/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudgetExceededException.java
index c2dfa08..cd271a2 100644
--- a/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudgetExceededException.java
+++ b/engine/src/main/java/com/github/jinba1/cuckoodb/QueryBudgetExceededException.java
@@ -5,11 +5,22 @@
* Subclass of QueryExecutionException so existing error handling paths apply.
*/
public class QueryBudgetExceededException extends QueryExecutionException {
+
+ private final BudgetKind kind;
+
/**
+ * @param kind which limit was breached (tuples vs time); lets a gateway map the same
+ * engine exception to different HTTP statuses without parsing the message
* @param message which limit was exceeded and by how much; the error code is
* always {@link ErrorCode#BUDGET_EXCEEDED}
*/
- public QueryBudgetExceededException(String message) {
+ public QueryBudgetExceededException(BudgetKind kind, String message) {
super(ErrorCode.BUDGET_EXCEEDED, message);
+ this.kind = kind;
+ }
+
+ /** Which budget limit was breached. */
+ public BudgetKind kind() {
+ return kind;
}
}
diff --git a/engine/src/test/java/com/github/jinba1/cuckoodb/QueryBudgetTest.java b/engine/src/test/java/com/github/jinba1/cuckoodb/QueryBudgetTest.java
index 4444b61..6ff75d9 100644
--- a/engine/src/test/java/com/github/jinba1/cuckoodb/QueryBudgetTest.java
+++ b/engine/src/test/java/com/github/jinba1/cuckoodb/QueryBudgetTest.java
@@ -63,6 +63,32 @@ public void exceptionIsAQueryExecutionException() {
assertTrue(QueryExecutionException.class.isAssignableFrom(QueryBudgetExceededException.class));
}
+ @Test
+ public void tupleBreachReportsKindTuples() {
+ // The REST layer maps a TUPLES breach to 429 and a TIME breach to 504, so the kind
+ // must be programmatically distinguishable, not parsed from the message text.
+ QueryBudget budget = new QueryBudget(0L, null);
+ QueryBudgetExceededException ex =
+ assertThrows(QueryBudgetExceededException.class, budget::charge);
+ assertEquals(BudgetKind.TUPLES, ex.kind());
+ }
+
+ @Test
+ public void timeBreachInChargeReportsKindTime() {
+ QueryBudget budget = new QueryBudget(null, 0L);
+ QueryBudgetExceededException ex =
+ assertThrows(QueryBudgetExceededException.class, budget::charge);
+ assertEquals(BudgetKind.TIME, ex.kind());
+ }
+
+ @Test
+ public void timeBreachInCheckDeadlineReportsKindTime() {
+ QueryBudget budget = new QueryBudget(null, 0L);
+ QueryBudgetExceededException ex =
+ assertThrows(QueryBudgetExceededException.class, budget::checkDeadline);
+ assertEquals(BudgetKind.TIME, ex.kind());
+ }
+
@Test
public void checkDeadlineWithZeroTimeoutTripsImmediately() {
QueryBudget budget = new QueryBudget(null, 0L);
diff --git a/pom.xml b/pom.xml
index 2cb4f92..bba38a7 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,18 +24,14 @@
1.37
+
-
- org.junit.jupiter
- junit-jupiter-api
- 5.10.2
-
-
- org.junit.jupiter
- junit-jupiter-engine
- 5.10.2
- com.github.jsqlparserjsqlparser
@@ -46,16 +42,6 @@
commons-csv1.14.1
-
- org.openjdk.jmh
- jmh-core
- ${jmh.version}
-
-
- org.openjdk.jmh
- jmh-generator-annprocess
- ${jmh.version}
-
diff --git a/server/pom.xml b/server/pom.xml
index 2755430..9433ad2 100644
--- a/server/pom.xml
+++ b/server/pom.xml
@@ -16,16 +16,77 @@
cuckooDB Server
+
+ 3.4.1
+ 2.7.0
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-dependencies
+ ${spring-boot.version}
+ pom
+ import
+
+
+
+
com.github.jinba1cuckoodb-engine${project.version}
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+ ${springdoc.version}
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+
+ true
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+ ${spring-boot.version}
+
+
+
+ repackage
+
+
+
+
+
+
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/CuckooDbServerApplication.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/CuckooDbServerApplication.java
new file mode 100644
index 0000000..8658082
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/CuckooDbServerApplication.java
@@ -0,0 +1,20 @@
+package com.github.jinba1.cuckoodb.server;
+
+import com.github.jinba1.cuckoodb.server.config.CuckooDbProperties;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+
+/**
+ * Entry point for the cuckooDB REST gateway: a guarded, read-only-by-construction,
+ * resource-budgeted HTTP front door onto the in-memory query engine.
+ */
+@SpringBootApplication
+@EnableConfigurationProperties(CuckooDbProperties.class)
+public class CuckooDbServerApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(CuckooDbServerApplication.class, args);
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/ServerPlaceholder.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/ServerPlaceholder.java
deleted file mode 100644
index d0e8f42..0000000
--- a/server/src/main/java/com/github/jinba1/cuckoodb/server/ServerPlaceholder.java
+++ /dev/null
@@ -1,28 +0,0 @@
-package com.github.jinba1.cuckoodb.server;
-
-import com.github.jinba1.cuckoodb.QueryResult;
-
-/**
- * Placeholder for the cuckooDB server module.
- *
- *
The Spring Boot REST gateway is added in a later change. This class exists
- * only so that the {@code cuckoodb-server} module compiles against an engine type,
- * proving the {@code server -> engine} Maven dependency is wired correctly. A
- * broken reactor dependency would fail compilation here rather than passing silently.
- */
-public final class ServerPlaceholder {
-
- private ServerPlaceholder() {
- }
-
- /**
- * References an engine type ({@link QueryResult}) so the engine dependency is
- * exercised at compile time, not merely declared in the POM.
- *
- * @return a short description naming the linked engine type
- */
- public static String describe() {
- return "cuckooDB server skeleton; REST gateway added later. Engine type linked: "
- + QueryResult.class.getSimpleName();
- }
-}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditEvent.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditEvent.java
new file mode 100644
index 0000000..86f686b
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditEvent.java
@@ -0,0 +1,42 @@
+package com.github.jinba1.cuckoodb.server.audit;
+
+/**
+ * One auditable query attempt, captured at the {@code QueryService} choke point after planning
+ * (so the plan is in scope and a later governance phase need not re-plan). The shape is fixed now
+ * even though the sink is a no-op, so wiring a persistent audit log later is a sink swap, not an
+ * API change.
+ *
+ *
Exactly one of {@code rowCount} / {@code errorCode} is meaningful per outcome:
+ * a success carries {@code rowCount} (and {@code truncated}); a failure carries {@code errorCode}.
+ *
+ * @param principal the caller label (anonymous until authentication is added)
+ * @param sql the submitted SQL text
+ * @param explainText the rendered plan for an EXPLAIN request, else null
+ * @param outcome SUCCESS / EXPLAIN / ERROR
+ * @param rowCount rows returned on success (null otherwise)
+ * @param truncated whether a LIMIT truncated the result (false for explain/error)
+ * @param errorCode the engine {@code ErrorCode} name on failure (null otherwise)
+ */
+public record AuditEvent(
+ String principal,
+ String sql,
+ String explainText,
+ Outcome outcome,
+ Long rowCount,
+ boolean truncated,
+ String errorCode) {
+
+ public enum Outcome { SUCCESS, EXPLAIN, ERROR }
+
+ public static AuditEvent success(String principal, String sql, long rowCount, boolean truncated) {
+ return new AuditEvent(principal, sql, null, Outcome.SUCCESS, rowCount, truncated, null);
+ }
+
+ public static AuditEvent explain(String principal, String sql, String explainText) {
+ return new AuditEvent(principal, sql, explainText, Outcome.EXPLAIN, null, false, null);
+ }
+
+ public static AuditEvent error(String principal, String sql, String errorCode) {
+ return new AuditEvent(principal, sql, null, Outcome.ERROR, null, false, errorCode);
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditSink.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditSink.java
new file mode 100644
index 0000000..7fef062
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/AuditSink.java
@@ -0,0 +1,10 @@
+package com.github.jinba1.cuckoodb.server.audit;
+
+/**
+ * Receives one {@link AuditEvent} per query attempt. The interface (and the event shape) are
+ * defined now so the {@code QueryService} call site is permanent; a later governance phase
+ * supplies a persistent implementation. The default {@link NoOpAuditSink} discards events.
+ */
+public interface AuditSink {
+ void record(AuditEvent event);
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/NoOpAuditSink.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/NoOpAuditSink.java
new file mode 100644
index 0000000..a9c3c44
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/audit/NoOpAuditSink.java
@@ -0,0 +1,20 @@
+package com.github.jinba1.cuckoodb.server.audit;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+/**
+ * Default {@link AuditSink}: logs each event at debug and otherwise discards it. A later
+ * governance phase replaces this with a persistent sink (a bean override, no call-site change).
+ */
+@Component
+public class NoOpAuditSink implements AuditSink {
+
+ private static final Logger log = LoggerFactory.getLogger(NoOpAuditSink.class);
+
+ @Override
+ public void record(AuditEvent event) {
+ log.debug("audit: {}", event);
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogFacade.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogFacade.java
new file mode 100644
index 0000000..61dd20a
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogFacade.java
@@ -0,0 +1,76 @@
+package com.github.jinba1.cuckoodb.server.catalog;
+
+import com.github.jinba1.cuckoodb.ColumnType;
+import com.github.jinba1.cuckoodb.DBCatalog;
+import com.github.jinba1.cuckoodb.TableMeta;
+
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * The server's only doorway to the engine's static {@link DBCatalog} singleton. Wrapping it
+ * in a Spring bean gives controllers a mockable seam (the static singleton cannot be mocked)
+ * and keeps every catalog read/write in one auditable place. The bean holds no state of its
+ * own — the catalog instance lifecycle is owned by {@code CatalogInitializer}.
+ */
+@Component
+public class CatalogFacade {
+
+ /** Table names currently in the catalog, sorted. */
+ public List tableNames() {
+ return DBCatalog.getInstance().getTableNames();
+ }
+
+ /** Whether a table of this exact name is registered. */
+ public boolean exists(String name) {
+ return DBCatalog.getInstance().tableExists(name);
+ }
+
+ /** Current registered-table count, for the upload table-count cap. */
+ public int tableCount() {
+ return DBCatalog.getInstance().getTableNames().size();
+ }
+
+ /**
+ * The static, catalog-authoritative typed schema of a base table, in column order, or
+ * {@code null} if the table is absent. Schema discovery must use this — never a query
+ * result's best-effort inferred types, which are null on an empty result.
+ */
+ public List columnsOf(String name) {
+ TableMeta meta = DBCatalog.getInstance().getTableMeta(name);
+ if (meta == null) {
+ return null;
+ }
+ Map schema = meta.schema();
+ List types = meta.types();
+ String[] byIndex = new String[types.size()];
+ for (Map.Entry e : schema.entrySet()) {
+ int idx = e.getValue();
+ if (idx >= 0 && idx < byIndex.length) {
+ byIndex[idx] = e.getKey();
+ }
+ }
+ List columns = new ArrayList<>(types.size());
+ for (int i = 0; i < types.size(); i++) {
+ columns.add(new TableColumn(byIndex[i], types.get(i)));
+ }
+ return columns;
+ }
+
+ /**
+ * Registers a table from an already-written CSV file. Returns {@code false} when the name
+ * is already taken (the 409 signal); throws {@code QueryExecutionException(DATA_ERROR)} for
+ * a malformed CSV. The file must outlive every query that scans the table.
+ */
+ public boolean register(String name, Path csv) {
+ return DBCatalog.getInstance().registerTable(name, csv);
+ }
+
+ /** One column of a base table's static schema: bare name + catalog-inferred type. */
+ public record TableColumn(String name, ColumnType type) {
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/config/CatalogInitializer.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/config/CatalogInitializer.java
new file mode 100644
index 0000000..d1413b3
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/config/CatalogInitializer.java
@@ -0,0 +1,57 @@
+package com.github.jinba1.cuckoodb.server.config;
+
+import com.github.jinba1.cuckoodb.DBCatalog;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/**
+ * Owns the catalog lifecycle under Spring. The engine's {@link DBCatalog} is a static
+ * singleton whose {@code initDBCatalog} silently no-ops a second call and whose
+ * {@code resetDBCatalog} nulls the instance with no reader coordination — both fight Spring's
+ * (and the test harness's) context lifecycle.
+ *
+ *
So this runner performs the one deterministic seeding for a context: {@code reset} then
+ * {@code init}, exactly once, before any request can arrive. At production boot the reset is a
+ * no-op (the singleton starts null); in tests it gives each freshly-built context a catalog
+ * seeded purely from its own configured {@code data-dir}, with no leakage from a prior
+ * context's singleton. Integration tests therefore must NOT call {@code resetDBCatalog}
+ * themselves and should isolate contexts with {@code @DirtiesContext}.
+ */
+@Component
+public class CatalogInitializer implements ApplicationRunner {
+
+ private static final Logger log = LoggerFactory.getLogger(CatalogInitializer.class);
+
+ private final CuckooDbProperties properties;
+
+ public CatalogInitializer(CuckooDbProperties properties) {
+ this.properties = properties;
+ }
+
+ @Override
+ public void run(ApplicationArguments args) throws IOException {
+ Path workDir = Path.of(properties.workDir());
+ Files.createDirectories(workDir);
+ log.info("Upload work dir: {}", workDir.toAbsolutePath());
+
+ DBCatalog.resetDBCatalog();
+ String dataDir = properties.dataDir();
+ if (dataDir != null && !dataDir.isBlank()) {
+ DBCatalog.initDBCatalog(dataDir);
+ log.info("Catalog seeded from data dir '{}': {} table(s)",
+ dataDir, DBCatalog.getInstance().getTableNames().size());
+ } else {
+ // No seed dir: create an empty catalog so registerTable has an instance to write to.
+ DBCatalog.getInstance();
+ log.info("No cuckoodb.data-dir configured; catalog starts empty (uploads only)");
+ }
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/config/CuckooDbProperties.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/config/CuckooDbProperties.java
new file mode 100644
index 0000000..eceecf6
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/config/CuckooDbProperties.java
@@ -0,0 +1,81 @@
+package com.github.jinba1.cuckoodb.server.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.NestedConfigurationProperty;
+
+/**
+ * Server-side configuration, bound from the {@code cuckoodb.*} property namespace. All
+ * resource bounds (budget caps, upload size, table count, concurrency) live here so they are
+ * tunable per deployment without code changes, and every default is conservative.
+ *
+ * @param dataDir directory whose {@code data/} subdir is loaded into the catalog at
+ * startup; {@code null} leaves the catalog empty (uploads only)
+ * @param workDir directory where uploaded CSVs are persisted for the process lifetime;
+ * must NOT be an auto-deleted temp dir because joins re-open table files
+ * @param maxConcurrentQueries permits for the query semaphore; a miss returns 429
+ * @param query per-query budget defaults and hard caps
+ * @param upload upload feature flag and limits
+ */
+@ConfigurationProperties(prefix = "cuckoodb")
+public record CuckooDbProperties(
+ String dataDir,
+ String workDir,
+ Integer maxConcurrentQueries,
+ @NestedConfigurationProperty Query query,
+ @NestedConfigurationProperty Upload upload) {
+
+ public CuckooDbProperties {
+ if (workDir == null || workDir.isBlank()) {
+ workDir = System.getProperty("java.io.tmpdir") + "/cuckoodb-work";
+ }
+ if (maxConcurrentQueries == null || maxConcurrentQueries < 1) {
+ maxConcurrentQueries = Runtime.getRuntime().availableProcessors();
+ }
+ if (query == null) {
+ query = new Query(null, null, null, null);
+ }
+ if (upload == null) {
+ upload = new Upload(null, null, null);
+ }
+ }
+
+ /**
+ * Per-query budget settings. The server always attaches a budget (the engine has none by
+ * default = unlimited), so both a tuple and a time bound are required: a time-only budget
+ * would not cap a Sort/HashJoin build heap.
+ *
+ * @param maxTuplesDefault tuples charged when the request omits {@code maxTuples}
+ * @param maxTuplesCap hard ceiling; a larger request is clamped down to this
+ * @param timeoutMsDefault wall-clock ms when the request omits {@code timeoutMs}
+ * @param timeoutMsCap hard ceiling; a larger request is clamped down to this
+ */
+ public record Query(Long maxTuplesDefault, Long maxTuplesCap,
+ Long timeoutMsDefault, Long timeoutMsCap) {
+ public Query {
+ if (maxTuplesDefault == null) maxTuplesDefault = 100_000L;
+ if (maxTuplesCap == null) maxTuplesCap = 1_000_000L;
+ if (timeoutMsDefault == null) timeoutMsDefault = 5_000L;
+ if (timeoutMsCap == null) timeoutMsCap = 30_000L;
+ }
+ }
+
+ /**
+ * Upload feature settings. Disabled by default so the write surface stays closed until a
+ * deployment opts in (and a later governance phase is added).
+ *
+ * @param enabled whether {@code POST /tables/{name}} is mounted at all
+ * @param maxBytes per-upload size cap in bytes; a larger body returns 413
+ * @param maxTables process-wide table count ceiling; over it returns 507
+ */
+ public record Upload(Boolean enabled, Long maxBytes, Integer maxTables) {
+ /** Absolute ceiling on the configurable per-upload size cap. */
+ public static final long HARD_MAX_BYTES = 50L * 1024 * 1024;
+
+ public Upload {
+ if (enabled == null) enabled = false;
+ if (maxBytes == null) maxBytes = 10L * 1024 * 1024;
+ if (maxBytes > HARD_MAX_BYTES) maxBytes = HARD_MAX_BYTES;
+ if (maxTables == null || maxTables < 1) maxTables = 100;
+ }
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/config/OpenApiConfig.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/config/OpenApiConfig.java
new file mode 100644
index 0000000..40af3eb
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/config/OpenApiConfig.java
@@ -0,0 +1,27 @@
+package com.github.jinba1.cuckoodb.server.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * OpenAPI document metadata. springdoc auto-generates the schema and serves Swagger UI at
+ * {@code /swagger-ui.html} and the spec at {@code /v3/api-docs}; this only supplies the
+ * title/description/version so the contract documents the gateway's read-only, budgeted intent.
+ */
+@Configuration
+public class OpenApiConfig {
+
+ @Bean
+ public OpenAPI cuckooDbOpenApi() {
+ return new OpenAPI().info(new Info()
+ .title("cuckooDB REST API")
+ .version("1.0.0")
+ .description("A guarded, read-only-by-construction, resource-budgeted HTTP front "
+ + "door onto the cuckooDB in-memory query engine. POST /queries executes "
+ + "SQL synchronously; the /tables endpoints expose catalog schema and "
+ + "(opt-in) CSV upload."));
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ApiPrincipalResolver.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ApiPrincipalResolver.java
new file mode 100644
index 0000000..09c4979
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ApiPrincipalResolver.java
@@ -0,0 +1,37 @@
+package com.github.jinba1.cuckoodb.server.query;
+
+import org.springframework.stereotype.Component;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import java.util.Optional;
+
+/**
+ * Resolves the calling principal from the {@code X-Api-Key} header. Today it only echoes the
+ * key (or anonymous); a later governance phase turns this into real authentication and per-key
+ * policy. It exists now so the audit trail and budget policy can be keyed on a principal without
+ * a later signature change.
+ */
+@Component
+public class ApiPrincipalResolver {
+
+ /** Header carrying the (currently unauthenticated) API key. */
+ public static final String API_KEY_HEADER = "X-Api-Key";
+
+ /** Principal label used when no API key is presented. */
+ public static final String ANONYMOUS = "anonymous";
+
+ /** The presented API key, if any — not yet validated. */
+ public Optional apiKey(HttpServletRequest request) {
+ String key = request.getHeader(API_KEY_HEADER);
+ if (key == null || key.isBlank()) {
+ return Optional.empty();
+ }
+ return Optional.of(key.strip());
+ }
+
+ /** A principal label for audit/budget keying; {@link #ANONYMOUS} when no key is presented. */
+ public String resolve(HttpServletRequest request) {
+ return apiKey(request).orElse(ANONYMOUS);
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/query/BudgetPolicy.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/BudgetPolicy.java
new file mode 100644
index 0000000..652ddd4
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/BudgetPolicy.java
@@ -0,0 +1,51 @@
+package com.github.jinba1.cuckoodb.server.query;
+
+import com.github.jinba1.cuckoodb.server.config.CuckooDbProperties;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * Turns an optional client budget request into a concrete, always-present budget: each
+ * dimension defaults when the client omits it and is clamped down to the configured hard cap
+ * (the cap always wins). The result is never null and never has a null dimension, so the
+ * engine's "null = unlimited" path is unreachable through the server — the fail-closed
+ * guarantee. This is also the seam where future per-principal caps will apply.
+ */
+@Component
+public class BudgetPolicy {
+
+ private final CuckooDbProperties.Query query;
+
+ public BudgetPolicy(CuckooDbProperties properties) {
+ this.query = properties.query();
+ }
+
+ /**
+ * @param requestedMaxTuples client's tuple bound, or null to take the default
+ * @param requestedTimeoutMs client's time bound, or null to take the default
+ * @return a fully-populated budget, both dimensions non-null and within the hard caps
+ * @throws IllegalArgumentException if a client supplies a non-positive bound
+ */
+ public ClampedBudget clamp(Long requestedMaxTuples, Long requestedTimeoutMs) {
+ long maxTuples = clampDimension(requestedMaxTuples,
+ query.maxTuplesDefault(), query.maxTuplesCap(), "maxTuples");
+ long timeoutMs = clampDimension(requestedTimeoutMs,
+ query.timeoutMsDefault(), query.timeoutMsCap(), "timeoutMs");
+ return new ClampedBudget(maxTuples, timeoutMs);
+ }
+
+ private static long clampDimension(Long requested, long fallback, long cap, String name) {
+ if (requested == null) {
+ return fallback;
+ }
+ if (requested <= 0) {
+ throw new IllegalArgumentException(
+ name + " must be a positive integer; got " + requested);
+ }
+ return Math.min(requested, cap);
+ }
+
+ /** A concrete budget with both dimensions resolved and bounded; never null-valued. */
+ public record ClampedBudget(long maxTuples, long timeoutMs) {
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimitExceededException.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimitExceededException.java
new file mode 100644
index 0000000..d37de96
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimitExceededException.java
@@ -0,0 +1,11 @@
+package com.github.jinba1.cuckoodb.server.query;
+
+/**
+ * Thrown when the query concurrency semaphore is saturated. Maps to HTTP 429 with a
+ * {@code Retry-After} header — the condition is transient, so the client should retry shortly.
+ */
+public class ConcurrencyLimitExceededException extends RuntimeException {
+ public ConcurrencyLimitExceededException(String message) {
+ super(message);
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimiter.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimiter.java
new file mode 100644
index 0000000..967ed05
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/ConcurrencyLimiter.java
@@ -0,0 +1,54 @@
+package com.github.jinba1.cuckoodb.server.query;
+
+import com.github.jinba1.cuckoodb.server.config.CuckooDbProperties;
+
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+
+/**
+ * Bounds the number of queries executing at once with a single fair semaphore. The engine is
+ * CPU-bound and holds intermediate results in heap, so unbounded concurrency would exhaust
+ * memory and thrash the CPU; capping it keeps each admitted query's budget meaningful. A miss
+ * is surfaced as a 429 rather than queued indefinitely, so a saturated server fails fast.
+ */
+@Component
+public class ConcurrencyLimiter {
+
+ private final Semaphore semaphore;
+
+ public ConcurrencyLimiter(CuckooDbProperties properties) {
+ this.semaphore = new Semaphore(properties.maxConcurrentQueries(), true);
+ }
+
+ /**
+ * Runs {@code work} while holding one permit, releasing it in a finally. Acquisition is
+ * non-blocking: if no permit is free the work never starts.
+ * @throws ConcurrencyLimitExceededException if no permit could be acquired
+ */
+ public T withPermit(Supplier work) {
+ boolean acquired;
+ try {
+ acquired = semaphore.tryAcquire(0, TimeUnit.MILLISECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new ConcurrencyLimitExceededException("Query admission interrupted");
+ }
+ if (!acquired) {
+ throw new ConcurrencyLimitExceededException(
+ "Server is at its query concurrency limit; retry shortly");
+ }
+ try {
+ return work.get();
+ } finally {
+ semaphore.release();
+ }
+ }
+
+ /** Permits currently available — for tests and diagnostics. */
+ public int availablePermits() {
+ return semaphore.availablePermits();
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryService.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryService.java
new file mode 100644
index 0000000..bf88672
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryService.java
@@ -0,0 +1,84 @@
+package com.github.jinba1.cuckoodb.server.query;
+
+import com.github.jinba1.cuckoodb.CuckooDB;
+import com.github.jinba1.cuckoodb.ErrorCode;
+import com.github.jinba1.cuckoodb.PlannedQuery;
+import com.github.jinba1.cuckoodb.QueryBudget;
+import com.github.jinba1.cuckoodb.QueryConfig;
+import com.github.jinba1.cuckoodb.QueryExecutionException;
+import com.github.jinba1.cuckoodb.QueryPlanner;
+import com.github.jinba1.cuckoodb.QueryResultSet;
+import com.github.jinba1.cuckoodb.operator.Operator;
+import com.github.jinba1.cuckoodb.server.audit.AuditEvent;
+import com.github.jinba1.cuckoodb.server.audit.AuditSink;
+
+import org.springframework.stereotype.Service;
+
+/**
+ * The single choke point through which every query passes, so governance, budgeting,
+ * concurrency bounding, and audit cannot be bypassed by any controller.
+ *
+ *
The pipeline per request: acquire a concurrency permit → plan the SQL (String overload) →
+ * branch EXPLAIN (returns plan text, zero tuple budget) → clamp and attach a fail-closed budget
+ * → drain in memory → audit. A budget is always attached for an executed query: the
+ * engine treats a null budget as unlimited, and {@link BudgetPolicy} never yields null, so the
+ * unbounded path is unreachable here.
+ */
+@Service
+public class QueryService {
+
+ private final BudgetPolicy budgetPolicy;
+ private final ConcurrencyLimiter concurrencyLimiter;
+ private final AuditSink auditSink;
+
+ public QueryService(BudgetPolicy budgetPolicy, ConcurrencyLimiter concurrencyLimiter,
+ AuditSink auditSink) {
+ this.budgetPolicy = budgetPolicy;
+ this.concurrencyLimiter = concurrencyLimiter;
+ this.auditSink = auditSink;
+ }
+
+ /**
+ * Plans and (unless EXPLAIN) executes one query under a clamped budget, holding a
+ * concurrency permit for the whole call.
+ *
+ * @param sql the SQL text (optionally EXPLAIN-prefixed); never a file path
+ * @param maxTuples client tuple bound, or null to take the configured default
+ * @param timeoutMs client time bound, or null to take the configured default
+ * @param principal caller label for the audit trail
+ * @return the executed result set or the EXPLAIN plan text
+ * @throws QueryExecutionException (classified by {@code ErrorCode}) on any engine failure
+ */
+ public QueryServiceResult execute(String sql, Long maxTuples, Long timeoutMs, String principal) {
+ return concurrencyLimiter.withPermit(() -> runPlanned(sql, maxTuples, timeoutMs, principal));
+ }
+
+ private QueryServiceResult runPlanned(String sql, Long maxTuples, Long timeoutMs,
+ String principal) {
+ try {
+ PlannedQuery planned = QueryPlanner.planSql(sql, QueryConfig.defaults());
+
+ if (planned.explainText() != null) {
+ auditSink.record(AuditEvent.explain(principal, sql, planned.explainText()));
+ return QueryServiceResult.explain(planned.explainText());
+ }
+
+ BudgetPolicy.ClampedBudget budget = budgetPolicy.clamp(maxTuples, timeoutMs);
+ Operator root = planned.root();
+ root.attachBudget(new QueryBudget(budget.maxTuples(), budget.timeoutMs()));
+
+ QueryResultSet resultSet = CuckooDB.executeToResultSet(root);
+ auditSink.record(AuditEvent.success(
+ principal, sql, resultSet.rows().size(), resultSet.truncated()));
+ return QueryServiceResult.of(resultSet);
+ } catch (QueryExecutionException e) {
+ auditSink.record(AuditEvent.error(principal, sql, e.code().name()));
+ throw e;
+ } catch (RuntimeException e) {
+ // An unchecked failure (engine bug, or a budget-validation IllegalArgumentException)
+ // must still leave an audit trail — the choke point's audit cannot be bypassed.
+ auditSink.record(AuditEvent.error(principal, sql, ErrorCode.INTERNAL.name()));
+ throw e;
+ }
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryServiceResult.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryServiceResult.java
new file mode 100644
index 0000000..1aca7c5
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/query/QueryServiceResult.java
@@ -0,0 +1,26 @@
+package com.github.jinba1.cuckoodb.server.query;
+
+import com.github.jinba1.cuckoodb.QueryResultSet;
+
+/**
+ * The two shapes a successful {@code QueryService} call can produce: an executed result set,
+ * or EXPLAIN plan text (no execution). Exactly one field is non-null. Keeping them in one
+ * return type lets the controller branch on {@link #isExplain()} without a second service call.
+ *
+ * @param resultSet the materialized rows for an executed query, or null for EXPLAIN
+ * @param explainText the rendered plan for an EXPLAIN request, or null for an executed query
+ */
+public record QueryServiceResult(QueryResultSet resultSet, String explainText) {
+
+ public static QueryServiceResult of(QueryResultSet resultSet) {
+ return new QueryServiceResult(resultSet, null);
+ }
+
+ public static QueryServiceResult explain(String explainText) {
+ return new QueryServiceResult(null, explainText);
+ }
+
+ public boolean isExplain() {
+ return explainText != null;
+ }
+}
diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/web/GlobalExceptionHandler.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/web/GlobalExceptionHandler.java
new file mode 100644
index 0000000..61f1bcb
--- /dev/null
+++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/web/GlobalExceptionHandler.java
@@ -0,0 +1,157 @@
+package com.github.jinba1.cuckoodb.server.web;
+
+import com.github.jinba1.cuckoodb.BudgetKind;
+import com.github.jinba1.cuckoodb.ErrorCode;
+import com.github.jinba1.cuckoodb.QueryBudgetExceededException;
+import com.github.jinba1.cuckoodb.QueryExecutionException;
+import com.github.jinba1.cuckoodb.server.query.ConcurrencyLimitExceededException;
+import com.github.jinba1.cuckoodb.server.web.dto.ErrorResponse;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.context.request.WebRequest;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+
+import java.util.UUID;
+
+/**
+ * Translates every failure into the uniform {@code {errorCode, message}} body with the right
+ * HTTP status. Engine {@link ErrorCode}s map to 4xx where the caller can act and 5xx only
+ * for genuine engine faults. Two rules protect the client: a budget breach splits by
+ * {@link BudgetKind} (tuples → 429+Retry-After, time → 504), and every 5xx returns a generic
+ * message plus a correlation {@code errorId} while the full detail (which may embed filesystem
+ * paths) is logged server-side only — stack traces are never serialized.
+ *
+ *
Extends {@link ResponseEntityExceptionHandler} so Spring's own request exceptions (unknown
+ * route, wrong method, etc.) keep their correct statuses instead of collapsing into the 500
+ * fallback; only the media-type and unreadable-body cases are overridden to use our body shape.
+ */
+@RestControllerAdvice
+public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
+
+ private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+ /** Retry-After value (seconds) for transient 429s. */
+ private static final String RETRY_AFTER_SECONDS = "1";
+
+ /** Budget breach: distinguish a too-big result (429, retry narrower) from too-slow (504). */
+ @ExceptionHandler(QueryBudgetExceededException.class)
+ public ResponseEntity handleBudget(QueryBudgetExceededException ex) {
+ ErrorResponse body = ErrorResponse.of(ex.code().name(), ex.getMessage());
+ if (ex.kind() == BudgetKind.TIME) {
+ return ResponseEntity.status(HttpStatus.GATEWAY_TIMEOUT).body(body);
+ }
+ return retryable(HttpStatus.TOO_MANY_REQUESTS, body);
+ }
+
+ /** All other classified engine failures. */
+ @ExceptionHandler(QueryExecutionException.class)
+ public ResponseEntity handleEngine(QueryExecutionException ex) {
+ ErrorCode code = ex.code();
+ ErrorResponse verbatim = ErrorResponse.of(code.name(), ex.getMessage());
+ return switch (code) {
+ case PARSE_ERROR -> ResponseEntity.badRequest().body(verbatim);
+ case UNSUPPORTED_SQL, UNKNOWN_TABLE, UNKNOWN_COLUMN, TYPE_MISMATCH ->
+ ResponseEntity.unprocessableEntity().body(verbatim);
+ // A bare BUDGET_EXCEEDED (not the kinded subclass) is treated as the tuples case.
+ case BUDGET_EXCEEDED -> retryable(HttpStatus.TOO_MANY_REQUESTS, verbatim);
+ // Query-path data/internal faults can embed absolute paths — scrub to a generic 500.
+ case DATA_ERROR, INTERNAL -> internal(code, ex);
+ };
+ }
+
+ /** Concurrency saturation: transient, so 429 + Retry-After. */
+ @ExceptionHandler(ConcurrencyLimitExceededException.class)
+ public ResponseEntity handleConcurrency(ConcurrencyLimitExceededException ex) {
+ return retryable(HttpStatus.TOO_MANY_REQUESTS,
+ ErrorResponse.of("CONCURRENCY_LIMIT", ex.getMessage()));
+ }
+
+ @ExceptionHandler(TableNotFoundException.class)
+ public ResponseEntity handleTableNotFound(TableNotFoundException ex) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(ErrorResponse.of("UNKNOWN_TABLE", ex.getMessage()));
+ }
+
+ @ExceptionHandler(UploadDisabledException.class)
+ public ResponseEntity handleUploadDisabled(UploadDisabledException ex) {
+ return ResponseEntity.status(HttpStatus.NOT_FOUND)
+ .body(ErrorResponse.of("NOT_FOUND", ex.getMessage()));
+ }
+
+ @ExceptionHandler(TableAlreadyExistsException.class)
+ public ResponseEntity handleTableExists(TableAlreadyExistsException ex) {
+ return ResponseEntity.status(HttpStatus.CONFLICT)
+ .body(ErrorResponse.of("TABLE_EXISTS", ex.getMessage()));
+ }
+
+ @ExceptionHandler(PayloadTooLargeException.class)
+ public ResponseEntity handlePayloadTooLarge(PayloadTooLargeException ex) {
+ return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE)
+ .body(ErrorResponse.of("PAYLOAD_TOO_LARGE", ex.getMessage()));
+ }
+
+ @ExceptionHandler(TableLimitExceededException.class)
+ public ResponseEntity handleTableLimit(TableLimitExceededException ex) {
+ return ResponseEntity.status(HttpStatus.INSUFFICIENT_STORAGE)
+ .body(ErrorResponse.of("TABLE_LIMIT", ex.getMessage()));
+ }
+
+ @ExceptionHandler(InvalidUploadException.class)
+ public ResponseEntity handleInvalidUpload(InvalidUploadException ex) {
+ return ResponseEntity.badRequest().body(ErrorResponse.of("DATA_ERROR", ex.getMessage()));
+ }
+
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity handleIllegalArgument(IllegalArgumentException ex) {
+ return ResponseEntity.badRequest().body(ErrorResponse.of("BAD_REQUEST", ex.getMessage()));
+ }
+
+ /** Anything unclassified is an engine/server bug: scrub the body, log the detail. */
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity handleUnexpected(Exception ex) {
+ String errorId = UUID.randomUUID().toString();
+ log.error("Unhandled exception errorId={}", errorId, ex);
+ return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+ .body(ErrorResponse.withId("INTERNAL", "Internal server error.", errorId));
+ }
+
+ @Override
+ protected ResponseEntity