Time Series

ArcadeDB includes a native Time Series engine designed for high-throughput ingestion and fast analytical queries over timestamped data. Unlike bolt-on solutions, the Time Series model is integrated directly into the multi-model core — the same database that stores graphs, documents, and key/value pairs can store and query billions of time-stamped samples with specialized columnar compression, SIMD-vectorized aggregation, and automatic lifecycle management.

Key capabilities:

  • Columnar storage with Gorilla (float), Delta-of-Delta (timestamp), Simple-8b (integer), and Dictionary (tag) compression — 0.4 to 1.4 bytes per sample

  • Shard-per-core parallelism with lock-free writes

  • Block-level aggregation statistics for zero-decompression fast-path queries

  • InfluxDB Line Protocol ingestion for compatibility with Telegraf, Grafana Agent, and hundreds of collection agents

  • Prometheus remote_write / remote_read protocol for drop-in Prometheus backend usage

  • PromQL query language — native parser and evaluator with HTTP-compatible API endpoints

  • SQL analytical functions — ts.timeBucket, ts.rate, ts.percentile, ts.interpolate, window functions, and more

  • Continuous aggregates with watermark-based incremental refresh

  • Retention policies and downsampling tiers for automatic data lifecycle

  • Grafana integration via DataFrame-compatible endpoints (works with the Infinity datasource plugin)

  • Studio TimeSeries Explorer with query, schema inspection, ingestion docs, and PromQL tabs

Creating a TimeSeries Type

Use CREATE TIMESERIES TYPE to define a new time series type. Every type requires a TIMESTAMP column, zero or more TAGS (low-cardinality indexed dimensions), and one or more FIELDS (high-cardinality measurement values).

CREATE TIMESERIES TYPE SensorReading
  TIMESTAMP ts PRECISION MILLISECOND
  TAGS (sensor_id STRING, location STRING)
  FIELDS (
    temperature DOUBLE,
    humidity DOUBLE,
    pressure DOUBLE
  )
  SHARDS 8
  RETENTION 90 DAYS
  COMPACTION_INTERVAL 1 HOURS

Minimal syntax (defaults for everything optional):

CREATE TIMESERIES TYPE SensorReading
  TIMESTAMP ts
  TAGS (sensor_id STRING)
  FIELDS (temperature DOUBLE)
Option Default Description

TIMESTAMP <name>

(required)

Name of the timestamp column

PRECISION

MILLISECOND

Timestamp unit. Only MILLISECOND is accepted: timestamps are stored, ingested, retained and queried in milliseconds (see the note below)

TAGS (…​)

(none)

Comma-separated name TYPE pairs for low-cardinality dimensions

FIELDS (…​)

(required)

Comma-separated name TYPE pairs for measurement values

CODEC <codec>

derived from the type

Per-column compression codec, written after a tag or field type (temperature DOUBLE CODEC GORILLA_XOR) or after the timestamp. By default the timestamp uses DELTA_OF_DELTA, tags DICTIONARY, DOUBLE/FLOAT fields GORILLA_XOR, integer, boolean and date/time fields SIMPLE8B, and other fields DICTIONARY. (Since v26.10.2) a codec the sealed store cannot encode or read back unchanged is refused at creation: NONE, any timestamp codec but DELTA_OF_DELTA, DELTA_OF_DELTA on another column, and a numeric codec other than the column type’s default (SIMPLE8B on a DOUBLE, GORILLA_XOR on a LONG or a text column). DICTIONARY is accepted on any tag or field. Earlier versions accepted such a codec, after which compaction of the type failed or the values read back changed. Types already created keep loading

SHARDS

arcadedb.asyncWorkerThreads (available cores minus 1)

Number of shards for parallel writes

RETENTION

(none)

Automatic deletion of data older than the specified duration (e.g., 90 DAYS, 1 HOURS)

COMPACTION_INTERVAL

(none)

Cuts sealed blocks at boundaries of this interval. A block that lies inside one query bucket is answered from its statistics without decompressing it, so set it to the bucket width of your most frequent aggregation (see Choosing the layout). Without it blocks are cut by size only and a block spanning several buckets is decompressed

IF NOT EXISTS

(none)

Skip creation if the type already exists, instead of failing. The statement returns one row either way, carrying a created flag: see the note on IF NOT EXISTS results

Since v26.10.2 PRECISION accepts only MILLISECOND, and SECOND, MICROSECOND and NANOSECOND are refused. Earlier versions accepted all four and honoured none of them: every ingestion endpoint (line protocol, Prometheus remote write, gRPC) converts its timestamps to milliseconds, and retention, downsampling and PromQL all count in milliseconds. On a PRECISION SECOND type fed with seconds, the first retention pass deleted every sample; on a NANOSECOND or MICROSECOND type fed with values in that unit, nothing ever expired and PromQL answered nothing.

A type declared with another precision by an earlier version keeps loading and keeps its data, but its retention and downsampling are suspended, because its stored timestamps may be in either unit. A warning names each such type when the maintenance scheduler first meets it, and SELECT FROM schema:types reports maintenanceSuspended: true for it. If the type’s timestamps are milliseconds (always the case for data written through the ingestion endpoints), confirm it to resume them:

ALTER TIMESERIES TYPE SensorReading PRECISION MILLISECOND

The statement changes only the declaration: no stored timestamp is rewritten, so MILLISECOND is the only unit it accepts. A type whose timestamps really are in another unit must have them converted to milliseconds first, by copying the samples into a new type, before retention is resumed. A JSONL export of such a type restores with its declaration and its suspension unchanged, into an embedded database.

A type stores its columns in the order they are declared, and that order matters: a sample is a positional row, so the column order is what an export records and what a restore reads back.

The TIMESTAMP, TAGS and FIELDS clauses may therefore be written in any order, and TAGS and FIELDS may each appear more than once. The declaration below creates a type whose columns are f1, ts, t1, f2, in that order:

CREATE TIMESERIES TYPE Interleaved
  FIELDS (f1 DOUBLE)
  TIMESTAMP ts
  TAGS (t1 STRING)
  FIELDS (f2 DOUBLE)

The Java schema builder accepts the same orders and keeps them, so a recipe rendered as SQL - which is what the remote schema builder does - creates the same type as the same recipe run against an embedded database. A JSONL export therefore restores into a remote database exactly as it does into an embedded one, whatever order its columns are in.

Before 26.10.1 the statement had exactly one slot for each clause, in the order timestamp, tags, fields. A builder recipe that interleaved them was regrouped when rendered as SQL, so the remote builder could create a type whose columns were in a different order than the embedded one - and a JSONL restore of such a type into a remote database was refused rather than silently loading the values into the wrong columns. Every statement written against the old grammar parses and means exactly what it did.

Altering a TimeSeries Type

Add downsampling policies to automatically reduce resolution of old data:

ALTER TIMESERIES TYPE SensorReading
  ADD DOWNSAMPLING POLICY
    AFTER 7 DAYS GRANULARITY 1 MINUTES
    AFTER 30 DAYS GRANULARITY 1 HOURS

Remove all downsampling policies:

ALTER TIMESERIES TYPE SensorReading DROP DOWNSAMPLING POLICY

Confirm that the timestamps of a type declared with another precision by an earlier version are milliseconds, which resumes its retention and downsampling (see the precision note above):

ALTER TIMESERIES TYPE SensorReading PRECISION MILLISECOND
A TIMESERIES type stores only its declared TIMESTAMP, TAGS, and FIELDS columns. It cannot be given a SUPERTYPE, and it cannot be used as a SUPERTYPE for another type: any inherited property would be silently dropped by every write, so both directions are rejected. For the same reason, CREATE PROPERTY on a TIMESERIES type is also rejected unless the name matches an already-declared column.

Column Constraints

(Since v26.11.1) ALTER PROPERTY declares MANDATORY, NOTNULL, MIN, MAX and REGEXP on a column of a TIMESERIES type as on any property, and every write path enforces them: SQL INSERT, the InfluxDB line protocol, gRPC, Prometheus remote write and the JSONL import. The value checked is the one the column stores, read the way SELECT returns it:

ALTER PROPERTY SensorReading.temperature MIN -50
ALTER PROPERTY SensorReading.temperature MAX 150
ALTER PROPERTY SensorReading.sensor_id REGEXP 'sensor-[A-Z]'
ALTER PROPERTY SensorReading.sensor_id MANDATORY true
  • MIN / MAX bound the value of a numeric column, the number of characters of a STRING column, and the instant of a DATE or DATETIME column, compared in the column’s own precision (a DATETIME_NANOS column to the nanosecond);

  • REGEXP is matched against the text of the value as SELECT returns it, a date as the date and not as its epoch count;

  • MANDATORY and NOTNULL both require a value. A sample has no way to keep a column set to null apart from one left out, so a MANDATORY column refuses a null too, and a NaN in a FLOAT or DOUBLE column, which is how such a column stores no value;

  • a STRING column, tag or field, stores a sample written without it as the empty text and returns it so, and that empty text is what MIN and REGEXP check: on a column declared MIN 2 or REGEXP '[a-z]+' a sample that leaves the column out is refused, whether the constraint was declared before or after the sample. A numeric or date column keeps no value as no value, and no MIN, MAX or REGEXP applies to it.

A SQL INSERT that violates a constraint fails with a ValidationException, as for a document. Unlike a document type, a multi-row INSERT (CONTENT […​], multi-row VALUES, FROM SELECT) refused at one row keeps the rows appended before it, because each sample is appended in its own shard transaction (see Appends and Transactions). On the line protocol, gRPC and Prometheus remote write the sample that violates it is dropped alone and named in unreadableValues (<measurement>.<column> (tag|field|timestamp): <reason>, the reason naming the constraint and never the value); the JSONL import applies its row-error policy. As for a document type, a constraint is refused when a sample already stored violates it (Cannot set MAX 10: the sample at timestamp …​ violates it). A sample cannot be updated, but it can be deleted by its time and its tags (see Deleting Samples), so the remedy is to delete the sample the message names and declare the constraint again:

DELETE FROM SensorReading WHERE ts = 1708430400000 AND sensor_id = 'sensor-A'
ALTER PROPERTY SensorReading.temperature MAX 150

Or, to keep the old type as it is, a new TIMESERIES type that declares the constraint, filled with the conforming samples:

CREATE TIMESERIES TYPE SensorReading2 TIMESTAMP ts TAGS (sensor_id STRING) FIELDS (temperature DOUBLE, humidity DOUBLE)
ALTER PROPERTY SensorReading2.temperature MAX 150
INSERT INTO SensorReading2 FROM SELECT ts, sensor_id, temperature, humidity FROM SensorReading WHERE temperature <= 150

A stored STRING column reads an absent value back as the empty text, so MANDATORY/NOTNULL cannot be checked against the samples already there, only against the new ones.

