Self-service BI fails in one specific way: someone points a fifteen-card dashboard with a thirty-second auto-refresh at a production database and the whole company notices. Qrly answers that with a per-connection query governor, per-user daily budgets, four opt-in cache tiers and, at the far end, full CDC replication of raw source tables. All of it is off until you turn it on.
Three separate mechanisms with three separate jobs. You can run any one of them without the others.
One connection, one semaphore, one queue. Everything else waits its turn or is told no.
A dashboard with fifteen cards is fifteen queries. Put it on a wall display with a one-minute auto-refresh, share it with a department, and the arithmetic runs away from you quickly. Nobody involved did anything wrong — they built a dashboard, which is the entire point of the product — but the source database now sees a sustained burst of analytical queries it was never sized for.
The classic outcome is not a slow dashboard. It is connection-pool exhaustion on the application that shares the database, at which point the analytics tool has taken down the system it was supposed to be reporting on. Retrying makes it worse. This is the single most common way self-service BI loses the trust of the team that runs the database.
Every Qrly connection has a governor: a semaphore sized to that connection's max concurrent queries, fronted by a FIFO wait queue capped at max queue size. Queries that cannot acquire a permit wait in order. Queries that arrive when the queue is already full get an immediate queue-full error, and a query that has waited longer than queue wait timeout is failed rather than left hanging.
The refusal is the feature. A tool that queues without bound eventually delivers every one of those queries to the database, just later and all at once. Qrly bounds the queue and returns an error, so load shedding happens in the BI tier where it is cheap, not in the database where it is not.
Governor lifecycle events are persisted on a background executor, so recording them never sits on the query's own critical path. The admin page shows live per-connection statistics — active, queued, max concurrent, max queue and last wait — with auto-refresh, alongside a recent-events table.
That turns "the dashboard felt slow this morning" into a question with an answer. You can see whether queries were waiting, how long, on which connection, and whether the queue was ever full — before you start blaming the database.
Concurrency limits shape the peak. Budgets shape the total. Each user has an effective daily budget resolved as per-user override → organisation default → 1000, checked and reserved atomically before every query and made race-safe by an upsert on the (user, date) pair — so two browser tabs cannot both spend the last query.
Bytes returned and AI tokens are recorded asynchronously afterwards. Administrators get a budget page, a per-user editor, a reset action and a usage report; users see their own remaining budget on their profile, which stops the whole thing being a mystery when a query is refused.
Set on the connection, applied to everything that runs through it — visual questions, native SQL, dashboards, subscriptions and scheduled runs alike.
They are not a hierarchy you have to adopt in order. Each solves a different problem, and each is enabled independently, per connection or per question.
Same cache key, same encrypted payload, different lifetime and different blast radius.
The first tier lives in the process. It stores encrypted bytes rather than object graphs, and it is budgeted in bytes twice over — once per connection and once JVM-wide — with registered evictors that evict the entries nearest to expiry first. That is a deliberately unfashionable policy: it protects the entries with the most useful life left, rather than the ones that happen to have been touched recently.
Because it is in-process, it is the fastest tier and it is gone on restart. It is the right tier for a dashboard that ten people open in the same ten minutes.
T1 can also hold results in a columnar layout using typed primitive arrays per column — LONG, DOUBLE, STRING and BOOLEAN — rather than boxed row objects. The point is CPU-cache-friendly scans: a column of longs read sequentially out of a primitive array behaves very differently from the same data spread across a million small objects.
Hit counters and byte estimates are tracked per entry, so the columnar store is measurable rather than a matter of faith.
Pointed at the shared application database, T2 gives cross-instance hits at roughly ten milliseconds: one node runs the query, every node in the cluster benefits. The registry is warmed from T2 at startup, so a rolling restart does not throw away everything the cluster had learned.
This is the sensible default for a clustered deployment where all instances serve the same tenants.
The alternative T2 backend is a SQLite file in WAL mode, one per connection, laid out on disk as {basePath}/{tenantSlug}/{orgSlug}/{connectionId}/cache.db. It is node-local rather than cluster-shared, and it buys something the shared database cannot: physical separation. One tenant's cached results are a different file, in a different directory, on a path you can point at in an audit.
Both backends carry the columnar layout in a compact self-describing binary wire format, so what T1 held in memory is what T2 writes to disk.
The same three modes apply to the shared cache and to the materialized table cache.
Cached results are encrypted at rest in the JVM tier and in the persistent tier. The cache never stores plaintext rows — not in memory, not in the shared application database, not in the SQLite file.
This is the difference between a cache you can enable on a connection carrying customer data and one you cannot. A cache that quietly duplicates production rows into a second, less-protected store is a compliance problem wearing a performance costume.
The cache key is a SHA-256 hash of the organisation, the connection, the SQL with whitespace collapsed and keywords normalised, and the parameters sorted by name. Two logically identical queries that differ only in formatting or parameter ordering hit the same entry.
Because the organisation and connection are part of the key, a result can never be served across a tenant boundary — the key space is partitioned by construction, not by a filter someone has to remember to apply.
Callers can pass NORMAL, NO_CACHE or NO_STORE to bypass or refuse to populate the cache for a specific run — useful when someone is reconciling a figure and wants to be certain they are looking at the source. Concurrent requests for the same key are de-duplicated so only one of them runs the query.
Per-connection consecutive-failure counters feed a ten-minute circuit breaker, and dispatch runs on virtual threads. A cache backend that starts failing stops being asked, rather than adding latency to every request while it fails.
This is the rule the rest of the design hangs off: cache failures degrade to a miss, never to an error. If the cache cannot answer, cannot decrypt, or cannot be reached, the query runs against the source and the user sees a result.
It means enabling caching cannot make your reporting less available than it was before you enabled it. The worst case is the performance you already had.
The materialized table cache re-runs a saved question and writes the result as an actual table in a database you own — so an external BI tool, a scheduled export or a colleague with a SQL client can read it directly.
The ROW format writes one wide table, which is what most people expect and what most external tools want. The COLUMNAR format writes one physical table per source column plus a coalescing view over them, which suits wide results where consumers select a handful of columns.
Columnar rejects UPSERT and APPEND_SNAPSHOT rather than pretending to support them — merging by key across one-table-per-column is not a thing you want happening quietly on your behalf.
Results carry logical type categories, which are mapped to the target dialect's physical types on write: TEXT becomes TEXT, STRING or VARCHAR(MAX) depending on the engine; NUMBER becomes DECIMAL(p,s) when precision and scale are known; JSON is preserved natively on Postgres and MySQL and falls back to text elsewhere; ARRAY, RANGE and COMPOSITE values are flattened.
Schema drift is detected through a DDL hash, which drives auto-recreate — so a question that gains a column does not silently start writing into a table shaped for the old one.
With serve-reads-from-cache enabled, a question run is rewritten to SELECT * FROM target.table instead of re-executing against the source. When the materialized copy is missing or broken, the run falls back and says so in an X-Cache response header — MATERIALIZED-MISS or MATERIALIZED-ERROR.
You can therefore tell, per request, whether you were served from the materialized table or from the source. Caches that cannot tell you that are how people end up debugging the wrong system.
Refresh follows the same passive, stale-by-TTL and active-by-cron modes as the shared cache. A per-config lock guarantees only one run is in flight, and every run is recorded — status, last run, duration, rows, error — alongside audit rows.
Batch size, statement timeout, max rows and whether drop-recreate is permitted are all configurable, and there is both a health probe and a preview-DDL endpoint so you can see exactly what Qrly intends to create before it creates it.
T3 caches an answer. T4 replicates the raw tables the answers are built from — with optional column projection and a source filter — into an external customer connection or a Qrly-managed cache target.
Full reloads never truncate the table your users are reading. Data lands in a staging table and the two are swapped: a rename on Postgres, RENAME TABLE on MySQL, sp_rename on SQL Server. Readers see the old copy right up until they see the new one.
BigQuery has no equivalent rename, so it falls back to drop, create and insert inside a single transaction — stated plainly rather than papered over, because that fallback has a visible consequence.
Replicating five related tables in five independent passes gives you five tables that were each individually correct at a different moment — which is how you end up with orphan rows in a replica nobody trusts. Qrly coordinates multi-table syncs as an FK group under a single source snapshot: repeatable read with an exported snapshot on Postgres and Redshift, InnoDB consistent snapshot on MySQL, best-effort elsewhere.
The apply order comes from a topological sort of the foreign keys, with an optional referential check afterwards. Parents land before children, by construction.
Reads are dialect-aware and streamed: a server-side cursor on Postgres, row-by-row on MySQL, tuned fetch size elsewhere, with configurable fetch and batch sizes. Writes use native COPY on Postgres and Redshift with automatic fallback to batched INSERT, and per-dialect upsert — ON CONFLICT, ON DUPLICATE KEY, SQLite's native form, or a portable MERGE.
Each batch commits, so a retry after a failure is idempotent rather than a restart from zero. Tasks run one virtual thread each behind a fair semaphore, with per-config locks and queued and in-flight counters.
When a sync config exists, is enabled, serves reads and its last successful run is inside the TTL, visual question runs are transparently rewritten to the synced copy. Four conditions, all of which must hold; otherwise the query goes to the source as usual.
Native SQL is never rewritten. If you wrote the SQL yourself, Qrly runs the SQL you wrote against the connection you named — silently retargeting hand-written SQL to a replica would be an unpleasant surprise in a reconciliation.
Debezium is loaded reflectively, so the application starts normally when it is not on the classpath. All three engines are snapshot-then-tail, with credentials injected after decryption and offsets and schema history persisted.
Every sync run is recorded with rows read and written, bytes, the watermark it moved from and to, status, duration and error, plus a DDL hash for drift detection. Replication that cannot be audited after the fact is replication you will eventually stop believing — the run history is what lets you answer "is the replica behind, and by how much" without opening the target database yourself.
A BI tool that starts caching your customer data the moment you connect a database has made a decision that was not its to make.
Every tier described on this page is opt-in, enabled per connection or per question. Out of the box, a Qrly query goes to the source, live, every time — through the governor, against the connection's limits, recorded in the query audit log.
Caching, replication, alerting and housekeeping all run on scheduled jobs. Every one of them is gated until the platform is initialised — a half-configured install does not start writing to your databases on a timer.
No. All four cache tiers are opt-in, enabled per connection or per question. With nothing switched on, every query runs live against the source, through the governor and the audit log.
Question-level materialization is a separate, simpler opt-in for un-parameterized base reads only — any caller-supplied parameter or interactive filter value forces a live run.
Yes. The in-JVM tier and the persistent tier both store AES-256-GCM encrypted bytes, so the cache never holds plaintext rows.
The persistent tier is either the shared application database, for cross-instance hits, or a per-connection SQLite WAL file laid out as {basePath}/{tenantSlug}/{orgSlug}/{connectionId}/cache.db, which gives physical separation per tenant and organisation on the node's own disk.
The query governor. Each connection gets a semaphore sized to its max-concurrent-queries setting, with a FIFO wait queue capped at max queue size. Overflow returns a queue-full error rather than piling more work onto the database, and a query that waits longer than the queue wait timeout is rejected.
Lifecycle events are persisted on a background executor, and the admin page shows live per-connection stats — active, queued, max concurrent, max queue, last wait — with a recent-events table.
A cache failure always degrades to a miss, never to an error — the query simply runs against the source.
On top of that there is per-key de-duplication, per-connection consecutive-failure counters and a ten-minute circuit breaker, so a sick cache backend stops being consulted rather than slowing every request down.
Table sync can run in CDC mode using embedded Debezium, loaded reflectively so the application still starts when it is not on the classpath. Postgres uses logical replication with a per-source slot and publication, MySQL and MariaDB use the binlog with a derived unique server id, and SQL Server uses database-level CDC with per-table capture instances.
All three are snapshot-then-tail, with credentials injected after decryption, offsets and schema history persisted, a parser tolerant of tombstones, heartbeats, schema changes and truncates, and an applier that caches prepared DML per config and operation.
Yes, with the materialized table cache. It re-runs a saved question and writes the result as a real table in a customer-owned target database, which any external tool can point at directly.
It supports truncate-insert, drop-recreate, upsert and append-snapshot write strategies, row and columnar storage formats, and schema-drift detection through a DDL hash that drives auto-recreate.
A governor your DBA can read, four cache tiers you switch on deliberately, and replication that writes into a database you own.