Skip to main content

Rows Tables

The second table family. A timeseries table observes ("entity X looked like this at time T"); a rows table registers — one live row per id, the current state. Device registries, name↔id mappings, configuration, catalogs: the data an IoT stack usually parks in a separate document store lives here, next to its telemetry.

CREATE ROWS TABLE devices (name TEXT, model TEXT, active BOOL);

The id is the engine's

Every rows table carries an implicit, immutable xid. The engine mints it on INSERT and returns it in the result — MongoDB's _id pattern:

INSERT INTO devices (name, model) VALUES ('Vehicle 1', 'S8');
-- → xid: 019f31db42672981e4f90c0975200000

The id is an XID: a 128-bit, time-ordered identifier rendered as 32 lowercase hex characters — [48-bit unix ms][64-bit sequence][16-bit node id]. Because the timestamp leads, ids sort in insert order (string order = numeric order = time order): id indexes append instead of splitting random pages — in the engine and in every client replica — and the id itself tells you when the row was born. The sequence reseeds randomly each millisecond and increments within it, so same-millisecond inserts stay ordered and collisions are practically impossible. The node id is 0000 today; it is the reserved seat for multi-node writing.

You can never supply, change or reuse an id; an xid column in INSERT, SET xid in UPDATE, or a reserved (xid, x_…) column in CREATE are all hard errors. Natural keys (a DTC code, a serial number) are ordinary columns of your own — look rows up through them:

SELECT * FROM devices; -- every current row
SELECT * FROM devices WHERE xid = '019f...'; -- by the engine's id
SELECT * FROM devices WHERE name = 'Vehicle 1'; -- by your own column (scan)
SELECT * FROM devices WHERE revision > 41250; -- numeric range (scan)

One column comparison per statement: = works on any column; >, >=, <, <= need a numeric one. A range result is ordered by the compared column (ascending), so LIMIT pages it deterministically.

Sync-native: x_rev, x_deleted, VACUUM

Every rows table carries two more implicit columns, as engine-owned as the id: x_rev, the table's monotone write sequence (stamped on every INSERT, UPDATE and DELETE), and x_deleted, the soft tombstone. They never appear in SELECT *, cannot be declared, written or altered — ask for them by name.

DELETE is soft: the row leaves every normal read (scans, point reads, count(*), natural-key lookups) but its trace stays. A statement whose WHERE filters on x_rev or x_deleted is the feed — it sees deleted rows too, which is how deletions travel to replicas:

SELECT xid, x_rev, x_deleted, code FROM dtc WHERE x_rev > 41250 LIMIT 500;
-- apply the batch, advance the cursor to its max x_rev, repeat

The feed is ordered by x_rev and pages with LIMIT; a client that remembers the highest x_rev it has applied pulls everything newer — inserts, updates and deletions — with one idiom. Under concurrent writers the feed answers only up to its watermark (the last write sequence that has fully landed), so an advancing cursor can never skip a row whose write is still in flight — the next pull sees it.

Push rides x_rev too — it is the row's version. Carry the x_rev your replica knows and the engine writes only if it is still current:

UPDATE dtc SET name_center_tr = '...' WHERE xid = '019f...' AND x_rev = 41250;
-- tag UPDATE 1: yours won (the row moved to a new x_rev)
-- tag UPDATE 0: someone wrote first — nothing overwritten, resolve and retry
DELETE FROM dtc WHERE xid = '019f...' AND x_rev = 41250; -- same contract

No silent last-write-wins: a stale push is reported, not applied.

Tombstone traces live forever by default. VACUUM <table> purges them physically and records the highest purged x_rev as the table's vacuum horizon: a feed cursor below the horizon is refused (the deletions it would need are gone) — that client resyncs in full. Vacuum when every replica has caught up, and the contract stays airtight.

A full scan (no xid filter) resolves the latest of every column in a single pass, so it stays linear even after the delta has folded into a segment.

Counting

count(*) returns the live row count — the whole table or a filtered slice:

SELECT count(*) FROM devices; -- how many rows
SELECT count(*) FROM devices WHERE region = 'north'; -- how many match

It rides the same single-pass scan, so it is linear (≈0.1 s for 20k rows), not a probe per row. The other aggregates (avg, sum, count(col)) are timeseries-only; a rows table is a current-state store, and count(*) is the aggregate it answers.

UPDATE and DELETE

UPDATE devices SET name = 'Vehicle 1B' WHERE xid = '019f...';
DELETE FROM devices WHERE xid = '019f...'; -- soft: the feed still sees it

UPDATE is a partial write: untouched columns keep their values. There is no upsert — INSERT always creates a new row with a new id, and the uniqueness of your natural keys is your application's contract (no unique constraints, no secondary indexes, no joins — by design; a relational appetite belongs to a relational database).

No time axis

FILL, STALENESS, SAMPLE BY, LATEST, ts ranges, retention, MUTABLE and ILP ingest are timeseries concepts; a rows table refuses them all. Internally a rows table rides the same mutable machinery as everything else — WAL, checkpoint and the backup contract are identical.

The pairing

The registry and its telemetry meet through the id:

CREATE ROWS TABLE devices (name TEXT, model TEXT);
CREATE TABLE telemetry (speed DOUBLE FILL HOLD) WITH (ENTITY UUID);

-- provision: register, hand the returned id to the device
INSERT INTO devices (name) VALUES ('Vehicle 1'); -- → id
INSERT INTO telemetry (entity, ts, speed) VALUES ('<id>', 1700000000000, 50);

WITH (ENTITY UUID) is the wall: that telemetry table refuses any entity that is not a UUID, so a display name can never leak into the history's key. Rename the device in devices — the telemetry never notices.