DEFAULT and HIDDEN are refused on a TIMESERIES column: no write path fills in an absent column and every read returns all of them, so either would be stored without any effect. READONLY is accepted, a sample being immutable anyway.

Before v26.11.1 these constraints were accepted and ignored by every write path, and DEFAULT and HIDDEN were stored without effect. A database written by an earlier version still opens with such a DEFAULT or HIDDEN (the schema load logs a warning naming the remedy, ALTER PROPERTY …​ DEFAULT null or HIDDEN false), and a MIN, MAX, REGEXP, MANDATORY or NOTNULL it carries is enforced from now on, on new samples only.

Compacting a TimeSeries Type

New samples land in each shard’s mutable bucket, and the maintenance scheduler seals them into compressed blocks every 60 seconds. To seal them now, for example right after a bulk load or before measuring a query on the settled layout, run:

COMPACT TIMESERIES TYPE SensorReading

It runs the same compaction the scheduler runs, requires the updateSchema permission like COMPACT INDEX, and under HA runs on the leader. The result carries mutableSamplesBefore and mutableSamples, the samples still in the mutable buckets when it finished: rows appended while it ran stay there until the next pass.

The scheduler (and COMPACT TIMESERIES TYPE) also merges the small blocks a slow feed leaves behind into full-size ones, so the number of blocks follows the data volume and not the time. A read that is scanning the old blocks at the very moment they are merged is refused with a retryable error (HTTP 503) instead of returning a partial answer: run it again. A merge rewrites the sealed file, so it needs free disk space of about the size of that file.

Since v26.10.2. Compaction, merge, retention and downsampling all rewrite a shard’s sealed file, so on one shard they run one at a time. A COMPACT TIMESERIES TYPE issued while a retention or downsampling pass is running waits for it (and the other way round), instead of the two overwriting each other’s work. Before, a retention pass that overlapped a compaction could be silently undone the next time the database was opened. Likewise, a rewrite that failed half-way no longer leaves a file behind that the next retention pass could resurrect deleted blocks from.

Reads do not need a compaction to be fast. A query for one tag, such as the newest reading of one host, skips every mutable page that holds no row of that tag without reading its rows, whether or not the tail has been sealed yet.

Dropping a TimeSeries Type

DROP TIMESERIES TYPE SensorReading
DROP TIMESERIES TYPE SensorReading IF EXISTS

Deleting Samples

A sample is never updated once written: UPDATE on a TIMESERIES type is refused (Cannot UPDATE the samples of the TIMESERIES type 'SensorReading': a sample cannot be updated once written …​). To change a sample, delete it and insert it again. Samples leave a type in four ways:

  • DELETE FROM removes the samples in a time range, optionally of some series only;

  • TRUNCATE TYPE removes all of them and keeps the type;

  • the RETENTION of the type removes the samples older than it;

  • DROP TIMESERIES TYPE removes all of them, with the type.

-- every sample before a date
DELETE FROM SensorReading WHERE ts < '2024-01-01 00:00:00'

-- one series, in a time range
DELETE FROM SensorReading WHERE sensor_id = 'sensor-A' AND ts >= '2024-03-01 00:00:00' AND ts < '2024-03-02 00:00:00'

-- one sample
DELETE FROM SensorReading WHERE ts = 1708430400000 AND sensor_id = 'sensor-A'

-- every sample, keeping the type
TRUNCATE TYPE SensorReading

DELETE answers {count: n, transactional: false}, the number of samples it removed and the reminder that a ROLLBACK will not put them back, and TRUNCATE TYPE the same two properties beside its usual result. The WHERE names the samples exactly, so it is a time range on the timestamp column (<, ⇐, >, >=, =, BETWEEN) and equalities on TAG columns, joined by AND. A predicate on a FIELD column, an OR, a LIMIT, a BATCH and RETURN BEFORE are refused with an error and delete nothing, rather than delete more than the statement says. EXPLAIN shows the range and the tags the statement takes. Deleting what is not there deletes nothing and rewrites nothing.

A sealed block that lies wholly inside the range is dropped without being read, so deleting a stretch of old data costs about what a retention pass costs. A block the range only crosses is rebuilt from the samples that stay, and so are the mutable pages of the shard; the statistics that answer count, sum, min and max without reading a block are recomputed with it.

Every statement that removes at least one sealed sample rewrites the sealed file of each shard it changes, as retention does, so its cost is about the size of that file whatever the range: many small deletes cost more than one statement that names the same samples. Under HA the leader ships the whole sealed file of each shard it changed, as it does for retention. A range that misses the mutable pages is decided from their header, with no page read; any other delete reads the mutable pages of the shard into memory once, as a compaction does, so it weighs what a compaction of the same pages weighs.

A statement that fails part way says so: the shards before the one that failed are already deleted, and running the statement again deletes the rest. Under HA the leader remembers, in memory, which shards it rewrote without publishing, and ships them with the next delete. If the leader restarts or loses the leadership between the failed statement and its rerun, the followers can keep samples the old leader deleted: POST /api/v1/cluster/verify/{database} compares the sealed files of the nodes and reports the difference, and a resync of the node that differs repairs it.

A delete is not part of the transaction it runs in, like the append of an INSERT (see Appends and Transactions). The samples are gone, durably, when the statement returns, and a ROLLBACK does not bring them back. The statement needs the deleteRecord permission on the type.

Readers are held off a shard while its files are rewritten, as they are during a retention pass. A long read that was scanning blocks the delete removed fails with a retryable error (HTTP 503) instead of returning an answer that mixes the old blocks and the new: run it again. Under HA the delete runs on the leader and ships the rewritten pages and sealed blocks of every shard it changed in one unit, as a compaction does; while another maintenance pass holds the replication session it is retried for a few seconds and then refused with a retryable error that says nothing was deleted. A statement that failed half way can be run again (a crash between the two layers of a shard, the sealed blocks and the mutable pages, leaves the same state, and the count that the interrupted statement would have returned is lost); under HA the shards it got through are published before the failure is reported, so the followers hold what the leader holds. The mutable pages are compacted in place, row by row, whatever their number, so a delete holds no more memory than one row. A DELETE with a tag filter decodes every sealed block that overlaps its range, one block at a time, and the shard stays held, as for a retention pass, for as long as that takes. Under HA the sealed file of each shard a delete changed is shipped to the followers whole, as a retention pass ships it, so the cost of a small delete on a shard with a very large sealed file is the size of that file.

