Time series
Ingest timestamped readings, query exact ranges, and chart bucketed measurements using ordinary Antfly tables and algebraic indexes.
Time series use ordinary Antfly tables. Declare a timestamp, keyword dimensions
such as device_id, and numeric measurements. Use the normal batch and query
APIs; the planner decides whether an eligible aggregation can use an algebraic
materialization.
Create a table
import { AntflyClient, timeSeriesTable } from "@antfly/sdk";
const client = new AntflyClient({ baseUrl: "http://localhost:8080" });
const config = timeSeriesTable({
timestamp: "observed_at",
dimensions: ["device_id"],
measurements: ["temperature"],
retention: "7d",
bucket: "5m",
});
console.log(config); // Ordinary, inspectable schema and index configuration.
await client.tables.create("readings", config);
The preset declares sortable fields, a descending timestamp physical index order, optional table TTL, and exact temporal algebraic recipes. It creates no embeddings. The physical order accelerates compatible chronological reads; selective filters may instead use exact candidate collection. Inspect the query profile rather than assuming every query uses the same access path.
A series identity is a grouping convention, not an event identity. Multiple valid readings may share the same timestamp and device. Use a producer event ID, or a stable combination of device, timestamp, and sequence, for each observation.
Ingest and correct readings
import { seriesBatch } from "@antfly/sdk";
await client.tables.batch("readings", {
...seriesBatch([
{ id: "sensor-1:sequence-42", document: {
device_id: "sensor-1",
observed_at: "2026-11-06T12:01:00.000000001Z",
temperature: 23.4,
} },
], "observed_at"),
sync_level: "full_index",
});
Readings can arrive out of order. RFC3339 timezone offsets normalize to the same
instant, and nanosecond strings retain their precision. Fixed durations accept
positive integer components in ns, us, ms, s, m, h, d, and w.
Examples include 30s, 5m, and 1h30m. Months and years are calendar units,
not fixed durations.
Batch writes are ordinary upserts with the API's existing transaction guarantees.
Replaying the same ID and payload avoids an extra observation, but an old retry
can overwrite a newer correction. Use version-conditional writes where that must
be prevented. Document versions advance independently of event time. The allocator is durable:
repeated timestamps, clock rollback, and delete/reinsert after restart cannot
reuse a version. Replicated commands activate this behavior only after every
applying replica crosses protocol 27; historical commands retain their original
version semantics when replayed. Source-preserving online transfers carry the
version and signed TTL instant together, independently of recipient TTL settings.
They also preserve the allocator floor from certified snapshot metadata and retained
deletes, including empty ranges. The receiver advances that floor atomically with
copy progress, so migration and restart cannot reintroduce a deleted version.
Helpers do not automatically replay ambiguous writes. Split large
imports into bounded batches and preserve the IDs and exact payload for retries.
client.writeSeries(table, timestamp, readings, { batchSize: 500 }) consumes an
iterable or async iterable in sequential bounded batches. It stops on error;
earlier batches remain committed. The result reports pending and repair-required
batch counts, and an optional onBatch callback receives each full commit outcome.
A SeriesImportError exposes acknowledged progress and pending IDs; reconcile
that batch before retrying it.
Pass an AbortSignal to stop ingestion while waiting for the next reading or
an onBatch callback. Cancellation closes the source and reports acknowledged
and buffered progress without submitting another batch. User-provided sources
and callbacks can continue settling after cancellation if they do not cooperate;
their late rejections are observed, and source cleanup cannot replace the original
failure or keep an aborted import pending.
Query bucketed measurements
const options = {
table: "readings", timestamp: "observed_at",
start: "2026-11-06T12:00:00Z", end: "2026-11-06T13:00:00Z",
dimensions: { device_id: "sensor-1" },
groupBy: ["device_id"], bucket: { every: "5m" },
measures: {
average: { op: "avg" as const, field: "temperature" },
minimum: { op: "min" as const, field: "temperature" },
maximum: { op: "max" as const, field: "temperature" },
},
};
const { series, result } = await client.series(options);
The SDK constructs a normal query. The equivalent aggregation request is:
{
"limit": 0,
"filter_query": {
"field": "observed_at",
"start": "2026-11-06T12:00:00Z",
"end": "2026-11-06T13:00:00Z",
"inclusive_start": true,
"inclusive_end": false
},
"aggregations": {
"points": {
"type": "date_histogram",
"field": "observed_at",
"fixed_interval": "5m",
"date_bounds": {
"start": "2026-11-06T12:00:00Z",
"end": "2026-11-06T13:00:00Z"
},
"min_doc_count": 0,
"sub_aggregations": {
"average": { "type": "avg", "field": "temperature" }
}
}
}
}
Ranges are half open: the start is included and the end is excluded. A fixed
interval aligns to the UTC epoch unless bucket_origin supplies an RFC3339
origin. Calendar intervals use calendar_interval instead and reject a fixed
origin. Calendar buckets currently use UTC boundaries; IANA timezone/DST bucket
rules are not implemented by this API. Explicitly normalize any application
business-time policy before relying on calendar buckets.
date_bounds supplies the empty-bucket extent, not a document filter. Supply a
matching range filter to restrict measurements. Empty generation is limited to
10,000 buckets. Bounded filling currently accepts primary metrics, not pipeline
aggregations. Grouped requests use a terms limit; increase it deliberately or
select explicit series rather than assuming all high-cardinality series fit.
The series helpers preserve missing measurements as null. A count of zero is different from a temperature of zero. If a visualization requires zero filling, perform it explicitly for that measurement. Do not interpolate values implicitly.
SQL
SELECT device_id,
date_bin('5m', observed_at, '1970-01-01T00:00:00Z'::datetime) AS bucket,
AVG(temperature) AS average
FROM readings
WHERE observed_at >= '2026-11-06T12:00:00Z'::datetime
AND observed_at < '2026-11-06T13:00:00Z'::datetime
GROUP BY device_id, bucket
ORDER BY device_id, bucket;
date_bin shares exact fixed-duration arithmetic with JSON histograms, including
origins and offset normalization. The SQL helper casts both source timestamps and
range parameters to datetime, including the preset's JSON string columns. For grouped
queries using the preset's scalar keyword dimension model, it selects the same top maxSeries by observation count and key before
bucketing, including canonical JSON tuple tie-breaking for composite keyword groups.
Series helpers set exclude_null: true on terms grouping: both paths exclude null
and missing grouping keys before series ranking. Ordinary terms queries retain
their existing null policy unless this option is enabled.
Its selection CTE retains only the bounded series list; it does not materialize the
entire filtered source. SQL COUNT results follow the SQL API's lossless bigint wire
representation and can be decimal strings. JSON metric responses retain 64-bit floating-point
precision, matching SQL floating measurements. Exact decimal SQL values retain
their separate numeric type semantics. Use date_trunc('day', observed_at) for UTC
calendar grouping. SQL emits populated groups; bounded empty generation is a JSON
presentation option. seriesSQL(options) produces parameterized SQL with quoted
identifiers. Dotted field names resolve to nested JSON paths in timestamps,
dimensions, grouping, and measurements. To address a literal dotted SQL column,
pass explicit segments, for example
seriesSQL(options, { fieldPaths: { "sensor.time": ["sensor.time"] } }).
Nested measurements are cast to floating-point values before aggregation.
SQL expressions outside the materialization planner's proven shapes
retain normal scan execution.
PostgreSQL and TimescaleDB conventions
The fixed-duration SQL primitive follows PostgreSQL's
date_bin
shape: width, timestamp, and explicit origin. Antfly currently accepts its compact
duration strings, such as '5m', rather than PostgreSQL INTERVAL values.
TimescaleDB's time_bucket_gapfill
is a useful model for the separate gap-generation stage: missing buckets do not
imply measured zeroes. Antfly's bounded JSON histograms generate missing buckets,
while SQL currently emits populated groups. With fill: 0, generated SQL applies
COALESCE after measurement aggregation, preserving document counts. Antfly does not currently expose SQL
time_bucket_gapfill, last-observation-carried-forward, or interpolation functions.
These policies belong after aggregation, so inferred values never become facts
in an algebraic index. TimescaleDB timezone, calendar, and default-alignment
behavior should not be assumed for Antfly queries.
Latest readings and pagination
import { seriesRead } from "@antfly/sdk";
const page = await client.query(seriesRead({
table: "readings", timestamp: "observed_at",
start: "2026-11-06T00:00:00Z", end: "2026-11-07T00:00:00Z",
dimensions: { device_id: "sensor-1" }, limit: 100,
}));
Reads default to descending time. The server appends _id as a deterministic
tie-breaker. Continue with the complete last _sort tuple and the retained
remote_snapshot from the response. Keep the bounds, filter, and order unchanged.
If the snapshot expires, restart the read rather than mixing snapshots. For latest
N per device, issue bounded per-device requests; a broad SQL window expression
can be correct without having a bounded per-device access cost. The TypeScript
latestSeries helper accepts an explicit selection of at most 100 series and
limits concurrency to four requests, retaining each result’s snapshot. On failure
it stops admitting queued requests, cancels outstanding calls, and waits for them
to finish before rejecting.
Other SDKs
Python exposes series_query and fixed_duration_nanoseconds:
from antfly import series_query
result = client.query("readings", query=series_query(
timestamp="observed_at", start="2026-11-06T12:00:00Z",
end="2026-11-06T13:00:00Z", measurement="temperature",
every="5m", group_by=["device_id"],
))
Go exposes NewSeriesQuery(SeriesQueryOptions{...}), using time.Time bounds and
time.Duration intervals. Rust exposes time_series::series_query, returning a
GlobalStatefulQueryRequest accepted directly by client.global_query(&request). All SDKs expose the generated temporal aggregation
and recipe fields directly.
Algebraic maintenance and retention
Public aggregate recipes can declare time and bucket along with dimensions,
operation, and measurement. Fixed buckets align to the epoch. The engine owns
physical state and only reuses compatible, complete materializations. Grouped
terms → date_histogram → metrics queries reuse the preset when their half-open
source range aligns to the maintained bucket boundaries. The range proof comes
from the source predicate, independently of date_bounds or empty-bucket presentation;
emptyBuckets: false retains the same indexed access opportunity. Equivalent fixed widths
such as 5m and 300s match. Extra maintained dimensions constrained by exact keyword filters are reduced,
and those filters are applied, and complete shard states merge before global series
ranking. A custom origin, unaligned boundary, unsupported predicate, stale index,
or incompatible layout uses the exact scan path. Unconstrained extra dimensions also
fall back because documents missing those fields may be absent from the materialization. Profiled responses report
aggregation_execution: "materialized_temporal" when this path succeeds, or a
scan_... reason when a temporal plan declines.
Execution is bounded to one million maintained cells and 64 MiB of retained distributed partial data. Owners apply proven time and dimension predicates before exporting states; unrelated history still counts against the maintained-scan work budget, but does not consume transfer or coordinator retention budgets. Owner exports use bounded parallel fanout, with at most four transport buffers alive in a window. Budget exhaustion declines the acceleration attempt rather than failing an otherwise valid exact scan. The scan fallback retains its normal source-row limit. Choose bounded time ranges and dimensions; the accelerator scans maintained state rather than guaranteeing a constant-cost range lookup. TTL reuse additionally requires proof that every matching observation remains visible through the query deadline; otherwise the exact visibility-filtered scan runs.
AVG merges sum and non-null count state, never averages of averages. Late inserts, overwrite corrections, and deletes follow the ordinary algebraic maintenance path. Authorization and snapshot coverage still gate reuse.
The document-table preset accepts pre-1970 observations with retention enabled. Retention uses the signed event instant, independently of the unsigned write version. New document writes allocate nonzero revisions from durable state, using admission time only as a lower bound. Correcting a reading advances its version even when the clock repeats or moves backward. Replay and restoration retain the original version. Old observations are accepted normally and immediately hidden when expired; sufficiently long retention can keep a pre-1970 observation visible. Existing timestamp metadata remains readable without rewriting retained data.
Staged restore and document-preserving rewrites retain the original TTL source, including when the configured TTL field is absent. They preserve the distinction between an epoch timestamp and missing timestamp metadata, and carry the durable revision floor even when its latest document was deleted. Replay does not derive retention from the restored write version. These pages require revision-capable replicas before proposal. Graph edge TTL continues to use its own unsigned admission lifetime, independently of the document's signed event time.
Repair preserves persisted edge lifetimes; it never derives them from logical revisions or event timestamps. A legacy field-only edge without an authoritative lifetime requires explicit re-admission of its document through the normal write path (use a version-conditional upsert to avoid overwriting a concurrent edit). This creates durable lifetime metadata. Known expired lifetimes remain expired during repair.
Table TTL expires raw observations and removes their contributions from exact aggregate indexes. These indexes are rebuildable accelerators over retained raw data. They do not preserve historical summaries after source expiration. Independently retained summaries, summary-aware TTL, and live subscriptions are tracked separately in the time-series feature request.
Explore in Antfarm
Open a table's Time series section. Choose the timestamp, measurement, grouping fields, range, and interval; inspect charts or bucket data and copy SQL/JSON. Refresh polling is opt-in, cancels obsolete requests, and uses a fixed selected range. It is not a live subscription or an automatically advancing window.
Numeric histogram timestamps must be renderable as UTC years 0000 through 9999. Out-of-domain timestamps return a query error. Fixed-interval extents are checked against the bucket budget before allocation. This rendering restriction does not narrow signed document-retention timestamps.
Bounded empty histograms accept metric children only and share a request-wide
output budget of 100,000 cells (bucket plus metrics), shared across groups and
root histograms for both populated-only and empty-filled results, in addition to the 10,000
bucket limit per histogram. The complete extent is reserved before constructing
metric results, including on populated scans. Histograms without explicit bounds
still fill interior gaps when min_doc_count is zero. Nested bounded bucket generation
is rejected before execution. Cancellation and deadlines are checked while folding
and filling; partially constructed histograms are safely released on failure.
The TypeScript series helpers use the maintained AVG non-null count as an
internal measurement-presence companion for SUM. The count operation counts
observations (SQL COUNT(*)), consistent with JSON document-count semantics.
An absent measurement is returned as the selected fill value, while a measured
zero stays zero; raw JSON SUM keeps its normal empty-sum identity. Internal
__antfly_series_ metric names are reserved. writeSeries failures preserve
acknowledged progress and report the failure phase, buffered IDs, ambiguous
submitted IDs, and pending or repair-required batch counts. A callback failure
occurs after acknowledgement and does not make that batch ambiguous.
Charts retain extrema within a bounded pixel budget, show isolated observations as dots, and never bridge missing intervals. The bucket table mounts on expansion and displays 100 rows per page.