SereneDB

Real-time analytics: search and GROUP BY rows seconds after a write

SereneDB is a real-time search analytics database. A committed row is visible to the next query on its table, and the inverted index publishes it at the next refresh, every second by default. One SQL statement then searches the fresh rows and aggregates them, over the PostgreSQL wire protocol.

curl https://install.serenedb.com | sh
one insert · two clockscommittedsearchable
An animation in three scenes. First, six log rows are inserted into an events table: SELECT count(*) on the table returns 6 at once, while a search for timeout on the inverted index returns 0 until the next refresh (refresh_interval, 1000 ms by default) and 3 after it; grouped by service, checkout has 2 timeouts with the worst at 1210 ms and search 1 at 480 ms. Second, an OTLP/HTTP export of three log records answers 200 in one transaction; otel_logs is a search table, so even count(*) returns 0 before the refresh and 3 after it, and the timeouts group into auth 1 and checkout 1. Third, SearchBench on 1 billion OpenTelemetry log rows (11 engines in 17 configurations, hot runs): SereneDB 26.09.1 loaded the rows in 5 minutes 10 seconds and answered all 92 queries within the 60-second cap, the only configuration to do so, with a median of 31.0 ms; the 16 queries for the latest matches in a time window had a median of 10.5 ms and were the fastest of the 17 on 12, and the 14 search-filtered GROUP BY queries had a median of 135.5 ms.
Data freshness

How fresh is the data? Visible on commit, searchable on the next refresh

A nightly load decides how stale every dashboard is, and a shorter schedule only narrows the gap. In SereneDB a committed row is visible to the next query on its table. The inverted index publishes it at the next refresh, every 1000 ms by default, so a search predicate sees it a moment later. Insert a timeout and watch when each path can see it.

freshness simulator · events
next refresh
any value in ms; 0 turns automatic refresh off
table · on commit
SELECT count(*) FROM events
6
sees every committed row
index · on refresh
… FROM events_idx WHERE message @@ 'timeout' GROUP BY service
checkout2 timeouts1210 ms
search1 timeout480 ms
every timeout is searchable
nightly etl · warehouse
SELECT count(*) FROM wh.events
6
as of last night’s run
The latest rows of events, newest first, with when each one is searchable
tsservicelevellatency_msmessagestatus
12:01:40authINFO25token refreshedsearchable
12:01:15searchWARN480upstream timeout, served stalesearchable
12:01:02checkoutERROR1210payment gateway timeout after retrysearchable
12:00:48searchINFO18query servedsearchable
12:00:31checkoutERROR950payment gateway timeoutsearchable
12:00:05checkoutINFO42order placedsearchable

Search tables (storage = 'search'), such as the OpenTelemetry tables, show a new row to every query, count(*) included, at the refresh.


Architecture

From write to dashboard: commit, refresh, query

Events arrive over the PostgreSQL wire protocol, as OTLP/HTTP exports, or through a subscription to a Postgres publication. They land in a table with an inverted index beside it, and every question, search or aggregate, is a plain SELECT. Hover a stage, or a lane inside the index.

01 · ingest
COPY … FROM STDIN
text or binary
01 · ingest
INSERT
small writes
01 · ingest
OTLP/HTTP
otel listener
01 · ingest
CREATE SUBSCRIPTION
a Postgres publication
02 · table
Visible on commit
events snapshot isolation
03 · inverted index
Searchable on refresh
message logs_dicttext
service, levelexact
id, tsrange
INCLUDE latency_msstored
refresh_interval = 1000 ms by default
04 · search
@@ 'timeout'
BM25 · time ranges
05 · aggregate
GROUP BY
time_bucket · p95
06 · window
OVER (…)
rolling averages
07 · dashboards
Grafana panel
PostgreSQL data source
07 · schedule
CREATE JOB
rollups · retention
> ingest · copy

Stream batches with COPY … FROM STDIN

Every PostgreSQL driver speaks the COPY protocol. In the text and binary formats SereneDB parses the rows on several threads while the client is still sending them, so a stream consumer writes its batches this way. The two rows below answer COPY 2.