A delete (DELETE FROM and TRUNCATE TYPE) marks the time range it removed samples from on every continuous aggregate over the type, and the aggregates are refreshed when the statement ends (when the transaction commits, if one is open), so the buckets they hold stop reflecting the samples that were deleted. (Since v26.11.1, issue #10428.) Before, the buckets behind the watermark kept the deleted samples for good. The RETENTION of the type does not: an aggregate outlives the raw samples it summarized. With no transaction open the refresh runs before the statement returns, so the time of a DELETE FROM includes the recomputation of the buckets it reached; inside a transaction it runs when the transaction ends, whether it commits or rolls back: the samples stay deleted either way, because they are not part of it, so the refresh they are owed is not conditional on the commit either. (Since v26.11.1, issue #10687: before, a ROLLBACK dropped the refresh and the aggregate kept the deleted samples until something else refreshed it.) A refresh that fails is logged and does not fail the statement: the range stays recorded, and the next refresh (the next INSERT triggers one, or REFRESH CONTINUOUS AGGREGATE) recomputes it.

A DELETE or UPDATE whose source is a subquery writes to the records that subquery returns, or to the record a projected @rid names (DELETE FROM (SELECT @rid FROM Person WHERE …​)); any other row, such as a sample or a projection (DELETE FROM (SELECT name FROM Person)), is refused, since there is no record to write to.

Before v26.11.1 DELETE FROM a TIMESERIES type reported the matching samples as deleted and kept them, UPDATE reported count: 0, and TRUNCATE TYPE reported success: none of them removed or changed a sample. From v26.11.1 UPDATE is refused, and DELETE FROM (by time range and tags) and TRUNCATE TYPE remove the samples.

Ingesting Data

There are four ways to ingest data into a TimeSeries type, listed from fastest to most convenient.

The fastest remote ingestion path. ArcadeDB exposes an InfluxDB Line Protocol-compatible HTTP endpoint that skips SQL parsing entirely.

POST /api/v1/ts/{database}/write?precision=ns
Content-Type: text/plain

SensorReading,sensor_id=sensor-A,location=building-1 temperature=22.5,humidity=65.0,pressure=1013.25 1708430400000000000
SensorReading,sensor_id=sensor-B,location=building-2 temperature=19.1,humidity=70.0 1708430400000000000

Line Protocol format: <measurement>[,<tag>=<value>…​] <field>=<value>[,…​] [<timestamp>]

A malformed line is not ingested; the rest of the payload is. When any line was malformed the endpoint answers 400 with a partial-write body: written and dropped counts (a malformed line counts as one dropped sample) and malformedLines, the 1-based numbers of the rejected lines in the payload (the first 100). A field with no value (temp, temp= or a line cut short after a field key, such as weather temp=21.5,hum) makes the whole line malformed. So does a , separator with nothing after it, in the tags or in the fields (weather temp=21.5, or weather,city=rome, temp=1).

Since v26.10.1. Before, a malformed line was only logged and the request answered 204, and a field missing its value at the end of a line was stored as 0.0 instead of rejecting the line. A line cut right after a , separator was still stored without the entries after the comma until v26.10.2.

(Since v26.10.2) A tag with an empty value (cpu,host= value=1) makes the line malformed too, as InfluxDB rejects it with "missing tag value"; to write a sample without a tag, leave the tag out. Earlier versions stored it as host="". The gRPC write RPCs refuse an empty-string tag value with INVALID_ARGUMENT (an unset value still means the tag is absent), and the Java remote clients refuse it before sending.

(Since v26.10.2) A sample carrying a tag or field key its type does not declare in that role (a misspelled tag such as weather,citty=rome temp=21.5, or a field sent as a tag) is dropped, because storing it would file the point under a different series than the one sent, with the declared tag left null. The other samples of the request are stored, and the 400 partial-write body names the key in undeclaredKeys as <measurement>.<key> (tag|field) (the first 100; dropped counts every sample), with a message naming the database setting below. The OpenAPI TimeSeriesWriteError schema carries the new property, and the gRPC TimeSeriesWriteSummary reports the same list as undeclared_keys (field 8) on the unary and streaming write RPCs. A sample that simply omits a declared key is still accepted. Earlier versions stored the point without the undeclared key and answered 204.

The database setting arcadedb.timeSeriesUndeclaredKeys decides this for every time-series write: line protocol, gRPC, Prometheus remote write and SQL INSERT. reject (the default) drops and reports the sample as above (a failed statement for INSERT); ignore stores the sample with the undeclared keys discarded, which is what a schema-less producer such as Telegraf, sending extra tags the type was never meant to hold, needs:

ALTER DATABASE `arcadedb.timeSeriesUndeclaredKeys` 'ignore'

A value its column cannot store drops only the sample that carries it, which the 400 partial-write body names in unreadableValues as <measurement>.<column> (tag|field): <reason> (the reason names the column and its type, never the value; dropped is the count of samples, the list holds one entry per distinct reason and the first 100). The gRPC TimeSeriesWriteSummary carries the same list as unreadable_values. (Since v26.10.2) the line protocol and gRPC read a value as INSERT does, instead of failing the whole request with a server error:

  • a DATE or DATETIME column takes a number as a count in the column’s own unit, digits in quotes as the same count (so "20230101" is the count 20230101, not the date, as for a tag filter), and any other quoted date or datetime as the instant it names (seen="2023-11-14 22:15:23"). A number is therefore not read as INSERT reads it (epoch milliseconds whatever the precision): see the note under SQL INSERT about the epoch counts these surfaces exchange;

  • a numeric column takes a number its type can hold and numeric text; a number out of range (300 in a BYTE column, a finite 1e300 in a FLOAT) is refused rather than truncated, while NaN and the infinities stay values of a floating point column;

  • a BOOLEAN column takes a boolean and the text true or false in any case; anything else is refused, where before any text was stored as false;

  • a STRING column takes a string of at most 256 UTF-8 bytes.

Precision parameter: ns (nanoseconds, default), us (microseconds), ms (milliseconds), s (seconds).

Example with curl:

curl -X POST "http://localhost:2480/api/v1/ts/mydb/write?precision=ns" \
  -u root:password \
  -H "Content-Type: text/plain" \
  --data-binary 'SensorReading,sensor_id=sensor-A temperature=22.5 1708430400000000000
SensorReading,sensor_id=sensor-A temperature=22.6 1708430401000000000'

Example with Python:

import requests

lines = [
    "SensorReading,sensor_id=sensor-A temperature=22.5 1708430400000000000",
    "SensorReading,sensor_id=sensor-A temperature=22.6 1708430401000000000",
]

requests.post(
    "http://localhost:2480/api/v1/ts/mydb/write?precision=ns",
    auth=("root", "password"),
    headers={"Content-Type": "text/plain"},
    data="\n".join(lines),
)

If the type does not exist and auto-creation is enabled (arcadedb.tsAutoCreateType=true), the schema is inferred from the first line: measurement name becomes the type, tags become TAG columns, fields become FIELD columns with inferred types.

Prometheus Remote Write

ArcadeDB acts as a drop-in Prometheus remote storage backend. Configure Prometheus to write to ArcadeDB:

# prometheus.yml
remote_write:
  - url: "http://localhost:2480/api/v1/ts/mydb/prom/write"
    basic_auth:
      username: root
      password: password

The endpoint accepts the standard Prometheus remote_write Protobuf payload (snappy-compressed). Each time series is mapped to an ArcadeDB TimeSeries type named after the name label. Types are auto-created if they do not exist.

(Since v26.10.2) A label with an empty value is the same as an absent label, as in Prometheus: it is stored as absent and {label=""} selects it. A series carrying a label that the existing type does not declare as a tag is dropped and the request answers 400 naming the label (the other series of the request are stored); Prometheus does not retry a 400. Set arcadedb.timeSeriesUndeclaredKeys=ignore to discard the extra label instead.

(Since v26.10.2) The endpoint implements remote write 1.0 (prometheus.WriteRequest) and answers as the remote-write specification asks of such a receiver:

  • a Content-Type whose proto= parameter names another message (application/x-protobuf;proto=io.prometheus.write.v2.Request) is answered 415, so a remote write 2.0 sender falls back to 1.0;

  • a body that decodes to no remote write 1.0 field at all, typically a 2.0 message sent without the proto= parameter, is answered 400. It used to be answered 204, so the sender discarded the batch as delivered;

  • a request carrying only metric metadata, or an empty WriteRequest, is still a no-op answered 204;

  • a malformed protobuf body is answered 400 instead of 500.

(Since v26.10.1) if your collector or proxy sends an X-Request-Id header, the request body is taken into account when deciding whether a request is a repeat of a previous one.

Before, on prom/write and prom/read only, two requests carrying different samples under the same X-Request-Id were mistaken for a retry: the second was answered with the first one’s 204 and its samples were never stored. This affected collectors that stamp one id per connection, per scrape loop or per retry batch rather than per distinct payload. Prometheus' own remote-write client does not send the header and was never affected.

Genuine retries still work as before: re-sending the identical body under the same id is applied only once.

SQL INSERT

Standard ArcadeDB SQL syntax works for TimeSeries types:

-- Single row
INSERT INTO SensorReading
  SET ts = date('2026-02-20 10:00:00', 'yyyy-MM-dd HH:mm:ss'),
      sensor_id = 'sensor-A',
      location = 'building-1',
      temperature = 22.5,
      humidity = 65.0

-- Batch insert
INSERT INTO SensorReading
  (ts, sensor_id, location, temperature, humidity)
  VALUES
    (date('2026-02-20 10:00:00', 'yyyy-MM-dd HH:mm:ss'), 'sensor-A', 'building-1', 22.5, 65.0),
    (date('2026-02-20 10:00:01', 'yyyy-MM-dd HH:mm:ss'), 'sensor-A', 'building-1', 22.6, 64.8),
    (date('2026-02-20 10:00:02', 'yyyy-MM-dd HH:mm:ss'), 'sensor-B', 'building-2', 19.1, 70.0)

-- CONTENT syntax
INSERT INTO SensorReading
  CONTENT { "ts": 1771581600000, "sensor_id": "sensor-A", "temperature": 22.5 }

-- One sample per selected row
INSERT INTO SensorReading
  FROM SELECT ts, sensor_id, temperature FROM RawReading
(Since v26.11.1) INSERT …​ FROM SELECT appends one sample per selected row, with the same conversion and the same column constraints as INSERT …​ SET. Before, it failed with Cannot retrieve a bucket for type …​ because there are no buckets associated (Transaction not begun outside a transaction).
(Since v26.10.2) INSERT fails if it sets a property that is not a declared tag or field of the type (for example a misspelled tag), because the sample would otherwise be filed under a different series (see undeclared keys). Set arcadedb.timeSeriesUndeclaredKeys=ignore on the database to discard such properties instead. The same setting governs line protocol, gRPC and Prometheus writes.
(Since v26.10.2) A DATE, DATETIME, DATETIME_SECOND, DATETIME_MICROS or DATETIME_NANOS tag or field behaves in SQL exactly like a document property of the same type. INSERT reads a value as such a property does: text and date/time objects as the instant they name, and a number as epoch milliseconds (epoch days for a DATE), whatever the column’s precision. SELECT returns the value as the date/time object the property would return (a java.time.LocalDateTime for the DATETIME family by default), so a value read from a row can be written back as it is, and a WHERE condition on the column matches the same literal INSERT accepted, on a tag (pushed down to the storage engine) and on a field alike. An instant the column’s precision cannot represent - for example a nanosecond count written to a DATETIME_NANOS column, which INSERT reads as milliseconds 53 million years from now - is refused with an error. Before, SELECT returned the raw epoch count in the column’s own unit, which neither INSERT nor a filter read the same way, and such an instant was stored as 2262-04-11T23:47:16.854775807. The engine API, line protocol, gRPC and HTTP surfaces keep exchanging these columns as epoch counts in the column’s unit. This split is intended: SQL treats the column as its document twin, the wire protocols carry the count the column stores. So digits in quotes ('1700000000000') are refused by INSERT, as by a DATETIME document property, while the line protocol and gRPC store them as that count. (Since v26.11.1) INSERT refuses a value such a column cannot take with a CommandExecutionException naming the column, like any other value it refuses, where it used to fail with a bare IllegalArgumentException.

The same literal, per column type and surface (since v26.11.1, pinned by a test that runs every cell). 1700000000000 is 2023-11-14 22:13:20 UTC in epoch milliseconds, and 1700000000 the same instant in seconds:

Literal in a column SQL INSERT Line protocol, gRPC (the wire)

1700000000000 (a number) in DATETIME

the instant 2023-11-14 22:13:20

the same: a count in milliseconds

1700000000 (a number) in DATETIME_SECOND

the count 1700000, the instant 1970-01-20 (a number is milliseconds)

the count 1700000000, the instant 2023-11-14 22:13:20 (a count in seconds)

1700000000000 (a number) in DATETIME_MICROS, DATETIME_NANOS

the instant 2023-11-14 22:13:20

the count 1700000000000, the instant 1970-01-20 (microseconds) or 1970-01-01 00:28:20 (nanoseconds)

1700000000000 (a number) in DATE

refused: no date is that many days away

stored: a count of days no date can stand for

'1700000000000', '0' (digits in quotes) in a DATETIME family column

refused: it is no date

stored as that count; a line protocol tag has no type, so a DATETIME tag can only arrive as text of digits

'19675' (digits in quotes) in DATE

the date 2023-11-14, read as days

the same

'2023-11-14 22:13:20'

the instant, in the column’s precision

the same instant

'not a date', true

refused

refused

An operator who writes one measurement from both places should write it the same way from both: an ISO date in text, or a number in the unit the surface reads (milliseconds in SQL, the column’s unit on the wire). The refusal of SQL names the rule.

The timestamp column of a TimeSeries type is stored as a number, so INSERT does not accept an ISO-8601 string for it (Cannot convert type String to LONG): write an epoch value (as in the CONTENT example, milliseconds for a type with PRECISION MILLISECOND) or build it with the date() function, which interprets the text in the server’s time zone. ISO-8601 strings are accepted in WHERE conditions on the timestamp column.

Java Embedded API

The fastest path — bypasses all protocol and SQL overhead:

TimeSeriesEngine engine = database.getSchema()
    .getTimeSeriesType("SensorReading").getEngine();

long[] timestamps = { 1708430400000000000L, 1708430401000000000L };
String[] sensorIds = { "sensor-A", "sensor-A" };
double[] temperatures = { 22.5, 22.6 };

database.transaction(() -> {
  engine.appendSamples(timestamps,
      new Object[] { sensorIds, temperatures });
});

TimeSeriesEngine.appendSamples() and appendBatch() are the engine’s own API: they store the values as given, and are for data the application has already checked. database.async().appendSamples() is the checked one. (Since v26.11.1) It converts every value as INSERT does and holds it to the constraints of the type’s columns (MANDATORY, NOTNULL, MIN, MAX, REGEXP) before it returns, on the calling thread: a value no column can store, or a sample a constraint refuses, raises a ValidationException that names the column and the reason, and the whole batch is refused, with nothing appended. Before, such a sample was stored (and then could not be removed, or declared against, when the constraint was declared again), or the batch was lost inside the async worker with waitCompletion() returning normally.

The form that takes a TimeSeriesBatch (database.async().appendSamples(type, batch)) is checked the same way. (Since v26.11.1) A TAG or STRING text of more than 256 UTF-8 bytes, which a row cannot hold, is refused on the calling thread with a ValidationException that names the column and the limit, and the whole batch is refused; before, the call returned and the whole batch, the conforming rows included, was lost inside the async worker. The typed setters of a batch (setLong(), setDouble(), …​) store the primitive they are given. TimeSeriesBatch.setValue(), for a value you hold as an Object, converts it as INSERT does and raises an IllegalArgumentException naming the column for a value the column cannot store: a number past the range of an INTEGER, SHORT or BYTE column, text that is not true or false for a BOOLEAN column, text that is not a number for a numeric column, a text over the limit. Before, a Long past the INTEGER range was stored modulo 2^32, any text for a BOOLEAN column was stored as false, and numeric text failed with a bare ClassCastException.

Appends and Transactions

A TimeSeries append is not part of the transaction that contains it. This is true of every ingestion method above - SQL INSERT, the REST write endpoint, the line protocol, Prometheus remote write and the Java embedded API alike:

  • the samples are committed as they are appended, so they are durable and visible to every other connection as soon as the append returns, before you commit anything;

  • rolling the enclosing transaction back does not remove them.

Use a session or a transaction around an append for what it does do - it holds that session’s lock and principal and keeps its idle timer from reaping it while you ingest - and not for atomicity with the samples.

The reason is the shard’s own append lock. Every append writes the shard’s header page, which holds the sample count and the min/max timestamps, so two appends to one shard always want the same page version; serializing them behind a lock the append itself releases at its own commit is what keeps them from conflicting. An append that wrote through the caller’s transaction could not be serialized that way, because the commit would belong to the caller and a shard cannot hold that lock until an arbitrary user transaction ends without blocking every other writer of that shard. Both would then stage the same page and one would lose its whole transaction to a concurrent-modification error that is not the shard’s to retry.

Ingestion Method Comparison

Method Throughput Overhead Best For

Java Embedded API

~0.5-1 us/sample

None (direct)

Embedded applications

InfluxDB Line Protocol

~1-5 us/sample

Text parsing

Remote ingestion, Telegraf

Prometheus Remote Write

~2-10 us/sample

Protobuf + Snappy

Prometheus ecosystems

SQL INSERT

~50-100 us/sample

SQL parsing + planning

Ad-hoc inserts, small batches

Querying Time Series Data

SQL Queries

Time series types support standard SQL SELECT with WHERE, GROUP BY, and ORDER BY. Time range conditions (BETWEEN, >, >=, <, <=, =) on the timestamp column are pushed down to the storage engine for efficient range scans. A time range on one branch of an OR never narrows the other branch, and two equalities on the same tag in one AND match nothing when the values differ, exactly as on a document type.

-- Basic range query
SELECT ts, sensor_id, temperature, humidity
FROM SensorReading
WHERE ts BETWEEN '2026-02-19' AND '2026-02-20'
  AND sensor_id = 'sensor-A'
ORDER BY ts

-- Aggregation with time bucketing
SELECT ts.timeBucket('1h', ts) AS hour,
       sensor_id,
       avg(temperature) AS avg_temp,
       max(temperature) AS max_temp,
       min(temperature) AS min_temp,
       count(*) AS sample_count
FROM SensorReading
WHERE ts BETWEEN '2026-02-19' AND '2026-02-20'
GROUP BY hour, sensor_id
ORDER BY hour

An aggregation that groups by the time bucket, by up to four TAG columns, or by both is answered by the storage engine in one pass (AGGREGATE FROM TIMESERIES in EXPLAIN), the shape of a per-host hourly report. Every projected tag must also be in the GROUP BY, and the aggregates are avg, min, max, sum and count(*) over numeric fields. A query outside that shape (a field in the GROUP BY, a projected tag that is not grouped by, count(field), DISTINCT) gives the same answer through the generic row-at-a-time plan, which is slower.

An aggregation with no GROUP BY at all is answered by the storage engine as well, as one row over the queried range (AGGREGATE FROM TIMESERIES …​ ungrouped in EXPLAIN). Every sealed block lying wholly inside the range is answered from the sample count, minimum, maximum and sum it records, so only the blocks that cross a bound of the range are decompressed, and the cost of the query follows the number of blocks rather than the number of samples. Over a range holding no sample it answers one row, 0 for count(*) and NULL for every other aggregate, as SQL does.

SELECT count(*) AS samples, avg(temperature) AS avg_temp, max(temperature) AS max_temp
FROM SensorReading
WHERE ts >= '2026-02-19' AND ts < '2026-02-20'

Range conditions on a numeric FIELD are pushed down too: >, >=, <, <=, = and BETWEEN against a literal or a parameter, combined with AND (FIELDS in EXPLAIN). A sealed block whose recorded minimum and maximum rule a condition out is skipped without being read, a block whose whole range satisfies every condition is answered as if there were no condition (from its statistics, in an aggregation), and only the blocks in between are decompressed, with the condition tested on the decoded columns before any row is built. That applies to the aggregations above, so SELECT count(*) …​ WHERE ts …​ AND temperature > 30 is answered by the storage engine, and to a plain row query, which then builds rows only for the samples that pass.

When the storage engine answers the whole WHERE of a row query - a time range, tag equalities and the field conditions above, combined with AND - the plan has no FILTER ITEMS step, and a projection made only of column names (aliases allowed) builds each row once, in the fetch (PROJECTED in EXPLAIN), instead of building a row of every column and copying it, as for SELECT sensor_id, ts, temperature FROM SensorReading WHERE ts >= ? AND ts < ? AND temperature > 30.

The comparisons pushed down are those SQL makes as plain numbers: a DOUBLE field against any number, and a LONG, INTEGER, SHORT or BYTE field against an integer. A missing measurement never satisfies one, as NULL > 30 does not. Any other condition - on a FLOAT field, <>, an OR, a comparison between two columns, a function of the field, a decimal against an integer field - gives the same answer through the generic filter.

When upgrading to 26.10.2: an aggregation answered by the storage engine returns the same values and Java types as the generic plan. min and max return a value of the column’s declared type (a date/time object for a DATE or DATETIME column), sum over a LONG, INTEGER, SHORT or BYTE field returns an integer that widens to a BigDecimal past the long range (sum over a FLOAT field a float), and avg is a Double, instead of the Double earlier versions returned for a query grouped by the time bucket or by tags. An integral column is aggregated exactly, as 64-bit integers with a 128-bit running total, so a value past 2^53 - an epoch-nanosecond instant, a 64-bit id - is no longer rounded.

TimeSeries SQL Functions

ArcadeDB provides a comprehensive set of ts.* SQL functions for time series analytics.

Function Description

ts.timeBucket(interval, timestamp [, options])

Truncates a timestamp to the nearest interval boundary for GROUP BY aggregation. Intervals: '1s', '5m', '1h', '1d', etc. Buckets are counted from the Unix epoch unless options moves the grid, see Moving the bucket grid.

ts.first(value, timestamp)

Returns the value corresponding to the earliest timestamp in the group.

ts.last(value, timestamp)

Returns the value corresponding to the latest timestamp in the group.

ts.rate(value, timestamp [, counterResetDetection])

Per-second rate of change. Optional 3rd parameter (true) enables Prometheus-style counter reset detection for monotonic counters.

ts.delta(value, timestamp)

Difference between the last and first values in the group.

ts.movingAvg(value, window)

Trailing moving average: returns a list with one element per input row, in row order, where each element averages the window of window rows ending at that row (window may also be given as {window: n}). A row whose value is null keeps its slot and contributes nothing to the windows it belongs to: each window averages only its non-null values, and a window holding no value at all answers null, so the list can contain null elements. Each window’s average depends only on its own members. (Since v26.10.2.) Earlier versions dropped a null row from the list, which shifted every later average into an earlier row’s slot.

ts.interpolate(value, method [, timestamp])

Gap filling. Methods: 'zero', 'prev', 'linear', 'none'. The 'linear' method requires the timestamp parameter.

ts.correlate(a, b)

Pearson correlation coefficient between two series.

ts.percentile(value, percentile)

Approximate percentile calculation (0.0-1.0). E.g., ts.percentile(latency, 0.99) for p99.

ts.lag(value, offset, timestamp [, default])

Window function: returns the value from a previous row.

ts.lead(value, offset, timestamp [, default])

Window function: returns the value from a subsequent row.

ts.rowNumber(timestamp)

Window function: sequential 1-based row numbering.

ts.rank(value, timestamp)

Window function: rank with ties, gaps after ties.

Moving the bucket grid

By default buckets are multiples of the interval counted from the Unix epoch, which was a Thursday at 00:00 UTC: a '1w' bucket starts on Thursday and a '1d' bucket starts at 08:00 in UTC+8. The optional third parameter of ts.timeBucket moves the grid; use one of:

-- weeks start on Monday
SELECT ts.timeBucket('1w', ts, {'origin': '2024-01-01T00:00:00Z'}) AS week, sum(kwh) FROM Energy GROUP BY week

-- days start at local midnight in UTC+8
SELECT ts.timeBucket('1d', ts, {'offset': '-8h'}) AS day, sum(kwh) FROM Energy GROUP BY day
SELECT ts.timeBucket('1d', ts, {'timezone': '+08:00'}) AS day, sum(kwh) FROM Energy GROUP BY day
  • origin: any instant a bucket starts at (a bare instant as third parameter means the same).

  • offset: a signed duration added to the epoch grid.

  • timezone: local midnight (local Monday for '1w') of a zone with a fixed offset such as '+08:00'. Zones with daylight saving are refused, because their days do not all have the same length.

The same grid is used by the aggregation push-down, the HTTP/gRPC/Grafana bucketOrigin, continuous aggregates (write the option in the defining query) and downsampling tiers, which take an OFFSET clause:

ALTER TIMESERIES TYPE SensorReading ADD DOWNSAMPLING POLICY AFTER 30 DAYS GRANULARITY 1 DAYS OFFSET -8 HOURS

Blocks already downsampled are not re-bucketed if you change the offset later.

Examples:

-- Rate of change with counter reset detection
SELECT ts.timeBucket('5m', ts) AS window,
       ts.rate(request_count, ts, true) AS requests_per_sec
FROM HttpMetrics
WHERE ts > '2026-02-20T10:00:00Z'
GROUP BY window

-- Percentile calculation
SELECT ts.timeBucket('1h', ts) AS hour,
       ts.percentile(latency_ms, 0.99) AS p99,
       ts.percentile(latency_ms, 0.50) AS median
FROM ServiceMetrics
GROUP BY hour

-- Gap filling with linear interpolation
SELECT ts.timeBucket('1m', ts) AS minute,
       ts.interpolate(temperature, 'linear', ts) AS temp
FROM SensorReading
WHERE ts BETWEEN '2026-02-20T10:00:00Z' AND '2026-02-20T11:00:00Z'
GROUP BY minute

-- Correlation between two fields
SELECT ts.correlate(temperature, humidity) AS correlation
FROM SensorReading
WHERE ts BETWEEN '2026-02-19' AND '2026-02-20'

Dedicated JSON Query Endpoint

A simplified REST endpoint is available for programmatic access and Grafana integration:

POST /api/v1/ts/{database}/query
Content-Type: application/json

{
  "type": "SensorReading",
  "from": "2026-02-19T00:00:00Z",
  "to": "2026-02-20T00:00:00Z",
  "columns": ["temperature", "humidity"],
  "tags": { "sensor_id": "sensor-A" },
  "aggregation": "AVG",
  "bucketInterval": "1h"
}

For raw (non-aggregated) queries, omit the aggregation and bucketInterval fields.

The optional bucketOrigin (epoch milliseconds, also available on the Grafana endpoint, on gRPC as bucket_origin_ms and in the Java client as TimeSeriesQuery.bucketOrigin()) moves the bucket grid like the origin option of ts.timeBucket, see Moving the bucket grid.

from, to, bucketInterval and maxDataPoints must be whole numbers. A value with a fractional part is refused with 400 and a message naming the member, rather than being rounded down to something you did not ask for. A bucket width computed by division therefore has to be rounded by the client, deliberately.
(Since v26.10.2) A DATE or DATETIME tag value given as text in a tag filter (the tags object of this endpoint and of the Grafana query endpoint, the tag parameter of latest, and the gRPC TimeSeriesQuery and TimeSeriesLatest RPCs) is read in the database’s time zone, with its date formats, exactly as the INSERT that wrote the tag read it. Earlier versions read it in the JVM’s default time zone, so on a server whose JVM zone differed from the database’s the filter named another instant and selected nothing. In Java, TimeSeriesGateway.buildTagFilter() and TimeSeriesGateway.andTag() now take the Database as their first argument; code calling the old signatures must pass it.

To retrieve the most recent data point:

GET /api/v1/ts/{database}/latest?type=SensorReading&tag=sensor_id:sensor-A

Repeat tag once per tag column to narrow to a single series; every occurrence must match:

GET /api/v1/ts/{database}/latest?type=SensorReading&tag=sensor_id:sensor-A&tag=region:eu

Since 26.10.1, a tag name that is not a TAG column of the type is refused with HTTP 400, naming it and listing the tags the type actually declares. It used to be ignored, which meant a typo widened the query instead of failing it: {"tags": {"sesnor_id": "sensor-A"}} returned every row in the range, and ?tag=sesnor_id:sensor-A the newest sample of any series - neither distinguishable from a filter that legitimately matched everything.

On latest, an occurrence that is not in name:value form (?tag=sensor_id) is refused for the same reason. An empty ?tag= is still ignored, since that is what an empty form field produces.

This applies to POST /api/v1/ts/{database}/query, GET /api/v1/ts/{database}/latest, the Grafana query endpoint (reported as that panel’s error, so one bad tag does not blank the others) and the gRPC TimeSeries RPCs, which answer INVALID_ARGUMENT. The PromQL endpoint is unaffected: there an unknown label has a defined meaning under Prometheus' rules and is not a malformed request.

Missing Values

A sample can be absent, which means "no measurement here" rather than a value of zero. You write one either as NaN or as null, and both are stored the same way on a DOUBLE or FLOAT column. Aggregations treat it that way:

  • MIN, MAX, SUM and AVG skip NaN samples. Over a window that holds at least one real value they answer from the real values alone: SUM is the sum of the real samples and AVG divides it by the number of real samples, not by the number of samples. Over a window whose samples are all NaN they return NaN, meaning the window has no measurement to report.

  • count(*) counts rows, so it counts NaN samples too. In SQL, count(field) counts only the rows where the field is not null, as on any other type, and returns a Long.

  • A genuine Infinity or -Infinity sample is a measurement and is returned as such.

  • Downsampling averages the real samples of each bucket; a bucket with no real sample is downsampled to NaN.

This is the semantics of SQL’s NULL under SUM, AVG and count(*). The PromQL endpoint is the one exception: its sum, avg, sum_over_time and avg_over_time propagate NaN, because that is what Prometheus does and a PromQL query is expected to answer what Prometheus would.

Over the REST and Grafana endpoints an absent value is serialized as JSON null, since JSON has no NaN literal. A Grafana panel therefore draws a gap for such a bucket rather than a dip to zero. Over SQL it reads back as NULL, both as a projected column and as the result of an aggregate with nothing to aggregate:

SELECT value FROM Sensor                        -- an absent sample is NULL
SELECT avg(value) FROM Sensor GROUP BY bucket   -- a bucket with no real sample is NULL

A NaN that arithmetic itself produces over real samples - the sum of +Infinity and -Infinity, for instance - is an undefined total rather than an absence, and is returned as NaN.

(Since v26.10.2) A LONG, INTEGER, SHORT, BYTE, BOOLEAN, DATE or DATETIME column records absence too: a null written to it, or a column a TimeSeriesBatch row leaves unset, reads back as null, and MIN, MAX, SUM, AVG and count(field) skip it as they skip NaN. These types have no value to spare for absence, so a row of the mutable buffer carries a small null bitmap and a sealed block a null bitmap per column. The row layout is fixed when the type is created, so a TimeSeries type created by an earlier version keeps storing such a null as 0 (or false), which counts as a real measurement: recreate the type (for example EXPORT and IMPORT) to record gaps in its integer columns.

Before 26.10.1 a null measurement was stored as a real 0.0 on every column type. AVG therefore divided by it and MIN returned the zero instead of the smallest real value; only an explicitly written NaN was treated as absent. Samples already stored as zero by an earlier version stay zero - they are indistinguishable from a real measurement of zero - so this affects data written from 26.10.1 onwards.

Before 26.10.1 SUM and AVG returned NaN for the whole window as soon as one of its samples was NaN, and the block statistics a sealed block carries recorded that poisoned sum, so the answer stayed NaN even for a bucket of thousands of real samples. Since 26.10.1 every sealed block also records the count of real samples per column, which is what lets AVG be answered from the block statistics without decompressing the block. Blocks written by an earlier version are read as they are: a block whose recorded sum was poisoned is decompressed for every aggregate but COUNT, and the next compaction, downsampling or retention rewrite of the block records both statistics. The sealed store format version moved from 0 to 1 for this.

Since 26.10.1 each sealed block records an identity of its own, which is what lets a long-running read - a wide query, a PromQL range, an EXPORT DATABASE - find its blocks again if the shard is rewritten underneath it. On a high-availability follower that happens whenever the leader ships a newly compacted store: before this, such a read returned the rows it had reached and stopped there silently, with no error. Blocks written by an earlier version have no identity recorded and one is derived from the block itself, so they behave the same way; the next compaction, downsampling or retention rewrite records it.

The sealed store format version moved to 2 for this, so a store written by 26.10.1 cannot be opened by an earlier version - deliberately, since the alternative is an older version reading it as if it held nothing. In a cluster, keep every node on the same version.

Before 26.9.1 an all-NaN window returned an internal placeholder for MIN/MAX instead - 1.7976931348623157E308 from the multi-column aggregation, +Inf/-Inf from PromQL’s min_over_time/max_over_time - and those placeholders were indistinguishable from real measurements. Different query paths could also disagree with each other on the same data.

Which Columns Can Be Aggregated

SUM, AVG, MIN and MAX read the column’s values, so they need a column that is stored as a number. That means a numeric FIELD: DOUBLE, FLOAT, LONG, INTEGER, SHORT, BYTE, BOOLEAN (as 1 and 0), and the datetime types.

A column that is not stored as a number is refused, and the message names it:

{ "error": "Aggregation SUM cannot be applied to column 'host': a TAG column of type STRING is not stored as a number" }

Tags are always stored as text, whatever type they are declared with, so a tag cannot be aggregated even when it is declared LONG. The same applies to a STRING field and to the timestamp column itself.

COUNT is the exception and works on any column. The SQL push-down also behaves differently: rather than refusing the query it simply falls back to the generic aggregation path, so SELECT …​ max(host) …​ GROUP BY and count(field) …​ GROUP BY (which skips null values) still answer. Only count(*) is answered by the push-down.

Before 26.10.1 such a request was not refused, it was answered wrongly: the recent samples were read as a column of zeros, so SUM returned 0 and AVG returned 0 over data that had never been measured, and the same query failed with a server error once the samples had been compacted.

The same fix corrected BOOLEAN fields, which are stored as numbers but were being read as zero before compaction and as their real value afterwards - so a SUM over a boolean field could change on its own when compaction ran, with no new data. It now answers the number of true samples on both.

When upgrading: a BOOLEAN field on a type created before 26.1 is stored as text rather than as a number, so SUM/AVG over it is now refused rather than silently returning 0. Recreate the type to aggregate such a field. COUNT is unaffected.

Aggregating from Embedded Java

Aggregation from embedded code goes through TimeSeriesEngine.aggregateMulti, which reads any number of columns in a single pass:

TimeSeriesEngine engine = database.getSchema()
    .getTimeSeriesType("SensorReading").getEngine();

List<ColumnDefinition> columns = ((LocalTimeSeriesType) database.getSchema()
    .getType("SensorReading")).getTsColumns();

// The position of 'temperature' inside an engine row, from its position in the schema.
int temperature = TimeSeriesGateway.aggregationRowIndex(columns,
    TimeSeriesGateway.findColumnIndex("temperature", columns));

MultiColumnAggregationResult result = engine.aggregateMulti(fromTs, toTs,
    List.of(new MultiColumnAggregationRequest(temperature, AggregationType.AVG, "avg_temp")),
    3_600_000L, null);

for (long bucket : result.getBucketTimestamps())
  System.out.println(bucket + " -> " + result.getValue(bucket, 0));

A bucket interval of 0 aggregates the whole range as one bucket, answering every sealed block wholly inside it from its statistics. The overloads of aggregateMulti, aggregateGrouped and iterateQuery that take a FieldFilter read only the samples that pass range conditions on numeric fields, judged on each block’s statistics before it is read. A condition is built with FieldFilter.range, which returns null for a comparison it cannot evaluate exactly as SQL does (see the SQL Queries section above):

// 'temperature' by its index among the columns that are not the timestamp, the numbering TagFilter uses
FieldFilter hot = FieldFilter.range(1, columns.get(2), 30, false, null, false); // temperature > 30

MultiColumnAggregationResult hotSamples = engine.aggregateMulti(fromTs, toTs,
    List.of(MultiColumnAggregationRequest.count("n")), 0L, 0L, null, hot, null, 0);

MultiColumnAggregationRequest.columnIndex is the position of the value inside an engine row: 0 is the timestamp and 1 onward are the non-TIMESTAMP columns in schema order. That is not the same as the column’s position in the schema unless the TIMESTAMP column is declared first, which is why the example above converts with TimeSeriesGateway.aggregationRowIndex.

When upgrading to 26.10.1: before this release the two halves of the aggregation engine disagreed about this number, so a type whose TIMESTAMP column was not declared first answered one value before compaction and another after it. Code that passed a schema index must now convert. Nothing will warn you — the request’s shape is unchanged, so such code still compiles and simply aggregates a different column. No change is needed for a type whose TIMESTAMP column is declared first, and none at all for the SQL, HTTP, Grafana and gRPC surfaces, which resolve the column by name.

The single-column TimeSeriesEngine.aggregate and TimeSeriesSealedStore.aggregate methods, which counted columns a third way, were removed in the same release. Use aggregateMulti with a one-element list.

Query Languages

A sample of a TimeSeries type is not a vertex, an edge or a record with an identity, so the languages that read the graph model do not see it as one. What each language does over a TimeSeries type:

Language Reads the samples Notes

SQL

Yes

Every statement on this page.

PromQL

Yes

See PromQL Query Language.

MongoDB QL

Yes

find, count and insert, and deleteMany by time range and tag equality. The timestamp is a date to a MongoDB client: find returns it as one and, as MongoDB does, does not match a number against it, so filter it with a date. A deleteMany whose filter compares the timestamp with a number is refused with an error, since it would remove samples that find and count with the same filter show none of. On a type declared with a precision other than MILLISECOND before it became the only one (a database restored from an old export), the unit of the timestamps is not known, so no condition on the timestamp is accepted until ALTER TIMESERIES TYPE <name> PRECISION MILLISECOND confirms it; a deleteMany by tags works meanwhile. A deleteOne, an update and an upsert are refused too.

GraphQL

Yes, through a query field

The query field of the type answers the samples, with filters. A @relationship that names a TimeSeries type is refused with an error naming the type, instead of answering an empty list.

Cypher

No: refused with an error

A pattern that names a TimeSeries type (a node label, a relationship type, a dynamic label, in a read or a write clause) fails with an error naming the type, instead of answering that there is no data. Patterns over the graph types are unaffected. Read the samples with SQL.

Gremlin

No: refused with an error

A traversal that asks for the label or the edge label of a TimeSeries type (hasLabel, out, outE, both, …​) fails with an error naming the type, instead of answering that there is no data. A negation of the label (hasLabel(neq(…​))) is answered, since no vertex carries it. Read the samples with SQL.

PromQL Query Language

ArcadeDB includes a native PromQL parser and evaluator, providing Prometheus-compatible query capabilities without requiring an external Prometheus server.

PromQL via HTTP API

The PromQL endpoints follow the Prometheus HTTP API format and return standard {status: "success", data: {…​}} JSON responses.

Instant query:

GET /api/v1/ts/{database}/prom/api/v1/query?query=avg(cpu_usage{host="srv1"})&time=1700000000

The time parameter is Unix seconds (float). Defaults to current time if omitted.

Range query:

GET /api/v1/ts/{database}/prom/api/v1/query_range?query=rate(http_requests_total[5m])&start=1700000000&end=1700003600&step=60

All timestamps in Unix seconds. The step parameter accepts a duration string (60s, 1m) or seconds as a float.

Since v26.9.1 start, end, step and time are validated before the query runs: a value that is not a number, is infinite (NaN, Infinity) or falls outside a sensible date range is refused with 400 bad_data. A range that would produce more than 1,000,000 points is refused the same way - increase step or narrow the range.

Earlier versions accepted such values and could take a very long time to answer, or not answer at all, on an extremely wide range.

Since v26.10.2 selector windows are left-open, as in Prometheus: an instant selector evaluated at t reads the samples in (t - lookback, t] and a range selector [5m] reads (t - 5m, t]. A series whose last sample is exactly one lookback old is therefore stale, and count_over_time(m[5m]) over a feed whose scrape interval divides five minutes no longer counts the sample sitting on the lower boundary. Earlier versions included that boundary sample (the Prometheus 2.x behaviour for range selectors, never Prometheus’s behaviour for the lookback).

Since v26.10.1 instant and range queries read samples in a streaming fashion instead of loading the whole lookback window into memory first. A query over a wide time range now uses memory proportional to the number of series returned, not to the number of samples scanned to compute it.

Label discovery:

GET /api/v1/ts/{database}/prom/api/v1/labels
GET /api/v1/ts/{database}/prom/api/v1/label/{name}/values
GET /api/v1/ts/{database}/prom/api/v1/label/{name}/values?start=1700000000&end=1700003600
GET /api/v1/ts/{database}/prom/api/v1/series?match[]=cpu_usage{host=~"srv.*"}
These endpoints follow the Prometheus data model, where a label with an empty value counts as absent. A tag that holds no value for some samples is therefore not offered as an empty entry by label/{name}/values, and series does not report a series whose only difference from another is an empty label. The generic /api/v1/ts/{database}/query endpoint is unaffected and still reports exactly what the samples hold.

Since v26.10.1 label/{name}/values honours start and end (Unix timestamps in seconds, fractional values allowed), and answers only the values a sample carries inside that window. Querying the special name label likewise returns only the metrics holding a sample in the window.

Both are optional. A request sending neither covers the whole series and, for name, names every time-series type - including one holding no sample at all - exactly as before, so existing dashboards are unaffected.

Earlier versions ignored both parameters and always answered over the whole retention period, so a Grafana variable scoped to the last hour was offered every value the label had ever held.

start and end are validated the same way as on query_range: a value that is not a number, is infinite, or falls outside a sensible date range is refused with 400 bad_data, as is an end earlier than start. The same validation now applies to series, which previously answered 500 for a malformed value.

Supported PromQL Features

Category Supported

Vector selectors

metric_name, metric{label="value"}, metric{label=~"regex"}, metric{label!="value"}, metric{label!~"regex"}, and the offset modifier (metric offset 1h). (Since v26.10.2) A matcher on a tag declared with a non-string type is read in the tag’s declared type, and =, !=, =~ and !~ share one rule on /query, /series and remote read, so = and != always select complementary sets. {id="007"} selects a LONG tag holding 7, and so does {id=~"007"}: a pattern that is a plain literal selects what the equality it spells selects, which is a deliberate deviation from Prometheus, where label values are text and =~"007" never matches 7. A real pattern is matched against the label text only, so =~"0.7" does not select 7. A DATE or DATETIME matcher value is read the way INSERT reads it, in the database’s time zone.

Range selectors

metric[5m], metric[1h], metric[30s], metric[1d]; units ms, s, m, h, d, w, y

Aggregations

sum, min, max, avg, count, topk, bottomk, with by / without clauses

Range-vector functions

rate(), irate(), increase(), avg_over_time(), min_over_time(), max_over_time(), sum_over_time(), count_over_time()

Math functions

abs(), ceil(), floor(), round() (with an optional to_nearest scalar). (Since v26.10.2) they accept only an instant vector, as in Prometheus: a scalar argument such as abs(-1) is refused with expected type instant vector in call to function "abs", got scalar. Earlier versions answered the scalar (abs(-1) returned 1).

Operators

`, `-`, `*`, `/`, `%`, `^`, comparison (`==`, `!=`, `<`, `>`, `\<=`, `>=`), logical (`and`, `or`, `unless`). _(Since v26.10.2)_ The `bool` modifier after a comparison (`a > bool 1`) keeps every sample and answers `0` or `1` instead of filtering, and drops `__name__` from the result. `bool` right after an operator is always the modifier, never a metric name, so it is refused after an operator that is not a comparison: `a + bool` is a parse error (`bool modifier can only be used on comparison operators, not on ''). Two instant vectors are matched one-to-one on their full label set, name excluded. Vector matching modifiers (on, ignoring, group_left, group_right) are not supported.

