Adding engine and protocol support
This guide separates two ideas that are easy to conflate:
| Concept | Meaning | Example |
|---|---|---|
| Backend engine | A cluster type QueryFlux routes queries to. It has an adapter that talks to the real database (HTTP, MySQL wire, embedded library, AWS SDK, …). | Trino, DuckDB, StarRocks, Athena |
| Frontend protocol | How clients connect to QueryFlux (ingress). SQL enters with a FrontendProtocol and a default source dialect for translation. | Trino HTTP, PostgreSQL wire, MySQL wire, Flight SQL |
Adding PostgreSQL wire as a client entrypoint is not the same as adding “PostgreSQL” as a backend: today, PostgresWire is already a frontend in queryflux-frontend; traffic still lands on the shared dispatch path and is sent to whatever backend adapter routing chose (often Trino).
Use the sections below depending on whether you are extending Studio, a backend adapter, or a frontend listener.
Part A — Backend engine (Rust)
Goal: a new engine value in cluster config, a live adapter, validation, translation target dialect, and wiring in the binary.
Registration overview
Backends are not loaded dynamically. Each engine is compiled in and registered explicitly. Data flow:
- Postgres / YAML →
engine_keycolumn +configJSONB →ClusterConfigRecord::to_core()usesparse_engine_keyand JSON helpers → typedClusterConfig. - Binary →
registered_engines::build_adapter(...)matchesEngineConfigand calls the adapter’stry_from_cluster_config(seecrates/queryflux/src/registered_engines.rs). - Adapter → reads only the
ClusterConfigfields it needs (endpoint, auth, region, …) and constructs itself; startup and hot reload both use the same factory.
JSONB stores per-cluster, per-engine payload without schema migrations; ClusterConfig in core is the typed view after to_core(). Engine-specific wiring belongs in try_from_cluster_config, not in main.rs.
1. Core model (queryflux-core)
EngineConfig— Add a variant incrates/queryflux-core/src/config.rs(serde camelCase in JSON/YAML, e.g.myEngine).EngineType— Add a variant incrates/queryflux-core/src/query.rsif the backend is distinct for metrics, translation, or dispatch.engine_registry(crates/queryflux-core/src/engine_registry.rs) — Keep these in sync when you add a variant:engine_key(&EngineConfig)—EngineConfig→ stable string key (must match the adapter descriptor and Studio).parse_engine_key(&str)— inverse mapping for theengine_keycolumn in Postgres / API.impl From<&EngineConfig> for EngineType— single place for config → runtimeEngineType(cluster manager andmain.rsuse this instead of ad-hoc matches).
EngineType::dialect()— Return theSqlDialectused as the translation target (and extendSqlDialect/is_compatible_within translation if needed). See query-translation.md.ClusterConfigfields — Add any new top-level fields (region, paths, engine-specific blobs). Prefer keeping engine-specific secrets and options inconfigJSON for Postgres-backed clusters; extend the typed struct when YAML and validation need them everywhere.
2. Adapter crate (queryflux-engine-adapters)
- Add a module (e.g.
src/myengine/mod.rs) implementingEngineAdapterTrait(submit_query,poll_query,cancel_query,health_check,engine_type,supports_async, and optionallyfetch_running_query_count,base_url, Arrow/catalog hooks as needed). - Implement
descriptor() -> EngineDescriptorwith:engine_key,display_name,description,hexconnection_type(Http,MySqlWire,Embedded,ManagedApi)supported_authandconfig_fields(these drive/admin/engine-registryand should stay aligned with Studio)implemented: truewhen the adapter is actually wired inmain
- Export the module from
crates/queryflux-engine-adapters/src/lib.rsand add the crate dependency if you introduce new third-party crates.
Factory — try_from_cluster_config
Implement on your adapter struct so all field extraction and validation for that engine live next to the adapter (not in registered_engines.rs):
-
Sync (most engines):
fn try_from_cluster_config(
cluster_name: ClusterName,
group_name: ClusterGroupName,
cfg: &ClusterConfig,
cluster_name_str: &str,
) -> queryflux_core::error::Result<Self> -
Async (e.g. Athena — AWS client setup): same parameters,
async fn, returnsResult<Self>.
Use QueryFluxError::Engine(format!(…)) for failures; include cluster_name_str in messages so startup and reload logs identify the cluster. Reference implementations: TrinoAdapter and StarRocksAdapter (trino/mod.rs, starrocks/mod.rs), DuckDbAdapter / DuckDbHttpAdapter (duckdb/mod.rs, duckdb/http.rs), AthenaAdapter (athena/mod.rs).
Keep pub fn new(...) (or async fn new) as the low-level constructor if you want tests to build adapters without a full ClusterConfig; try_from_cluster_config can delegate to new after parsing cfg.
3. Binary wiring (crates/queryflux)
Registration is centralized in crates/queryflux/src/registered_engines.rs:
all_descriptors()— AppendMyEngineAdapter::descriptor()to the returnedvec!.main.rsbuildsEngineRegistry::new(registered_engines::all_descriptors())for validation andGET /admin/engine-registry.build_adapter(cluster_name, placeholder_group, cluster_cfg, cluster_name_str).await— Returnsanyhow::Result<Arc<dyn EngineAdapterTrait>>. Add amatcharm onEngineConfig::MyEnginethat callsMyEngineAdapter::try_from_cluster_config(...), mapsQueryFluxErrortoanyhow::Error(same helper as other arms), and wrapsArc::new(...). Startup uses.context(...)?on the result; hot reload inbuild_live_configlogs a warning andcontinueon error — behavior stays inmain.rs, not in the factory.
Do not add a second adapter-construction match in main.rs.
Not implemented yet: e.g. EngineConfig::ClickHouse is handled inside build_adapter with anyhow::bail! until a ClickHouseAdapter and try_from_cluster_config exist.
-
EngineTypefor cluster state — Inmain.rsand anywhere else (e.g. group memberClusterState), useEngineType::from(engine_config)fromengine_registry.rs.queryflux-cluster-managerengine affinity uses the sameFromimpl (seestrategy.rs). -
Special rules — Search for engine-specific checks (e.g.
queryAuth/ impersonation) and extend validation if your engine has constraints.
4. Dispatch and frontends (queryflux-frontend)
- Shared query execution goes through
dispatch_query/execute_to_sink. Usually no change if the new engine only differs in the adapter; if you need a special execution path (like Trino raw HTTP), follow the existing engine-specific branches. - Per-protocol handlers (Trino HTTP, Postgres wire, …) should keep using the shared dispatch layer unless the protocol requires a dedicated contract.
5. Persistence (queryflux-persistence) — why touch it if config is JSON?
The table stores engine_key as its own column plus a config JSONB blob. The DB does not load straight into the proxy as opaque JSON: code paths call ClusterConfigRecord::to_core(), which must produce a typed ClusterConfig (including EngineConfig).
So persistence changes are not “because Postgres needs a JSON schema.” They are because of this explicit conversion layer:
ClusterConfigRecord::to_core— Callsparse_engine_keyfromqueryflux-core(next toengine_key). Extendparse_engine_keywhen you add an engine; you do not maintain a second duplicate string match in persistence.UpsertClusterConfig::from_core— Usesengine_key(&EngineConfig)from core to set theengine_keycolumn when seeding from YAML.
Extra JSON keys that only live inside config and are already read in to_core (e.g. endpoint, region, authType, …) usually need no persistence change beyond the engine-key match. You only extend the s("…") / b("…") helpers in to_core (and the matching from_core inserts) if you add new top-level persisted fields on ClusterConfig that should round-trip through that JSON.
Hot reload often uses list_cluster_configs → records → to_core() → build_live_config; the same conversion applies.
6. Optional: routing config
- If operators choose the new group via router JSON (
RouterConfigvariants), no change unless you add a new router type. - Protocol-based routing maps frontend labels to group names; it does not list backend engines.
7. Tests and docs
- Add or extend e2e tests under
crates/queryflux-e2e-testsif you have a dockerized target. - Update system-map.md component status if you document supported engines there.
8. Suggested order of work (backend only)
EngineConfig/EngineType+engine_key/parse_engine_key/From<&EngineConfig> for EngineType+dialect()if needed.ClusterConfigfields if the engine needs new top-level keys (and persistenceto_coreJSON extraction if those keys live in JSONB).- Adapter module:
EngineAdapterTrait,descriptor(),try_from_cluster_config. registered_engines.rs: descriptor inall_descriptors(), arm inbuild_adapter.- Run
cargo build -p queryflux; exercise YAML and Postgres load + admin upsert if applicable.
Part B — QueryFlux Studio (UI, TypeScript / React)
Studio is the Next.js app under ui/queryflux-studio/. It does not run wire protocols; it calls the Admin API (ADMIN_API_URL, default http://localhost:9000) for clusters, groups, routing, and scripts.
Backend engines are registered in Studio through StudioEngineModule objects: one file per engine under lib/studio-engines/engines/, aggregated in lib/studio-engines/manifest.ts. That manifest drives ENGINE_REGISTRY, catalog slots for implemented backends, optional flat-form validation, engine-affinity dropdown entries, and extra findEngineByType aliases.
The proxy still exposes descriptors at GET /admin/engine-registry. Studio does not load that at runtime yet, so Rust descriptor() and each studio module’s descriptor field must stay aligned by hand (same engineKey, configFields keys, auth shapes, etc.). Shared TypeScript types live in lib/engine-registry-types.ts; lib/engine-registry.ts only re-exports helpers and builds ENGINE_REGISTRY from the manifest.
Where users see engines
| User action | UI entrypoint | What must know your engine |
|---|---|---|
| Create cluster | Clusters → Add cluster (components/add-cluster-dialog.tsx) | Expanded ENGINE_CATALOG (includes studio slots) + findEngineDescriptor + validateClusterConfig / validateEngineSpecific + toUpsertBody |
| Edit cluster | Clusters grid → cluster card → Edit (app/clusters/clusters-grid.tsx) | Same + mergeClusterConfigFromFlat / buildClusterUpsertFromForm + EngineClusterConfig |
| View config | Cluster detail / engine config view in clusters-grid.tsx | findEngineDescriptor for labels; unknown key shows “add to engine registry” warning |
| Group strategy engine affinity | Engines → group dialog → strategy (components/group-form-dialog.tsx) | ENGINE_AFFINITY_OPTIONS is built by buildEngineAffinityOptionsFromManifest() from each module’s engineAffinity field (omit label override, or set engineAffinity: false to exclude, e.g. Athena). |
| Live utilization cards | Engines (Groups) page (app/engines/page.tsx) | findEngineByType; studio modules contribute aliases via extraTypeAliases (merged with static dialect aliases in components/engine-catalog.ts) |
1. Studio engine module (primary registration)
Types: ui/queryflux-studio/lib/studio-engines/types.ts — StudioEngineModule.
Per engine: ui/queryflux-studio/lib/studio-engines/engines/<engine>.ts
Export a constant (e.g. trinoStudioEngine) with:
descriptor— FullEngineDescriptor(must match Rust:engineKey,connectionType,supportedAuth,configFields,implemented, brandinghex, etc.). ExtendConnectionType/AuthTypeinlib/engine-registry-types.tsif Rust added a variant.catalog—category,simpleIconSlug,catalogDescriptionfor the engines grid / picker (display name andsupportedcome from the descriptor when the catalog is expanded).validateFlat(optional) — Cross-field checks before save (e.g. Trino basic vs bearer). Dispatched byvalidateEngineSpecificinlib/studio-engines/validate-flat.ts(re-exported fromlib/cluster-persist-form.ts).customFormId(optional) — String key; must match an entry incomponents/cluster-config/studio-engine-forms.tsxif the genericGenericEngineClusterConfigis not enough.engineAffinity(optional) —falseto omit from affinity, or{ label?: string }for a custom dropdown label (default label isdisplayName).extraTypeAliases(optional) — Map of normalized API/type strings → canonicalEngineDef.nameforfindEngineByType(e.g. alternate spellings).
Manifest: ui/queryflux-studio/lib/studio-engines/manifest.ts
- Import the new module and append it to
STUDIO_ENGINE_MODULES(order affectsENGINE_AFFINITY_OPTIONSand registry iteration; catalog card order is separate — see below).
Derived registry: ui/queryflux-studio/lib/engine-registry.ts
ENGINE_REGISTRYisSTUDIO_ENGINE_MODULES.map((m) => m.descriptor). Do not duplicate descriptor arrays here.findEngineDescriptor,implementedEngines,isClusterOnboardingSelectable,validateClusterConfig— unchanged behavior;validateClusterConfigstill uses generic required-field checks fromconfigFieldsunless you extend the Rust/TS contract.
2. Catalog layout (picker order and dialect-only rows)
File: ui/queryflux-studio/components/engine-catalog.ts
- Implemented backends appear as studio slots:
{ k: "studio", engineKey: "<same key as descriptor>" }insideENGINE_CATALOG_SLOTS, interleaved with staticEngineDefrows (dialects withengineKey: null). - At runtime,
expandCatalogreplaces each studio slot withstudioModuleToEngineDeffromlib/studio-engines/catalog.ts. - Static
STATIC_ENGINE_TYPE_ALIASESremains for dialects without a studio module;buildStudioTypeAliases()merges in per-module aliases and the lowercaseengineKey→displayNamemapping.
isClusterOnboardingSelectable still requires a catalog row with supported and engineKey; for studio-backed engines, supported is descriptor.implemented after expansion.
3. Cluster config forms
Router: ui/queryflux-studio/components/cluster-config/engine-cluster-config.tsx
- Resolves
getStudioEngineModule(engineKey); ifcustomFormIdis set andSTUDIO_CUSTOM_CLUSTER_FORMS[id]exists, renders that component; otherwiseGenericEngineClusterConfig(descriptorconfigFields).
Custom form registration: ui/queryflux-studio/components/cluster-config/studio-engine-forms.tsx — map customFormId → component (see Trino / StarRocks / Athena).
Reference components: trino-cluster-config.tsx, starrocks-cluster-config.tsx, athena-cluster-config.tsx, generic-engine-cluster-config.tsx, config-field-row.tsx.
4. Persisted JSON ↔ flat form (create + edit save path)
File: ui/queryflux-studio/lib/cluster-persist-form.ts
- Still shared across engines. If
cluster_configs.configgains new top-level JSON keys, update:MANAGED_CONFIG_JSON_KEYSpersistedClusterConfigToFlat,flatToPersistedConfig,mergeClusterConfigFromFlatbuildValidateShape(shape expected byvalidateClusterConfig)
validateEngineSpecificis implemented inlib/studio-engines/validate-flat.ts(per-modulevalidateFlat); this file re-exports it for call sites.
5. Clusters page (grid, dialog, validation)
File: ui/queryflux-studio/app/clusters/clusters-grid.tsx
- Uses
findEngineDescriptor,validateClusterConfig,validateEngineSpecific,buildValidateShape,skipImplementedCheckwhere needed. No per-engine branches beyondEngineClusterConfig.
File: ui/queryflux-studio/components/add-cluster-dialog.tsx
- Wires catalog → descriptor →
EngineClusterConfig→toUpsertBody→upsertClusterConfig.
6. Group strategy (engine affinity)
File: ui/queryflux-studio/lib/cluster-group-strategy.ts
ENGINE_AFFINITY_OPTIONS=buildEngineAffinityOptionsFromManifest(). To exclude an engine, setengineAffinity: falseon itsStudioEngineModule. To customize the label, useengineAffinity: { label: "…" }.
7. Display helpers
File: ui/queryflux-studio/lib/merge-clusters-display.ts
- Uses
findEngineDescriptor(p.engineKey); the descriptor must exist in the manifest.
File: ui/queryflux-studio/components/ui-helpers.tsx (EngineBadge)
- Uses
ENGINE_CATALOG; studio-expanded rows must matchdisplayNamewhere badges key off names.
File: ui/queryflux-studio/components/engine-icon.tsx
- Consumes
EngineDef(re-exported fromengine-catalog.ts; types inlib/engine-catalog-types.ts).
8. API types (usually unchanged)
File: ui/queryflux-studio/lib/api-types.ts
ClusterConfigRecord/UpsertClusterConfigstay generic unless you add typed helpers.
9. Optional: fetch registry from the proxy
A follow-up could load GET /admin/engine-registry at runtime and hydrate forms from the API. Until then, keep Rust descriptor() and StudioEngineModule.descriptor in sync manually.
Studio checklist (copy-paste)
-
lib/studio-engines/engines/<engine>.ts—StudioEngineModule(descriptoraligned with Rust,catalog, optionalvalidateFlat,customFormId,engineAffinity,extraTypeAliases) -
lib/studio-engines/manifest.ts— import + append toSTUDIO_ENGINE_MODULES -
lib/engine-registry-types.ts— extendConnectionType/AuthTypeif needed -
components/engine-catalog.ts— add{ k: "studio", engineKey: "…" }toENGINE_CATALOG_SLOTSat the desired position -
components/cluster-config/studio-engine-forms.tsx— register component ifcustomFormIdis set -
lib/cluster-persist-form.ts— only if new persistedconfigJSON keys (managed keys + flat ↔ JSON +buildValidateShape) - Smoke-test: Add cluster → save → edit → save; Engines page icons; group engine affinity if applicable
Part C — Frontend protocol (e.g. “more Postgres wire”)
Goal: clients speak a wire protocol to QueryFlux, not a new backend.
Where the code lives
- PostgreSQL wire:
crates/queryflux-frontend/src/postgres_wire/ - MySQL wire:
crates/queryflux-frontend/src/mysql_wire/ - Trino HTTP:
crates/queryflux-frontend/src/trino_http/ - Flight SQL:
crates/queryflux-frontend/src/flight_sql/
Typical steps
FrontendProtocol— Already defined inqueryflux_core::query::FrontendProtocol; add a variant only for a new ingress protocol.default_dialect()— Set the sqlglot source dialect for translation (see query-translation.md).- Listener — Bind a port, parse the protocol, build
SessionContextandInboundQuery, then call shareddispatch_query(or the same helpers Trino HTTP uses). - Routing — Optionally extend protocol-based routing in config / persisted routing so this frontend maps to the right default group.
- Tests — Protocol-level tests or e2e clients as appropriate.
Studio does not implement wire protocols; it only talks to the Admin API for config and metrics.
Checklist summary
Backend engine
-
EngineConfig+EngineType+engine_key()+parse_engine_key()+From<&EngineConfig> for EngineType+ dialect mapping (engine_registry.rs+query.rs) -
EngineAdapterTrait+descriptor() -
registered_engines.rs:all_descriptors()+build_adapter()arm callingtry_from_cluster_configon the adapter - Adapter module:
try_from_cluster_config(or async equivalent) readingClusterConfig -
UpsertClusterConfig::from_core/to_corestay aligned viaengine_key/parse_engine_key(no extra string match in persistence) - Translation / compatibility if dialect is new
Studio (UI)
-
lib/studio-engines/engines/<engine>.ts—StudioEngineModule(descriptor + catalog + options) -
lib/studio-engines/manifest.ts— register module inSTUDIO_ENGINE_MODULES -
lib/engine-registry-types.ts—ConnectionType/AuthTypeif Rust added variants -
components/engine-catalog.ts—{ k: "studio", engineKey }slot inENGINE_CATALOG_SLOTS -
components/cluster-config/studio-engine-forms.tsx— only if usingcustomFormId -
lib/cluster-persist-form.ts— only if newconfigJSON keys need round-tripping - Verify add-cluster + edit-cluster, Engines page icons /
findEngineByType, and engine affinity if used
New client protocol
-
FrontendProtocol+ dialect + listener module + dispatch integration + routing docs
Related reading
- system-map.md — End-to-end flow
- query-translation.md — Dialects and sqlglot
- routing-and-clusters.md — Routers and groups
- observability.md — Admin API (including engine registry JSON)
Rust files referenced above
crates/queryflux/src/registered_engines.rs—all_descriptors,build_adaptercrates/queryflux-core/src/engine_registry.rs—engine_key,parse_engine_key,EngineRegistry,From<&EngineConfig> for EngineTypecrates/queryflux-persistence/src/cluster_config.rs—to_core/from_corevsengine_key+ JSONB