sql · text format, tab-separated
COPY events FROM STDIN;
7 2026-11-03 12:02:10 checkout ERROR 880 payment gateway timeout
8 2026-11-03 12:02:30 auth ERROR 640 session store timeout
\.
> ingest · insert

INSERT for small writes

Six rows stand in for a feed in the walkthrough below. A committed INSERT is visible to the next query on the table.

sql
INSERT INTO events VALUES
(1, '2026-11-03 12:00:05', 'checkout', 'INFO', 42, 'order placed'),
(2, '2026-11-03 12:00:31', 'checkout', 'ERROR', 950, 'payment gateway timeout'),
(3, '2026-11-03 12:00:48', 'search', 'INFO', 18, 'query served'),
(4, '2026-11-03 12:01:02', 'checkout', 'ERROR', 1210, 'payment gateway timeout after retry'),
(5, '2026-11-03 12:01:15', 'search', 'WARN', 480, 'upstream timeout, served stale'),
(6, '2026-11-03 12:01:40', 'auth', 'INFO', 25, 'token refreshed');
> ingest · otel

An OTLP/HTTP listener in the server

An otel listener serves the OTLP/HTTP export endpoints for logs, traces and metrics itself. They land in seven otel_* search tables created at startup, and each export is written in one transaction. Exporters authenticate with HTTP Basic as a SereneDB role that has INSERT on those tables.

shell
serened ./data --listen \
'postgres://0.0.0.0:7890,http://0.0.0.0:4318?api=otel'
> ingest · postgres

A subscription to a Postgres publication

The published tables must exist in SereneDB under the same names. CREATE SUBSCRIPTION copies their rows, then applies every committed INSERT, UPDATE and DELETE from the publisher, and the inverted index publishes them at its next refresh. The publisher runs PostgreSQL 15 or later with wal_level = logical.

sql
CREATE SUBSCRIPTION shop_sub
CONNECTION 'host=pg.internal port=5432 dbname=shop user=replicator password=…'
PUBLICATION shop_pub;
> stage 02 · commit

The first clock: commit

A committed transaction is visible to every transaction that starts after it: SereneDB guarantees snapshot isolation. A query on the table sees every committed row, so a count or a GROUP BY over events needs no refresh.

sql
SELECT count(*) FROM events;
-- 6
> stage 03 · refresh

The second clock: refresh

The inverted index is eventually consistent: a background refresh publishes new rows every refresh_interval, 1000 ms by default. Lower it for fresher search, raise it to cut overhead on write-heavy tables, or run VACUUM (REFRESH_TABLE) to publish now. INCLUDE stores latency_ms in the index, so a search returns it without a lookup in the table.

sql · hover a line
CREATE INDEX events_idx ON events
USING inverted (
message logs_dict, -- text
service, level, -- exact values
id, ts -- exact values and ranges
)
INCLUDE (latency_ms) -- returned from the index
WITH (refresh_interval = 1000);
ALTER INDEX events_idx SET (refresh_interval = 250);
VACUUM (REFRESH_TABLE) events; -- publish pending rows now
> stage 04 · search

A search predicate on fresh rows

A full-text @@ predicate reads the index, which holds the rows the last refresh published: two timeouts in checkout, the worst at 1210 ms, and one in search. When order matters, BM25 ranks the matches: for 'payment timeout', rows 2, 4 and 5 score 0.783, 0.615 and 0.277.

sql
SELECT service,
count(*) AS timeouts,
max(latency_ms) AS worst_ms
FROM events_idx
WHERE message @@ 'timeout'
GROUP BY service
ORDER BY timeouts DESC, service;
SELECT id, service, BM25(events_idx.tableoid) AS score
FROM events_idx
WHERE message @@ 'payment timeout'
ORDER BY score DESC
LIMIT 3;
> stage 05 · aggregate

GROUP BY in the same statement

time_bucket, count(*) FILTER and quantile_cont run over the fresh rows: in the first minute checkout logged two events, one error and a p95 of 904.6 ms. Rollups, grouping sets and columnar scans over large tables are on the OLAP database page.