No other PromQL function is implemented, among them quantile, delta(), idelta(), stddev_over_time(), sqrt(), clamp_min(), label_replace(), label_join(), absent(), histogram_quantile(), vector(), scalar() and time(). A call to one is refused with Unknown function: <name> (HTTP 400 bad_data).

Refused expressions

(Since v26.10.2) The expressions below are refused as Prometheus refuses them, with Prometheus' message. Over HTTP the answer is 400 with "errorType": "bad_data"; the SQL promql() function fails with the same message. Earlier versions answered most of them with an empty result or a NaN scalar.

  • A comparison between two scalars without bool: 1 > 0 gives comparisons between scalars must use BOOL modifier. Write 1 > bool 0.

  • and, or or unless with a scalar operand: m and 1 gives set operator "and" not allowed in binary scalar expression.

  • A range vector or a string as the operand of an operator: m[5m] + 1, -m[5m] and "a" + 1 give binary expression must contain only scalar and instant vector types (for the unary minus, unary expression only allowed on expressions of type scalar or instant vector).

  • A function or aggregation argument of the wrong type, or the wrong number of arguments: sum(m[5m]) and sum(1) give expected type instant vector in aggregation expression, got range vector (or scalar), rate(m) gives expected type range vector in call to function "rate", got instant vector, and abs() gives expected 1 argument(s) in call to "abs", got 0.

  • A range query (query_range) whose expression is a range vector or a string: m[5m] gives invalid expression type "range vector" for range query, must be Scalar or instant Vector ("string" for a string literal). An instant query still accepts both.

