From 059338b2f03ae113e772a2e5a16f379d43ca77b5 Mon Sep 17 00:00:00 2001 From: Daniel Nicoletti Date: Thu, 1 Oct 2026 17:49:46 -0300 Subject: [PATCH] cutelyst: add async-db and fortunes via ASql and Cutelee. Wire thread-local Postgres pools, prepared range queries, and a typed fortunes sort rendered through a preloaded Cutelee template. Co-authored-by: Cursor --- frameworks/cutelyst/CMakeLists.txt | 7 ++ frameworks/cutelyst/Dockerfile | 54 +++++++++++- frameworks/cutelyst/README.md | 8 +- frameworks/cutelyst/app.cpp | 50 +++++++++++ frameworks/cutelyst/app.h | 1 + frameworks/cutelyst/build.sh | 15 ++++ frameworks/cutelyst/fortunes.html | 1 + frameworks/cutelyst/meta.json | 6 +- frameworks/cutelyst/root.cpp | 136 +++++++++++++++++++++++++++++ frameworks/cutelyst/root.h | 6 ++ 10 files changed, 279 insertions(+), 5 deletions(-) create mode 100755 frameworks/cutelyst/build.sh create mode 100644 frameworks/cutelyst/fortunes.html diff --git a/frameworks/cutelyst/CMakeLists.txt b/frameworks/cutelyst/CMakeLists.txt index 39776e819..ba810e348 100644 --- a/frameworks/cutelyst/CMakeLists.txt +++ b/frameworks/cutelyst/CMakeLists.txt @@ -8,6 +8,8 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON) find_package(Qt6 6.5 REQUIRED COMPONENTS Core Network) find_package(Cutelyst5Qt6 5.1.0 REQUIRED) +find_package(ASql1Qt6 REQUIRED) +find_package(Cutelee6Qt6 REQUIRED) add_executable(server main.cpp @@ -22,4 +24,9 @@ target_link_libraries(server PRIVATE Qt6::Network Cutelyst::Core Cutelyst::Server + Cutelyst::View::Cutelee + ASql::Core + ASql::Pg ) + +# fortunes.html is loaded from CUTELEE_INCLUDE_PATH or /templates at runtime. diff --git a/frameworks/cutelyst/Dockerfile b/frameworks/cutelyst/Dockerfile index 16404380f..634cfa6c9 100644 --- a/frameworks/cutelyst/Dockerfile +++ b/frameworks/cutelyst/Dockerfile @@ -4,6 +4,9 @@ ENV DEBIAN_FRONTEND=noninteractive # Chunked request bodies + TCP-fragmented CRLF (HttpArena baseline / 8gbit). ARG CUTELYST_REF=5ec5ce95d80a87b38ea45a326945bb22e515a23a +# ASql 1.x (async Postgres). Pin when tagging a release. +ARG ASQL_REF=eebafdde3069008626a558b15ae641ab356c799b +ARG CUTELEE_REF=v6.2.0 RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ @@ -15,9 +18,45 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ qt6-base-dev \ qt6-tools-dev \ qt6-l10n-tools \ + qt6-declarative-dev \ + libpq-dev \ && rm -rf /var/lib/apt/lists/* WORKDIR /src + +# Django-style templates for fortunes (not in Ubuntu archives). +RUN git init cutelee \ + && cd cutelee \ + && git remote add origin https://github.com/cutelyst/cutelee.git \ + && git fetch --depth 1 origin "${CUTELEE_REF}" \ + && git checkout FETCH_HEAD \ + && cmake -GNinja -B build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DBUILD_TEMPLATES=ON \ + -DBUILD_TEXTDOCUMENT=OFF \ + -DBUILD_TESTS=OFF \ + -DBUILD_DOCS=OFF \ + && cmake --build build \ + && cmake --install build + +# Async Postgres driver for async-db / fortunes. +RUN git init asql \ + && cd asql \ + && git remote add origin https://github.com/cutelyst/asql.git \ + && git fetch --depth 1 origin "${ASQL_REF}" \ + && git checkout FETCH_HEAD \ + && cmake -GNinja -B build -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX=/usr \ + -DASQL_DRIVER_POSTGRES=ON \ + -DASQL_DRIVER_MYSQL=OFF \ + -DASQL_DRIVER_ODBC=OFF \ + -DASQL_DRIVER_SQLITE=OFF \ + -DASQL_BUILD_DEMOS=OFF \ + -DASQL_MIGRATION_TOOL=OFF \ + -DBUILD_TESTING=OFF \ + && cmake --build build \ + && cmake --install build + RUN git init cutelyst \ && cd cutelyst \ && git remote add origin https://github.com/cutelyst/cutelyst.git \ @@ -26,6 +65,7 @@ RUN git init cutelyst \ && cmake -GNinja -B build -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_INSTALL_PREFIX=/usr \ -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=OFF -DBUILD_DOCS=OFF \ + -DPLUGIN_VIEW_CUTELEE=ON \ && cmake --build build \ && cmake --install build @@ -40,14 +80,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ninja-build \ pkg-config \ qt6-base-dev \ + qt6-declarative-dev \ + libpq-dev \ && rm -rf /var/lib/apt/lists/* COPY --from=cutelyst-build /usr /usr WORKDIR /app -COPY CMakeLists.txt main.cpp app.cpp app.h root.cpp root.h ./ +COPY CMakeLists.txt main.cpp app.cpp app.h root.cpp root.h fortunes.html ./ RUN cmake -GNinja -B build -DCMAKE_BUILD_TYPE=Release \ - && cmake --build build + && cmake --build build \ + && mkdir -p /app/templates \ + && cp fortunes.html /app/templates/fortunes.html FROM ubuntu:26.04 @@ -57,6 +101,8 @@ ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ wget \ + libpq5 \ + libqt6qml6 \ && wget -q https://github.com/cutelyst/cutelyst/releases/download/v5.1.0/cutelyst5-qt6_5.1.0_amd64.deb \ && apt-get install -y --no-install-recommends ./cutelyst5-qt6_5.1.0_amd64.deb \ && rm -f cutelyst5-qt6_5.1.0_amd64.deb \ @@ -64,6 +110,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=cutelyst-build /usr/lib/ /usr/lib/ COPY --from=app-build /app/build/server /server +COPY --from=app-build /app/templates /templates + +ENV CUTELEE_INCLUDE_PATH=/templates +ENV LANG=C.UTF-8 EXPOSE 8080 8081 CMD ["/server"] diff --git a/frameworks/cutelyst/README.md b/frameworks/cutelyst/README.md index 2b3eda37d..b58ec7484 100644 --- a/frameworks/cutelyst/README.md +++ b/frameworks/cutelyst/README.md @@ -6,6 +6,8 @@ Cutelyst on Cutelyst::Server, one worker thread per core. - **Language:** C++23 - **Framework:** Cutelyst (Qt 6), built from upstream commit `5ec5ce95` (chunked request body support on top of 5.1) +- **SQL:** [ASql](https://github.com/cutelyst/asql) async Postgres (`ASql::Pg`), thread-local pools sized from `DATABASE_MAX_CONN` +- **Templates:** Cutelee (`Cutelyst::View::Cutelee`) for fortunes - **Build:** CMake on Ubuntu 26.04 ## Endpoints @@ -18,10 +20,14 @@ Cutelyst on Cutelyst::Server, one worker thread per core. | `/delay/{ms}` | GET | Async wait via `ASync` + `QTimer`, then echoes `ms` | | `/json/{count}?m=N` | GET | First `count` dataset items with `total = price * quantity * m` | | `/echo` | POST | Returns the request body back verbatim | +| `/async-db?min=&max=&limit=` | GET | Async Postgres range query on `items` (ASql) | +| `/fortunes` | GET | Postgres `fortune` rows + runtime row, rendered with Cutelee | ## Notes -- Routing through Cutelyst controllers (`:Local` / `:AutoArgs`) +- Routing through Cutelyst controllers (`:Local` / `:AutoArgs` / `:Path`) - JSON through `Response::setJsonObjectBody` (`QJsonDocument`) - TLS on `:8081` when `/certs/server.crt` and `/certs/server.key` are mounted - `json-comp` is not subscribed yet: Cutelyst has no built-in dynamic response gzip/brotli middleware under standard rules +- Postgres: read `DATABASE_URL` and size each worker-thread ASql pool as `max(1, min(DATABASE_MAX_CONN, 240) / idealThreadCount)` so 64 threads stay under the sidecar `max_connections` +- Fortunes template lives at `/templates/fortunes.html` (`CUTELEE_INCLUDE_PATH`) diff --git a/frameworks/cutelyst/app.cpp b/frameworks/cutelyst/app.cpp index ed15e5674..61417fe74 100644 --- a/frameworks/cutelyst/app.cpp +++ b/frameworks/cutelyst/app.cpp @@ -2,8 +2,21 @@ #include "root.h" +#include + +#include +#include +#include +#include + #include +#include +#include + +#include +using namespace ASql; +using namespace Cutelyst; using namespace Qt::StringLiterals; ArenaApp::ArenaApp(QObject *parent) @@ -14,6 +27,43 @@ ArenaApp::ArenaApp(QObject *parent) bool ArenaApp::init() { + auto *view = new CuteleeView(this); + const char *env = std::getenv("CUTELEE_INCLUDE_PATH"); + const QString includePath = env ? QString::fromLocal8Bit(env) : u"/templates"_s; + view->setIncludePaths({includePath, QDir::currentPath()}); + view->preloadTemplates(); + new Root(this); return true; } + +bool ArenaApp::postFork() +{ + // ASql pools are thread_local; each Cutelyst::Server worker thread gets its own pool. + const char *url = std::getenv("DATABASE_URL"); + if (!url || !*url) { + return true; + } + + int maxConn = 256; + if (const char *env = std::getenv("DATABASE_MAX_CONN")) { + bool ok = false; + const int parsed = QByteArray(env).toInt(&ok); + if (ok && parsed > 0) { + maxConn = parsed; + } + } + + const int threads = qMax(1, QThread::idealThreadCount()); + const int perThread = qMax(1, qMin(maxConn, 240) / threads); + + APool::create(APg::factory(QString::fromUtf8(url))); + APool::setMaxIdleConnections(perThread); + APool::setMaxConnections(perThread); + APool::setSetupHook([](ADatabase db) -> ACoroTerminator { + db.enterPipelineMode(std::chrono::milliseconds{500}); + co_return; + }); + + return true; +} diff --git a/frameworks/cutelyst/app.h b/frameworks/cutelyst/app.h index 58c794904..27c45c817 100644 --- a/frameworks/cutelyst/app.h +++ b/frameworks/cutelyst/app.h @@ -12,6 +12,7 @@ class ArenaApp : public Cutelyst::Application ~ArenaApp() override = default; bool init() override; + bool postFork() override; }; #endif diff --git a/frameworks/cutelyst/build.sh b/frameworks/cutelyst/build.sh new file mode 100755 index 000000000..971861e5c --- /dev/null +++ b/frameworks/cutelyst/build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Optional wrapper so local re-runs can skip a full rebuild. +# HttpArena calls this when present (see scripts/lib/framework.sh). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +IMAGE_NAME="${IMAGE_NAME:-httparena-cutelyst}" + +if [[ "${SKIP_FRAMEWORK_BUILD:-0}" == "1" ]] \ + && docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then + echo "[build] SKIP_FRAMEWORK_BUILD=1 — reusing existing $IMAGE_NAME" + exit 0 +fi + +docker build -t "$IMAGE_NAME" "$SCRIPT_DIR" diff --git a/frameworks/cutelyst/fortunes.html b/frameworks/cutelyst/fortunes.html new file mode 100644 index 000000000..ada0e3ac4 --- /dev/null +++ b/frameworks/cutelyst/fortunes.html @@ -0,0 +1 @@ +Fortunes{% for fortune in fortunes %}{% endfor %}
idmessage
{{ fortune.id }}{{ fortune.message }}
diff --git a/frameworks/cutelyst/meta.json b/frameworks/cutelyst/meta.json index e030380cb..13b04a3a0 100644 --- a/frameworks/cutelyst/meta.json +++ b/frameworks/cutelyst/meta.json @@ -10,7 +10,7 @@ "response": true }, "engine": "Cutelyst::Server", - "description": "Cutelyst on Cutelyst::Server with one thread per core. Catalyst-style controllers and routing, QJsonDocument serialization, async delays via ASync and QTimer.", + "description": "Cutelyst on Cutelyst::Server with one thread per core. Catalyst-style controllers and routing, QJsonDocument serialization, async delays via ASync and QTimer, async Postgres via ASql, fortunes via Cutelee.", "repo": "https://github.com/cutelyst/cutelyst", "enabled": true, "tests": [ @@ -22,7 +22,9 @@ "pipelined", "limited-conn", "json-tls", - "8gbit" + "8gbit", + "async-db", + "fortunes" ], "maintainers": ["dantti"] } diff --git a/frameworks/cutelyst/root.cpp b/frameworks/cutelyst/root.cpp index 6bfabe562..ecfe7fd33 100644 --- a/frameworks/cutelyst/root.cpp +++ b/frameworks/cutelyst/root.cpp @@ -3,14 +3,26 @@ #include #include #include +#include + +#include +#include +#include +#include #include +#include #include #include #include +#include +#include +#include +#include #include +using namespace ASql; using namespace Qt::StringLiterals; namespace { @@ -29,6 +41,111 @@ qint64 toLong(const QByteArray &value) return ok ? n : 0; } +int queryInt(Context *c, const QString &key, int defaultValue) +{ + const QString raw = c->req()->queryParam(key); + if (raw.isEmpty()) { + return defaultValue; + } + bool ok = false; + const int n = raw.toInt(&ok); + return ok ? n : defaultValue; +} + +void emptyAsyncDb(Context *c) +{ + c->res()->setJsonObjectBody({ + {u"items"_s, QJsonArray{}}, + {u"count"_s, 0}, + }); +} + +ACoroTerminator runAsyncDb(Context *c, ASync async, int min, int max, int limit) +{ + Q_UNUSED(async); + + auto result = co_await APool::exec( + APreparedQueryLiteral( + u"SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " + u"FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3"), + QVariantList{min, max, limit}, + c); + + if (!result) { + emptyAsyncDb(c); + co_return; + } + + QJsonArray items; + for (const auto &row : *result) { + QJsonObject item; + item.insert(u"id"_s, row[0].toInt()); + item.insert(u"name"_s, row[1].toString()); + item.insert(u"category"_s, row[2].toString()); + item.insert(u"price"_s, row[3].toInt()); + item.insert(u"quantity"_s, row[4].toInt()); + item.insert(u"active"_s, row[5].toBool()); + item.insert(u"tags"_s, row[6].toJsonValue()); + item.insert(u"rating"_s, + QJsonObject{ + {u"score"_s, row[7].toInt()}, + {u"count"_s, row[8].toInt()}, + }); + items.append(item); + } + + c->res()->setJsonObjectBody({ + {u"items"_s, items}, + {u"count"_s, items.size()}, + }); +} + +struct FortuneRow { + int id = 0; + QString message; +}; + +ACoroTerminator runFortunes(Context *c, ASync async) +{ + Q_UNUSED(async); + + auto result = co_await APool::exec( + APreparedQueryLiteral(u"SELECT id, message FROM fortune"), c); + + if (!result) { + c->res()->setStatus(Response::InternalServerError); + co_return; + } + + QVector rows; + rows.reserve(result->size() + 1); + for (const auto &row : *result) { + rows.append(FortuneRow{row[0].toInt(), row[1].toString()}); + } + rows.append(FortuneRow{0, u"Additional fortune added at request time."_s}); + + std::sort(rows.begin(), rows.end(), [](const FortuneRow &a, const FortuneRow &b) { + return a.message < b.message; + }); + + QVariantList fortunes; + fortunes.reserve(rows.size()); + for (const FortuneRow &row : rows) { + fortunes.append(QVariantMap{ + {u"id"_s, row.id}, + {u"message"_s, row.message}, + }); + } + + c->setStash(u"template"_s, u"fortunes.html"_s); + c->setStash(u"fortunes"_s, fortunes); + + if (View *view = c->view()) { + view->execute(c); + } + c->response()->setContentType("text/html; charset=utf-8"_ba); +} + } // namespace Root::Root(QObject *app) @@ -124,3 +241,22 @@ void Root::echo(Context *c) c->res()->setContentType("application/octet-stream"_ba); c->res()->setBody(data); } + +void Root::async_db(Context *c) +{ + int min = queryInt(c, u"min"_s, 10); + int max = queryInt(c, u"max"_s, 50); + int limit = queryInt(c, u"limit"_s, 50); + if (limit < 1) { + limit = 1; + } else if (limit > 50) { + limit = 50; + } + + runAsyncDb(c, ASync(c), min, max, limit); +} + +void Root::fortunes(Context *c) +{ + runFortunes(c, ASync(c)); +} diff --git a/frameworks/cutelyst/root.h b/frameworks/cutelyst/root.h index eee9c44a8..b2d93d062 100644 --- a/frameworks/cutelyst/root.h +++ b/frameworks/cutelyst/root.h @@ -29,6 +29,12 @@ class Root : public Controller C_ATTR(echo, :Local :AutoArgs) void echo(Context *c); + C_ATTR(async_db, :Path("async-db") :Args(0)) + void async_db(Context *c); + + C_ATTR(fortunes, :Local :AutoArgs) + void fortunes(Context *c); + private: QJsonArray m_dataset; };