sql
SELECT time_bucket(INTERVAL '1 minute', ts) AS minute,
service,
count(*) AS events,
count(*) FILTER (WHERE level = 'ERROR') AS errors,
quantile_cont(latency_ms, 0.95) AS p95_ms
FROM events
GROUP BY ALL
ORDER BY minute, service;
> stage 06 · window

Window functions over the same rows

A three-row moving average per service, with a window function: checkout climbs 42, 496, 734 ms. Add a WHERE on ts to keep it to the range a chart shows.

sql
SELECT ts, service, latency_ms,
avg(latency_ms) OVER (
PARTITION BY service ORDER BY ts
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS rolling_ms
FROM events
ORDER BY service, ts;
> stage 07 · dashboards

Grafana over raw SQL

Grafana connects through its built-in PostgreSQL data source. Panels are raw SQL, so one panel mixes a full-text filter and a time bucket. Grafana's $__timeFilter(ts) macro expands to a BETWEEN like this one: 1 timeout at 12:00, 2 at 12:01.

sql
SELECT time_bucket(INTERVAL '1 minute', ts) AS time,
count(*) AS timeouts
FROM events_idx
WHERE message @@ 'timeout'
AND ts BETWEEN '2026-11-03T12:00:00Z' AND '2026-11-03T12:05:00Z'
GROUP BY 1
ORDER BY 1;
> stage 07 · schedule

Rollups and retention on a schedule

CREATE JOB runs SQL inside the server on a calendar grid in UTC. One job rebuilds a per-minute error table in one transaction, for panels that read precomputed numbers; another deletes rows past their retention.

sql
CREATE TABLE errors_per_minute (
minute TIMESTAMP,
service VARCHAR,
errors BIGINT
);
CREATE JOB errors_rollup EVERY 1 MINUTE AS $$
BEGIN;
DELETE FROM errors_per_minute;
INSERT INTO errors_per_minute
SELECT time_bucket(INTERVAL '1 minute', ts), service, count(*)
FROM events
WHERE level = 'ERROR'
GROUP BY ALL;
COMMIT;
$$;
CREATE JOB events_retention EVERY 1 HOUR AS
DELETE FROM events WHERE ts < now() - INTERVAL '30 days';
EXECUTE JOB errors_rollup;
SELECT * FROM errors_per_minute ORDER BY minute, service;
A diagram of seven numbered stages. Rows arrive by COPY … FROM STDIN, INSERT, OTLP/HTTP exports to the otel listener, or a subscription to a Postgres publication. They land in the events table, visible to the next query on commit, and the inverted index events_idx publishes them at the next refresh: message through the logs_dict dictionary as text, service and level as exact values, id and ts as exact values with ranges, and latency_ms stored with INCLUDE. Queries search with @@ and BM25, aggregate with GROUP BY, time_bucket and quantile_cont, and compute rolling averages with window functions, for a Grafana panel over the PostgreSQL data source or for CREATE JOB rollups and retention.

Watch the refresh backlog

sdb_metrics · num_buffered_docs
commit
Visible to the next transaction

Regular tables, snapshot isolation.

refresh_interval
Searchable on refresh

1000 ms by default. Lower it for fresher search; 0 turns automatic refresh off.

vacuum (refresh_table)
Publish now

After a bulk load, instead of waiting for the interval.

VACUUM (REFRESH_TABLE) events;
num_buffered_docs
Rows not yet searchable

If it stays high, or avg_commit_time_ms climbs, maintenance is behind the write rate. See sdb_metrics.

SELECT c.relname, m.value AS buffered FROM sdb_metrics m JOIN pg_class c ON c.oid = m.relation_id WHERE m.metric = 'num_buffered_docs';

Build it

Tutorial: a search-filtered GROUP BY over fresh rows

From an empty database to a GROUP BY over rows the index published a moment ago: one dictionary, one table, one index and two jobs. Every statement and result below ran on SereneDB 26.11.2 on 2026-11-03.

step 01 / 05 · sql
CREATE TEXT SEARCH DICTIONARY logs_dict AS
    split_text(case := 'lower')
    WITH (frequency, position, norm);

CREATE TABLE events (
    id BIGINT PRIMARY KEY,
    ts TIMESTAMP,
    service VARCHAR,
    level VARCHAR,
    latency_ms INTEGER,
    message VARCHAR
);

CREATE INDEX events_idx ON events
    USING inverted (id, ts, service, level, message logs_dict)
    INCLUDE (latency_ms);
step 02 / 05 · sql
INSERT INTO events VALUES
(1, '2026-11-03 12:00:05', 'checkout', 'INFO', 42, 'order placed'),
(2, '2026-11-03 12:00:31', 'checkout', 'ERROR', 950, 'payment gateway timeout'),
(3, '2026-11-03 12:00:48', 'search', 'INFO', 18, 'query served'),
(4, '2026-11-03 12:01:02', 'checkout', 'ERROR', 1210, 'payment gateway timeout after retry'),
(5, '2026-11-03 12:01:15', 'search', 'WARN', 480, 'upstream timeout, served stale'),
(6, '2026-11-03 12:01:40', 'auth', 'INFO', 25, 'token refreshed');
INSERT 0 6. A live producer streams its batches with COPY … FROM STDIN.
step 03 / 05 · sql
SELECT time_bucket(INTERVAL '1 minute', ts) AS minute,
       service,
       count(*) AS events,
       count(*) FILTER (WHERE level = 'ERROR') AS errors,
       quantile_cont(latency_ms, 0.95) AS p95_ms
FROM events
GROUP BY ALL
ORDER BY minute, service;
5 rows. In the first minute checkout logged 2 events, 1 error and a p95 of 904.6 ms.
step 04 / 05 · sql
VACUUM (REFRESH_TABLE) events;  -- or wait for refresh_interval

SELECT time_bucket(INTERVAL '1 minute', ts) AS minute,
       service,
       count(*) AS timeouts,
       max(latency_ms) AS worst_ms
FROM events_idx
WHERE message @@ 'timeout'
GROUP BY ALL
ORDER BY minute, service;
Three timeouts: checkout 950 ms at 12:00 and 1210 ms at 12:01, search 480 ms at 12:01. Without the VACUUM, the same rows came back once the automatic refresh had run.
step 05 / 05 · sql
CREATE TABLE errors_per_minute (
    minute TIMESTAMP,
    service VARCHAR,
    errors BIGINT
);

CREATE JOB errors_rollup EVERY 1 MINUTE AS $$
    BEGIN;
    DELETE FROM errors_per_minute;
    INSERT INTO errors_per_minute
    SELECT time_bucket(INTERVAL '1 minute', ts), service, count(*)
    FROM events
    WHERE level = 'ERROR'
    GROUP BY ALL;
    COMMIT;
$$;

CREATE JOB events_retention EVERY 1 HOUR AS
    DELETE FROM events WHERE ts < now() - INTERVAL '30 days';

EXECUTE JOB errors_rollup;

SELECT * FROM errors_per_minute ORDER BY minute, service;
EXECUTE JOB runs the rollup once: 12:00 checkout 1, 12:01 checkout 1. After that it runs at the start of every minute, and the retention job every hour.

Use cases

What teams build on real-time analytics

Observability on OpenTelemetry

Point an OTel SDK or a collector's otlphttp exporter at SereneDB. Logs, traces and metrics land in otel_* search tables; log bodies are searchable with BM25, and timeouts per service is one GROUP BY.

tables
otel_logsotel_tracesotel_metrics_*
Full-text search →
example · search the logs
SELECT service_name, count(*) AS timeouts
FROM otel_logs
WHERE body @@ 'timeout'
GROUP BY service_name
ORDER BY service_name;
result
service_nametimeouts
auth1
checkout1
Three records exported over OTLP/HTTP a moment earlier; the search table showed them at its next refresh.

Live dashboards in Grafana

Grafana connects through its built-in PostgreSQL data source. Panels are raw SQL, so one panel mixes a full-text filter, a time bucket and a percentile over rows written seconds ago, and Grafana's $__timeFilter(ts) becomes the BETWEEN.

built with
PostgreSQL data sourceraw SQLtime_bucket
OLAP database →
example · panel query
SELECT time_bucket(INTERVAL '1 minute', ts) AS time,
       service,
       count(*) AS events,
       count(*) FILTER (WHERE level = 'ERROR') AS errors,
       quantile_cont(latency_ms, 0.95) AS p95_ms
FROM events
WHERE ts BETWEEN '2026-11-03T12:00:00Z' AND '2026-11-03T12:05:00Z'
GROUP BY ALL
ORDER BY 1, 2;
result
timeserviceeventserrorsp95_ms
12:00checkout21904.6
12:00search1018
12:01auth1025
12:01checkout111210
12:01search10480
The query builder is not supported; write the SQL. A time-series panel needs a column named time.

Postgres rows, searchable seconds later

Subscribe to a publication on your PostgreSQL database. SereneDB copies the published tables, applies each committed change, and its index makes the new rows searchable at the next refresh, while the application keeps writing to Postgres.

built with
CREATE SUBSCRIPTIONpublicationinverted index
Zero-ETL search →
example · orders at risk
SELECT region,
       count(*) AS timeouts,
       sum(amount) AS at_risk
FROM orders_idx
WHERE note @@ 'timeout'
GROUP BY region
ORDER BY region;
result
regiontimeoutsat_risk
eu2215.50
us160.00
Orders 1 and 2 came with the initial copy; orders 3 and 4 were inserted on the publisher afterwards and applied by the subscription.

FAQ

Frequently asked questions

Go to docs →

A real-time analytics database makes new data queryable seconds after it arrives and answers analytical queries over it fast enough for dashboards and alerts to show the present, not the last batch load. In SereneDB a committed row is visible to the next query on its table, and one SQL statement can search the fresh rows and aggregate them.

On a regular table an aggregate sees the row as soon as the transaction commits. A search predicate, and every query on a search table such as the OpenTelemetry tables, sees it after the next refresh: refresh_interval is 1000 ms by default and is set per index, or per search table. VACUUM (REFRESH_TABLE) publishes pending rows at once.

Judge candidates on four things you can test with your own data: how soon a write is queryable, how events get in, whether one query can both search and aggregate, and how fast that query is at your volume. On SearchBench (1 billion OpenTelemetry log rows, 11 engines in 17 configurations, hot runs), SereneDB 26.09.1 was the only configuration to answer all 92 queries within the 60 s cap, with a median of 31.0 ms, and the fastest of the 17 on 12 of the 16 queries that fetch the latest matches in a time window. SereneDB vs ClickHouse and SereneDB vs Tiger Data make that comparison engine by engine.

A warehouse loaded by batch ETL answers as of its last load; a real-time analytics database answers over rows committed seconds ago. SereneDB also queries Parquet, CSV and JSON files in object storage with the same SQL, so history in a data lake and today's events share one dialect: see search over a data lake.

Error rate per service per minute while an incident is still open, p95 latency on the panel an on-call engineer is watching, or the orders that hit a payment timeout in the last five minutes. Each is one SELECT over the events table, with a full-text filter where the signal sits in message text.

Not for freshness. Aggregates run at query time, so a dashboard read covers every row committed before it, and every row the index has published when it filters with a search predicate. When a panel should read precomputed numbers instead, a CREATE JOB keeps a rollup table current on a schedule, every minute for example, and another job deletes rows past their retention.

Over the PostgreSQL wire protocol with INSERT, or with COPY … FROM STDIN, which is how a Kafka or other stream consumer writes its batches; as OTLP/HTTP exports to the built-in otel listener; as change data from PostgreSQL through a subscription to a Postgres publication; or as Elasticsearch-style _bulk requests to the es listener. Each path lands in a table that SQL queries directly.

SereneDB speaks the PostgreSQL wire protocol, so psql, PostgreSQL drivers and Grafana's PostgreSQL data source connect to it. It can also subscribe to a publication on your Postgres database and make those rows searchable seconds after they commit there. For BM25 search over data you keep in Postgres, see Postgres full-text search.

> get started

Get started with SereneDB

Write each event once, to one table, and search and aggregate it a moment later in the same SQL, over the PostgreSQL wire protocol.