PromQL via SQL

The promql() SQL function calls the PromQL evaluator from within SQL queries:

-- Instant query at explicit time
RETURN promql('cpu_usage{host="srv1"}', 1700000000000)

-- Rate calculation
RETURN promql('rate(http_requests_total[5m])')

-- Scalar arithmetic
RETURN promql('2 + 3 * 4', 1000)

The function accepts 1-2 arguments: the PromQL expression (required) and an optional evaluation timestamp in milliseconds.

Prometheus Remote Read

Configure Prometheus to read from ArcadeDB for long-term storage:

# prometheus.yml
remote_read:
  - url: "http://localhost:2480/api/v1/ts/mydb/prom/read"
    basic_auth:
      username: root
      password: password

The endpoint accepts the standard Prometheus remote_read Protobuf query (snappy-compressed) and supports =, !=, =~, !~ label matchers, applied with the same rules as the PromQL /query and /series endpoints (including the typed-tag rule in the Vector selectors row of the table above). A name matcher that is not an equality (for example {name=~"cpu.*"}) selects every metric it matches among those the user can read. An invalid or unsafe regular expression is refused with HTTP 400 rather than answered with an empty result (since v26.10.2).

(Since v26.10.2) Like remote write, the endpoint reads the proto= parameter of the Content-Type header and answers 415 when it names a message other than prometheus.ReadRequest, and a malformed protobuf body is answered 400 instead of 500.

Continuous Aggregates

Continuous aggregates are pre-computed time-bucketed rollups that are automatically refreshed when new data is inserted. They dramatically speed up common dashboard queries by maintaining materialized summaries.

-- Create a continuous aggregate
CREATE CONTINUOUS AGGREGATE hourly_temps AS
  SELECT ts.timeBucket('1h', ts) AS hour,
         sensor_id,
         avg(temperature) AS avg_temp,
         max(temperature) AS max_temp,
         count(*) AS cnt
  FROM SensorReading
  GROUP BY hour, sensor_id

-- Query the aggregate like any other type
SELECT * FROM hourly_temps
WHERE hour BETWEEN '2026-02-19' AND '2026-02-20'

-- Manual refresh
REFRESH CONTINUOUS AGGREGATE hourly_temps

-- Drop
DROP CONTINUOUS AGGREGATE hourly_temps

The defining query must reference a TimeSeries source type and contain a ts.timeBucket() call with a GROUP BY clause. After creation, every insert into the source type triggers an incremental refresh using watermark tracking — only new data since the last watermark is processed.

A refresh is never part of the caller’s transaction. REFRESH CONTINUOUS AGGREGATE, ContinuousAggregate.refresh() and the first refresh of CREATE CONTINUOUS AGGREGATE run inside a transaction of their own, committed before they return, even when the caller has one open: a ROLLBACK of the caller’s transaction does not take the rows of the aggregate back, exactly as it does not take the aggregate itself or the samples it reads back. The refresh an INSERT, DELETE FROM or TRUNCATE TYPE on the source type triggers inside a transaction runs once, when that transaction ends, whether it commits or rolls back, since the samples it answers for are written either way. (Since v26.11.1, issue #10708. Before, a refresh inside a transaction joined it, and a ROLLBACK left the watermark advanced over rows that were gone: the buckets behind it were lost for good, with the aggregate reported VALID. A ROLLBACK also dropped the refresh an INSERT or a delete was owed.)

Inspect continuous aggregates via:

SELECT FROM schema:continuousAggregates

This returns name, query, source type, bucket column, bucket interval, watermark timestamp, watermarkSet, status, and metrics for each aggregate. watermarkSet tells a watermark of 0 that has actually been reached — an aggregate whose newest bucket is the epoch — apart from an aggregate that has never been refreshed, which reports 0 as well.

The watermark is the bucket boundary of the newest row the last refresh produced. Each refresh recomputes the buckets at or after it from the source and replaces the backing rows of those buckets, so the incomplete bucket that was open when the previous refresh ran is always rebuilt rather than duplicated. A recomputed row is written over the row of its own bucket, in place, rather than deleted and inserted again.

The backing type is indexed on its bucket column (a DATETIME property with a NOTUNIQUE index), so a refresh reads only the rows of the buckets it replaces and the same index serves a range query on the aggregate (SELECT FROM hourly_temps WHERE hour >= …​). (Since v26.11.1.) An aggregate created by an earlier version has no such index, and its refresh reads every row the aggregate holds: with 100,000 rows that is about 400 times the cost of the INSERT that triggers it. The database names each one in a warning when it opens, with the statements that index it, for example:

CREATE PROPERTY `hourly_temps`.`hour` DATETIME;
CREATE INDEX ON `hourly_temps` (`hour`) NOTUNIQUE

An index the bucket column leads ((hour, sensor_id)) serves as well. A defining query may carry its own WHERE clause, including a disjunction: the watermark filter is added as a separate conjunct around it, so WHERE a OR b keeps meaning ts >= watermark AND (a OR b).

The watermark only reaches forward, so a bucket behind it is not recomputed by the refresh that follows an insert. DELETE FROM and TRUNCATE TYPE on the source type make up for it: they record, on every aggregate over the type, the time range they removed samples from, and the next refresh recomputes the buckets that range reaches and only those. The buckets behind the range, and the ones between it and the watermark, are not touched. The record is part of the definition of the aggregate in the schema, so it survives a restart, and under HA it is shipped to the followers in the replication unit of the delete. A bucket is found among the rows the aggregate holds, so the grid of the aggregate (an origin, an offset or a timezone given to ts.timeBucket()) needs no special care. A delete that removes nothing records nothing. Deletes that come before the next refresh runs are merged into one range, so two deletes far apart in time recompute the buckets between them too. A sample written with a timestamp behind the watermark, and the RETENTION of the source type, do not change the buckets that are already computed.

Renaming the source type (ALTER TYPE SensorReading NAME Readings) carries the aggregate with it: its stored source type and the FROM of its defining query are rewritten, so it keeps refreshing and DROP TYPE keeps refusing to drop the source. The backing type of an aggregate is named after the aggregate and cannot be renamed. (Since v26.11.1.)

Retention Policies

Retention policies automatically delete data older than a specified duration. Set the retention period during type creation:

CREATE TIMESERIES TYPE SensorReading
  TIMESTAMP ts
  TAGS (sensor_id STRING)
  FIELDS (temperature DOUBLE)
  RETENTION 90 DAYS

A background maintenance scheduler (60-second interval) automatically enforces retention and downsampling policies on all TimeSeries types.

Downsampling Policies

Downsampling reduces the resolution of old data to save storage while preserving long-term trends. Multiple tiers can be defined to progressively reduce resolution as data ages:

ALTER TIMESERIES TYPE SensorReading
  ADD DOWNSAMPLING POLICY
    AFTER 7 DAYS GRANULARITY 1 MINUTES
    AFTER 30 DAYS GRANULARITY 1 HOURS

In this example, data older than 7 days is downsampled to 1-minute resolution, and data older than 30 days is further reduced to 1-hour resolution. The downsampling process aggregates values using AVG within each granularity bucket, preserving tag groupings.

Reads That Cross a Downsampling Pass

Downsampling replaces samples rather than deleting them, so a read already in flight when a pass runs cannot finish coherently: the rows it has already returned are the original samples, and the rows still to come are their coarser replacements. Joining the two would report the bucket at the crossing point at two different resolutions.

Such a read is therefore refused rather than allowed to return a short answer:

  • Over HTTP — including PromQL and the Grafana endpoints — the response is 503, so a client that already retries on 503 recovers on its own.

  • Over gRPC, Bolt, MongoDB and Postgres the equivalent retryable status is returned.

  • EXPORT DATABASE fails only the TIMESERIES type it was reading; every other type in the database is still exported.

Re-running the read succeeds and returns a complete answer at a single resolution, because downsampling is a scheduled maintenance event rather than something that happens per request.

A retention pass is different. It deletes old data outright, so a read that crosses one still completes and simply returns the rows that remain.

(Since v26.10.2) An unbounded read streams too: a SQL SELECT from a TIMESERIES type with no LIMIT in ascending order, the gRPC time-series stream without a row limit, and TimeSeriesEngine.iterateQuery in Java decode one sealed block per shard at a time instead of loading the whole range before returning the first row, so their memory no longer grows with the range (a 50-million-point range used to exhaust a 4 GB heap). They therefore follow the rules above: a block a retention pass deletes mid-read is skipped, and a downsampling pass mid-read refuses the read with the retryable error. Earlier versions held the type’s compaction lock while loading the range, so neither pass could change what such a read returned.

What an Export Reports

The EXPORT DATABASE summary carries two counters for TIMESERIES data that changed underneath it:

Field Meaning

vanishedTimeSeriesBlocks

Blocks a retention pass deleted while the export was reading them. Their samples are genuinely gone, so the export is still valid for the database as it now stands — but it is not the snapshot it would have been a moment earlier. Reported, never fatal.

partialTimeSeriesTypes

Types whose export was cut short by a downsampling pass. The rows already written for such a type are real but incomplete, and the export as a whole is reported as failed so the gap cannot pass unnoticed. Re-run the export for a complete result.

Grafana Integration

ArcadeDB provides Grafana DataFrame-compatible HTTP endpoints that work with the Grafana Infinity datasource plugin — no custom plugin is needed.

Endpoints:

Endpoint Description

GET /api/v1/ts/{database}/grafana/health

Datasource health check

GET /api/v1/ts/{database}/grafana/metadata

Discovers TimeSeries types, fields, tags, and available aggregation types

POST /api/v1/ts/{database}/grafana/query

Multi-target query returning Grafana DataFrame wire format

The query endpoint supports raw queries, aggregated queries (SUM/AVG/MIN/MAX/COUNT), tag filtering, field projection, and automatic bucket interval calculation from Grafana’s maxDataPoints setting.

Grafana Infinity datasource configuration:

  1. Install the Grafana Infinity datasource plugin.

  2. Add a new Infinity datasource.

  3. Set the base URL to http://<arcadedb-host>:2480.

  4. Configure authentication (Basic Auth with ArcadeDB credentials).

  5. Use the health endpoint for health checks.

Studio TimeSeries Explorer

The ArcadeDB Studio web interface includes a dedicated TimeSeries Explorer accessible from the main navigation sidebar.

The explorer provides four tabs:

  • Query — Time range selector, aggregation controls, field checkboxes, interactive charts (ApexCharts with zoom), data table with pagination, auto-refresh capability

  • Schema — Type introspection with column roles (TIMESTAMP/TAG/FIELD badges), diagnostics (total samples, shards, time range), configuration details, downsampling tiers, per-shard statistics

  • Ingestion — Documentation and examples for all four ingestion methods with a method comparison table

  • PromQL — PromQL expression input, instant/range toggle, time controls, chart and table rendering of results

HTTP API Reference

Method Endpoint Description

POST

/api/v1/ts/{database}/write?precision=<ns|us|ms|s>

InfluxDB Line Protocol ingestion. Body: plain text lines. Returns 204 on success.

POST

/api/v1/ts/{database}/query

JSON query endpoint. Supports raw and aggregated queries with tag filtering and field projection. A tags name that is no TAG column of the type is refused with 400. A DATE/DATETIME tag value is read in the database’s time zone.

GET

/api/v1/ts/{database}/latest?type=<name>&tag=<key>:<value>

Returns the most recent data point, with optional tag filter. Repeat tag to narrow across several tag columns; every occurrence must match. An occurrence that is malformed, or names no TAG column, is refused with 400. A DATE/DATETIME tag value is read in the database’s time zone.

GET

/api/v1/ts/{database}/prom/api/v1/query?query=<promql>&time=<unix_seconds>

PromQL instant query.

GET

/api/v1/ts/{database}/prom/api/v1/query_range?query=<promql>&start=<s>&end=<s>&step=<s>

PromQL range query.

GET

/api/v1/ts/{database}/prom/api/v1/labels

List all label names (metric names and tag columns).

GET

/api/v1/ts/{database}/prom/api/v1/label/{name}/values

List distinct values for a label.

GET

/api/v1/ts/{database}/prom/api/v1/series?match[]=<selector>

Find series matching PromQL selector(s).

POST

/api/v1/ts/{database}/prom/write

Prometheus remote_write 1.0 endpoint (snappy-compressed Protobuf). Answers 415 to a remote write 2.0 Content-Type.

POST

/api/v1/ts/{database}/prom/read

Prometheus remote_read endpoint (snappy-compressed Protobuf).

GET

/api/v1/ts/{database}/grafana/health

Grafana datasource health check.

GET

/api/v1/ts/{database}/grafana/metadata

Grafana metadata discovery.

POST

/api/v1/ts/{database}/grafana/query

Grafana DataFrame query endpoint.

High Availability

In an HA cluster, TimeSeries data is handled as follows:

  • Mutable bucket data (.tstb files) is replicated to all followers via ArcadeDB’s standard page-replication protocol

  • Sealed store data (.ts.sealed files) is replicated too, but by its own path: compaction, retention and downsampling run on the leader only, and the rewritten sealed file is shipped to the followers together with the change that clears the matching mutable data. Followers do not compact independently

  • Reads are consistent after failover: the engine queries both sealed and mutable layers, so a newly promoted leader returns correct results even before its first compaction cycle

  • Compaction lag: a node whose maintenance scheduler has not run yet (default: 60 seconds) serves the same results, just with more of the answer coming from the mutable layer

  • Backups and snapshots taken on a follower are consistent. Because the sealed file and the clearing of the mutable data arrive together, a copy taken across that moment used to be able to capture both and restore with duplicated samples; since 26.10.1 the copy waits for the install to finish

Integrity Checking

Starting in 26.9.1, if a type’s sealed store fails to open at all (for example, a corrupted .ts.sealed file), the type stays visible in the schema instead of disappearing. CHECK DATABASE reports it as corrupted and names the file; any query, write, or REST/PromQL/Grafana call against the type fails with a clear error instead of silently recreating an empty type or returning wrong results. Earlier versions dropped the type from the schema silently on the next restart.

A TimeSeries type has neither record buckets nor indexes, so CHECK DATABASE walks it with a pass of its own that reads all three of the on-disk formats it owns:

  • the mutable bucket (.tstb) — page 0’s counters are reconciled against the samples its data pages actually hold

  • the tag dictionary (.tstd) — the declared entry count against the entries walked

  • the sealed store (.ts.sealed) — the header, the block directory, the block bounds, and the CRC32 of every block

CHECK DATABASE
CHECK DATABASE TYPE SensorReading

The CRC32 pass reads the whole sealed file. That is deliberate: a sealed block is otherwise verified only on the first read that touches it, so a block nothing queries is a block nothing verifies.

The DEEP tier

CHECK DATABASE DEEP adds the checks that have to decompress the blocks. A CRC proves the bytes are the bytes that were written; it proves nothing about whether they mean what the block claims. Three things the read paths answer queries from, without ever looking at a value, are verified only here:

Claim What a wrong one does

Timestamps are sorted

The range iterator binary-searches them, so a query over the block silently returns a subset of the matching rows

Per-column min/max/sum

Aggregation push-down answers MIN/MAX/SUM/AVG from them without decompressing, so the query returns a wrong number

Distinct tag values

Block pruning skips a whole block whose declaration omits the value being filtered on, hiding those rows entirely

Each of those produces a wrong answer rather than an error, which is why nothing else in the engine reports them.

DEEP also reports a block written before 26.9.1 whose column holds only NaN samples: such a block stored an internal placeholder as its min/max, and aggregation push-down returns that placeholder as if it were a measurement. The check names the column and the remedy - recompacting or downsampling the block rewrites its header. Existing data is not corrected until then.

CHECK DATABASE DEEP
CHECK DATABASE TYPE SensorReading FIX DEEP

What FIX repairs

FIX repairs only what is derived from the data, and never a sample:

  • the mutable bucket’s page-0 counters (sample count, min and max timestamp)

  • the sealed header’s block count and global timestamp bounds

  • the tail of a sealed append that did not complete

None of those is cosmetic. The global bounds are read straight out of the header when the file is opened rather than recomputed, so a range query pruned against a wrong bound silently misses data the file holds; and because new blocks are appended at the end of the file, a tail no reader can see also hides every block appended after it.

A sealed block that fails its CRC, or that DEEP finds inconsistent, is reported and left untouched. It is the only copy of the samples in it, so discarding it is an operator’s decision - and in an HA cluster the sealed store is derived, so a node can rebuild one by recompacting from its replicated mutable pages.

The report carries totalTimeSeriesTypes, totalTimeSeriesShards, totalTimeSeriesSamples and totalTimeSeriesSealedBlocks alongside the findings, so a clean result can be told apart from a pass that never ran. Findings appear in warnings with the offending type named in corruptedTimeSeries; repairs are counted in repairedTimeSeries and listed in timeSeriesRepairs.

Comparison with Other TimeSeries Databases

Feature ArcadeDB InfluxDB 3 TimescaleDB Prometheus QuestDB

License

Apache 2.0

MIT (core)

Apache 2.0 (core)

Apache 2.0

Apache 2.0

Multi-Model

Graph + Document + K/V + TimeSeries + Vector in one engine

TimeSeries only

Relational + TimeSeries (PostgreSQL extension)

Metrics only

TimeSeries only (SQL)

Query Languages

SQL, PromQL, MongoDB QL, GraphQL (query fields). Cypher and Gremlin refuse a TimeSeries type with an error, see Query Languages

SQL, InfluxQL

Full PostgreSQL SQL

PromQL

SQL (PG wire)

Ingestion Protocols

Line Protocol, Prometheus remote_write, SQL, Java API

Line Protocol, SQL

SQL (INSERT, COPY)

Prometheus scrape, remote_write

Line Protocol, SQL, CSV

Compression

Gorilla, Delta-of-Delta, Simple-8b, Dictionary

Parquet native (Delta, Dict, Snappy/ZSTD)

Gorilla, Delta-of-delta, Simple-8b, Dictionary, LZ4

Gorilla (~1.37 B/sample)

ZFS-level + Parquet for cold tier

Continuous Aggregates

Yes (watermark-based, auto-refresh on commit)

Materialized views

Yes (policy-based refresh)

Recording rules

No

Downsampling

Yes (multi-tier, automatic)

Via compaction

Via continuous aggregates + retention

Recording rules

No

Retention Policies

Yes (automatic, per-type)

Yes

Yes (per-chunk)

Yes (per-block)

Yes (per-partition)

Grafana Integration

DataFrame endpoints (Infinity plugin)

Native plugin

PostgreSQL datasource

Native plugin

PostgreSQL datasource

PromQL Support

Native (parser + evaluator + HTTP endpoints)

No

Via adapter

Native

No

Embeddable (in-process)

Yes (Java library)

No

No (requires PostgreSQL)

No

No (separate process)

SIMD Aggregation

Yes (Java Vector API)

Via DataFusion (Arrow)

No

No

Yes (AVX2)

Prometheus Remote Write/Read

Yes

No

Via adapter

Native

No

Graph + TimeSeries Queries

Yes (native cross-model)

No

No

No

No

Choosing the layout: COMPACTION_INTERVAL and SHARDS

For a type that is bulk-loaded and then aggregated in fixed buckets (for example hourly averages), declare COMPACTION_INTERVAL with the bucket width of that aggregation and leave SHARDS at its default:

CREATE TIMESERIES TYPE Point TIMESTAMP ts TAGS (host STRING)
  FIELDS (uu DOUBLE, us DOUBLE, ui DOUBLE)
  COMPACTION_INTERVAL 1 HOURS
  • A sealed block answers an aggregate from its min/max/sum statistics only when it lies inside ONE query bucket. Without an interval a block covers many hours and is decompressed (PROFILE reports it under slow); with 1 HOURS the blocks are cut at the hour and are counted under fast.

  • Other queries on the same type are not penalised. A last-point query (ORDER BY ts DESC LIMIT 1) walks the blocks newest-first and stops at the first block that has the row, and a finer bucket (timeBucket('1m', ts)) over a short range reads only the blocks of that range, so smaller blocks only mean less to decompress. Ingest is not slowed either: blocks are cut when the mutable data is compacted, not while samples are appended.

  • The price is more, smaller blocks (each shard cuts at every interval boundary), so a very small interval on a long retention multiplies the block count. Pick the bucket of the aggregation you run most, not the smallest one you might ever run.

  • SHARDS trades ingest parallelism for fragmentation: every shard cuts its own blocks, so doubling the shards roughly doubles the blocks per interval, and queries merge one more stream. The default (available cores minus 1) suits concurrent writers; for a type loaded by one writer and queried more than written, fewer shards are enough.

  • Run COMPACT TIMESERIES TYPE <name> after a bulk load: mutable samples are not in sealed blocks yet, and only sealed blocks are answered from their statistics.

Architecture

The TimeSeries engine uses a two-layer storage architecture:

  • Mutable bucket — An append-only in-memory buffer backed by ArcadeDB’s PaginatedComponent. New samples land here first. This layer is ACID-transactional and replicated in HA mode.

  • Sealed store — Immutable, compressed columnar blocks on disk. The maintenance scheduler periodically compacts mutable data into sealed blocks using Gorilla, Delta-of-Delta, Simple-8b, and Dictionary codecs. Block-level min/max/sum statistics enable zero-decompression aggregation when an entire block falls within a single time bucket (for an aggregation with no GROUP BY, within the queried range), and let a range condition on a numeric field skip a block without reading it.

Since v26.10.2. A sealed block keeps the statistics of an integer column (LONG, INTEGER, SHORT, BYTE, BOOLEAN and the date/time types) exactly: the minimum and the maximum as 64-bit integers and the sum as a 128-bit integer, so min, max and sum of such a column past 2^53 are answered from the block headers, exactly, without decompressing anything. A block written by an earlier version is answered from its headers whenever its statistics are provably exact, and decompressed otherwise until the next compaction rewrites it. The sealed file format version is 3: an older ArcadeDB version refuses to open such a file, so keep this in mind before a downgrade.

Since v26.10.2. Integer columns (LONG, DATETIME_NANOS and the other integral types) hold the full 64-bit range. A block whose values do not all fit the compact Simple-8b packing (outside +/-2^59, such as epoch-nanosecond instants or snowflake ids) is stored as plain 64-bit values instead of making the compaction fail. Such a block is larger, and an older ArcadeDB version cannot read it, so keep this in mind before a downgrade. Before, compaction of such a type failed permanently.

Data is distributed across N shards (default: arcadedb.asyncWorkerThreads, the available cores minus 1) for parallel writes and reads. Each shard maintains its own mutable bucket and sealed store. Queries merge results across shards using a min-heap priority queue sorted by timestamp.

See Also