Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
29 KiB
QuestDB historian backend — T0 feasibility spike
Status: run 2026-10-11 against a live container. Gitea #666. Spec: docs/plans/2026-10-10-questdb-historian-design.md
(§2–§4 amended in place from this doc; every confirmed form there is marked T0-confirmed).
Fixture
| Item | Value |
|---|---|
| Image | questdb/questdb:10.0.1 — Docker Hub tag_last_pushed 2026-08-24T14:00:08Z (48 days before 2026-10-10) |
| Why that tag | Newest exact x.y.z tag ≥ 14 days older than 2026-10-10. Newer tags are nightly* / patch-* only. Floor for TTL is 8.2.3; 10.0.1 is well above it. |
| Build | SELECT build() → Build Information: QuestDB 10.0.1, JDK 25.0.2, Commit Hash 7a391566ac827e0d8c269b91f2415c16b0a32a84 |
| Overlay | docker-dev/docker-compose.questdb.yml — container otopcua-dev-questdb-1, ports 18812:8812 (PG wire) and 19000:9000 (console/REST), named volume otopcua-questdb-data (explicit name: so it is not project-prefixed) |
| Up | docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.questdb.yml up -d --no-deps questdb (a --dry-run showed only the volume + otopcua-dev-questdb-1 being created; nothing else in the otopcua-dev project is touched) |
| Healthcheck | the image ships /usr/bin/curl (command -v curl → /usr/bin/curl; no wget), so curl -fs http://localhost:9003/status is kept. Container reached healthy. |
| Harness | throwaway net10.0 console app, Npgsql 10.0.3 (the repo's central pin, Directory.Packages.props line 97), in the session scratchpad questdb-spike/ with its own Directory.Build.props, Directory.Packages.props and nuget.config. Nothing was added to the repo. |
Connection string (confirmed)
Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb;ServerCompatibilityMode=NoTypeLoading
Inside the docker network the same string uses Host=questdb;Port=8812.
Q1 — keywords, parameter types, CLR types
[OK] open+SELECT 1 with 'Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb'
[OK] open+SELECT 1 with '...;ServerCompatibilityMode=NoTypeLoading'
[OK] open->close->reopen x5 on one data source (no 'No Reset On Close')
[OK] reopen after a parameterised INSERT (prepared-ish state)
[FAIL] ts Utc + Timestamp: ArgumentException: Cannot write DateTime with Kind=UTC to PostgreSQL type 'timestamp without time zone', consider using 'timestamp with time zone'. ...
[OK] ts Utc + TimestampTz
[OK] ts Utc + inferred
[OK] ts Unspecified + Timestamp
[FAIL] ts Local + TimestampTz: ArgumentException: Cannot write DateTime with Kind=Local to PostgreSQL type 'timestamp with time zone', only UTC is supported. ...
[OK] kind bound as Integer (int4)
[OK] tag bound as Text
[OK] NaN double insert
[OK] +Inf double insert
cols: ts:timestamp without time zone/DateTime, tag:character varying/String, kind:smallint/Int16, num_value:double precision/Double,
int_value:bigint/Int64, str_value:character varying/String, status:bigint/Int64, deleted:boolean/Boolean, server_ts:timestamp without time zone/DateTime
2026-01-02T03:04:05.1234560(Unspecified) | ts Utc + TimestampTz | 3<Int16> | 1.5 | 7<Int64> | s | 2147483648<Int64> | False | 2026-10-11T04:56:27.1103710(Unspecified)
...
2026-01-02T03:04:11.1234560(Unspecified) | nan | 0<Int16> | NULL | NULL | NULL | NULL | False | NULL
2026-01-02T03:04:12.1234560(Unspecified) | inf | 0<Int16> | NULL | NULL | NULL | NULL | False | NULL
integer arithmetic with $n:
cols: ts::LONG - $1:bigint/Int64 [OK] cast(ts AS LONG) - $1 (Bigint)
cols: (ts::LONG * 10 - $1) / $2:bigint/Int64 [OK] (Bigint) [OK] (Integer)
Sentinel NULLs (extra check):
cols: ts, l:bigint/Int64, i:integer/Int32, b:smallint/Int16, z:boolean/Boolean, l = NULL:boolean
2026-03-01T00:00:00 | NULL | NULL | 0 | False | True <- l = long.MinValue, i = int.MinValue written
2026-03-01T00:00:01 | -9223372036854775807 | -2147483647 | 0 | False | False
2026-03-01T00:00:02 | NULL | NULL | 0 | False | True <- BYTE/BOOLEAN bound as DBNull
Decisions
- Both connection strings work. Keep
ServerCompatibilityMode=NoTypeLoading: it skips thepg_typeload, and every form in this doc was run with it on.No Reset On Closeis not needed: open → close → reopen on one pooledNpgsqlDataSourceworks. - Timestamps are bound as
NpgsqlDbType.TimestampTzwithDateTimeKind.Utc.Timestamp+Utcthrows in Npgsql before reaching the server.Unspecified+Timestampalso works, but one rule is enough. - Timestamps read back as
DateTimewithKind=Unspecified, wall clock = UTC. The reader mustDateTime.SpecifyKind(r.GetDateTime(i), DateTimeKind.Utc). - CLR types returned:
BYTE→smallint/Int16(GetInt16);SYMBOL→character varying/String;VARCHAR→String;LONG→Int64;INT→Int32;BOOLEAN→Boolean;TIMESTAMP→DateTime;DOUBLE→Double.count()/sum(int)→Int64. - Parameter types:
BYTE←Smallint(Integeralso accepted);SYMBOL/VARCHAR←Varchar(Textalso accepted);LONG←Bigint;BOOLEAN←Boolean;DOUBLE←Double; a NULL isDBNull.Valuewith the column'sNpgsqlDbType. $nin integer arithmetic: bind asNpgsqlDbType.Bigint(Integer also worked for small values; Bigint is the rule because tick offsets exceed int32).- DOUBLE NaN and ±Inf read back as NULL.
LONGlong.MinValueandINTint.MinValueare QuestDB's NULL sentinels and read back NULL.BYTEandBOOLEANhave no NULL: unset or DBNull reads0/false. Sodeleteddefaults tofalseandkindof a never-written field is0(Empty). - Every server error carries
SqlState = 00000. Classification cannot use SQLSTATE: aPostgresExceptionis a server-side rejection (decide byMessageText), any otherNpgsqlException/IOException/SocketException/timeout is transport.
Q2 — multi-row parameterised INSERT and batch size
[OK] 1000 rows x 9 params = 9000 params 88 ms
[OK] 2000 rows x 9 params = 18000 params 167 ms
[OK] 3640 rows x 9 params = 32760 params 342 ms
[FAIL] 3641 rows x 9 params = 32769 params: PostgresException: 00000: invalid parameter count [parameterCount=-32767, offset=225670
[FAIL] 5000 rows x 9 params = 45000 params: PostgresException: 00000: invalid parameter count [parameterCount=-20536, ...
[FAIL] 7282 rows x 9 params = 65538 params: NpgsqlException: A statement cannot have more than 65535 parameters
20 x 1000-row statements: fails=0, 122 ms total
count()=27251 | count_distinct(ts)=27251 expected rows = 27251
Decision: QuestDB reads the Bind parameter count as a signed int16, so the real limit is 32,767 parameters per
statement, not Npgsql's 65,535. Batch size = 1,000 rows per INSERT … VALUES (…),(…) (9,000 parameters for
hist_sample), which is reliable and far under the limit. DeleteRaw page size = 1,000 rows (see the DDL section).
Q3 — DEDUP UPSERT KEYS
before: 2026-03-01T00:00:00 | a | 1 | False 2026-03-01T00:00:00 | b | 2 | False
after re-insert of (t0,'a') as tombstone:
2026-03-01T00:00:00 | a | NULL | True 2026-03-01T00:00:00 | b | 2 | False
NOT deleted: 2026-03-01T00:00:00 | b | 2
same key twice in one statement (10 then 20): 2026-03-01T00:00:01 | c | 20
hist_event with event_id VARCHAR as a dedup key:
[OK] CREATE hist_event-shape with DEDUP UPSERT KEYS(ts, event_id) on VARCHAR
[OK] insert e1 'first', e2, then e1 'second'
2026-03-01T00:00:00 | e1 | src1 | 500 | second | NULL
2026-03-01T00:00:00 | e2 | src1 | 500 | other | NULL
Decision: re-inserting a key replaces the whole row; a tombstone written at (ts, tag) hides the row from NOT deleted. Within one statement the LAST duplicate wins, so the writer's "first value wins" rule must be applied
client-side before the INSERT (spec §2.2 step 1 already does this). event_id VARCHAR works as a dedup key, so the
S4 fallback (no dedup on hist_event) is not needed.
Q4 — wal_tables() and the visibility wait
cols: name:varchar, suspended:boolean, writerTxn:bigint, bufferedTxnSize:bigint, sequencerTxn:bigint,
errorTag:varchar, errorMessage:varchar, memoryPressure:integer
run 0: seqTxn before=0 after=1 visibleImmediately=False waitMs=13 visibleAfterWait=True
run 1: seqTxn before=1 after=2 visibleImmediately=False waitMs=0 visibleAfterWait=True
...
run 16: seqTxn before=16 after=17 visibleImmediately=False waitMs=6 visibleAfterWait=True
lag ms: min=0 median=0 p95=6 max=13; visible-without-wait 17/20
(An earlier run: visible without waiting 13/20, max 6 ms.) Every autocommitted INSERT is exactly one sequencer transaction.
Decision (the form T3's QuestDbWalVisibility implements):
- After the INSERT(s) return, on the same or any connection:
SELECT sequencerTxn FROM wal_tables() WHERE name = $1($1= the table name,Varchar) →target. - Poll
SELECT writerTxn, suspended FROM wal_tables() WHERE name = $1every ~10 ms untilwriterTxn >= target, orsuspendedis true, orVisibilityTimeoutelapses.
A concurrent writer can only make target larger, which lengthens the wait slightly and never shortens it. The wait is
needed: up to 7/20 inserts were not yet visible on the next query. The S11 key-poll fallback is not needed.
Q5 — TTL
[FAIL] CREATE ... PARTITION BY DAY WAL DEDUP UPSERT KEYS(ts, tag) TTL 7 DAYS (TTL last): unexpected token [TTL]
[OK] CREATE ... PARTITION BY DAY TTL 7 DAYS WAL DEDUP UPSERT KEYS(ts, tag) (TTL before WAL)
spike_q5 | DAY | True | True | ttlValue=1 | ttlUnit=WEEK
[FAIL] ALTER TABLE spike_q5 SET TTL $1 DAYS (parameter): invalid syntax, should be <number> <unit> but was $1
[FAIL] TTL 7 DAYS on PARTITION BY YEAR table: TTL value must be an integer multiple of the partition size (its time interval)
Q5b ALTER ... SET TTL visibility in tables()
SET TTL 30 DAYS : seqTxn 6->7; applied after 100 ms; tables() now ttlValue=30 ttlUnit=DAY
SET TTL 14 DAYS : seqTxn 7->8; applied after 94 ms; tables() now ttlValue=2 ttlUnit=WEEK
SET TTL 1 DAY : seqTxn 8->9; applied after 59 ms; tables() now ttlValue=1 ttlUnit=DAY
SET TTL 0 DAYS : seqTxn 9->10; applied after 25 ms; tables() now ttlValue=0 ttlUnit=HOUR
SET TTL 3 DAYS : seqTxn 10->11; applied after 60 ms; tables() now ttlValue=3 ttlUnit=DAY
after inserting now-20d and now into a TTL 7 DAYS table:
new | 2026-10-11T05:03:03
Q5c TTL reference clock (future-dated row):
after inserting a now+30d row, then one more ordinary commit:
d-3 | 2026-10-08 now | 2026-10-11 now+1h | 2026-10-11 d+30 | 2026-11-10
Image conf/server.conf: #cairo.ttl.use.wall.clock=true — "when true (default), TTL enforcement uses wall clock
time to prevent accidental data loss when future timestamps are inserted".
Decision:
- Create syntax:
… TIMESTAMP(ts) PARTITION BY DAY TTL n DAYS WAL DEDUP UPSERT KEYS(…)— TTL goes betweenPARTITION BYandWAL. Alter:ALTER TABLE t SET TTL n DAYS; remove:ALTER TABLE t SET TTL 0 DAYS.ncannot be a parameter; it is the validated integerRetentionDays, formatted withInvariantCulture. - Metadata:
SELECT ttlValue, ttlUnit FROM tables() WHERE table_name = $1. QuestDB normalises the unit: 7 days shows1 WEEK, 14 days2 WEEK, 30 days30 DAY, none0 HOUR. The bootstrapper compares in hours (HOUR=1,DAY=24,WEEK=168; any other unit counts as different) and issuesALTERonly when they differ. ALTER … SET TTLis a WAL transaction:tables()shows the old value until it is applied (25–100 ms). Do not re-read to verify without the Q4 wait.- Partitions older than TTL are dropped on commit. With the default wall-clock mode a future-dated sample does not expire current data.
- TTL cannot go on the
PARTITION BY YEARhist_tagtable; it is never needed there.
Q6 — timestamp precision
raw sent 2026-03-01T12:00:00.1234567(Utc) -> read 2026-03-01T12:00:00.1234560 micros=1772366400123456 exact=False
raw9 sent 2026-03-01T12:00:01.1234569(Utc) -> read 2026-03-01T12:00:01.1234560 micros=1772366401123456 exact=False
pre sent 2026-03-01T12:01:00.1234560(Utc) -> read 2026-03-01T12:01:00.1234560 micros=1772366460123456 exact=True
Decision: Npgsql truncates sub-µs ticks (…1234567 → …123456; not rounded). That is truncation toward zero
relative to 2000-01-01, so before 2000 it rounds up (Q13). A pre-truncated (ticks - ticks % 10) value
round-trips exactly. This confirms R6: always Round first.
Q7 — concurrent CREATE TABLE IF NOT EXISTS
Q7 concurrent CREATE TABLE IF NOT EXISTS (4 connections x 20 rounds)
errors: none
plain CREATE on existing: SqlState=00000 MessageText='table already exists'
(An earlier run with 2 connections × 20 rounds also had no errors.) Decision: IF NOT EXISTS was race-free here.
The S11 catch rule stays as defence: catch a PostgresException whose MessageText contains already exists,
then re-check with SELECT count() FROM tables() WHERE table_name = $1, and rethrow when the table is not there. Never
key on SQLSTATE (it is always 00000).
Q8 — bucket statistics
Seed (3 × 10 s buckets from start = t0 + 0.5 s): bucket 0 holds Good 5, Uncertain 2, Bad 9, a usable String, Good 2
(a min tie with the Uncertain 2), a tombstone of 100, and Good 7 at the last µs of the bucket. Bucket 1 holds Good 1 at
the edge, an Empty BadNoCommunication and an Empty with Good status. Bucket 2 holds Bad 3 only. Another tag sits in
range.
Simple family (GROUP BY alias):
cols: b, total_count, usable_count, good_count, bad_count, non_numeric_count, avg_value, min_value, max_value, sum_value
0 | 6 | 5 | 4 | 1 | 1 | 4 | 2 | 7 | 16
1 | 3 | 1 | 1 | 2 | 0 | 1 | 1 | 1 | 1
2 | 1 | 0 | 0 | 1 | 0 | NULL | NULL | NULL | NULL
[OK] simple GROUP BY <expression> (no subquery) [OK] simple implicit group-by
StartEnd form A (first()/last()):
0 | 6 | 5 | 4 | 1 | 1 | 1 | 5 | NULL | NULL | 00:00:00.5000000 | 1 | 7 | NULL | NULL | 00:00:10.4999990
1 | 3 | 1 | 1 | 2 | 0 | 1 | 1 | NULL | NULL | 00:00:10.5000000 | 1 | 1 | NULL | NULL | 00:00:10.5000000
2 | 1 | 0 | 0 | 1 | 0 | 0 | NULL | NULL | NULL | NULL | 0 | NULL | NULL | NULL | NULL
StartEnd form B (row_number window): identical rows [OK] filter-in-subquery [OK] extra predicate in ON
ActualTime (min): 0 ... | 2 | 00:00:01.5000000 1 ... | 1 | 00:00:10.5000000 2 ... | NULL | NULL
ActualTime (max): 0 ... | 7 | 00:00:10.4999990 1 ... | 1 | 00:00:10.5000000 2 ... | NULL | NULL
[OK] arg_min(ts, num_value) exists? 0 | 00:00:01.5 | 00:00:10.499999 1 | 00:00:10.5 | 00:00:10.5
randomized cross-check (60k rows over 3 days (4 partitions), 2 tags, 7 min buckets, random statuses/kinds/tombstones):
buckets=617 rowsInWindow=28423 mismatches=0; first()/last() 23 ms, window 21 ms, actual-time min+max 26 ms;
arg_min/arg_max (one stmt) 11 ms, tie-break mismatches vs earliest-ts rule = 0
Every hand-computed value matches: bucket 0 averages (5+2+2+7)/4 = 4, the tombstone is invisible, the sample one µs before the edge stays in bucket 0, the edge sample is in bucket 1, and both Empty rows count as bad whatever their status. The randomized cross-check compares counts, first/last usable times, sums and min/max actual times against a client-side computation. It used a µs-aligned start that is not on a second boundary, and integer-rounded values to force ties.
Decision (the confirmed forms, with the SQL Server family split kept):
- Classified subquery
s. Status is stored as the unsigned uint in a LONG, so usable =status < 2147483648and good =status < 1073741824, both also needingkind <> 0:SELECT (cast(ts AS LONG) * 10 - $1) / $2 AS b, ts, kind, num_value, int_value, str_value, CASE WHEN status < 2147483648 AND kind <> 0 THEN 1 ELSE 0 END AS u, CASE WHEN status < 1073741824 AND kind <> 0 THEN 1 ELSE 0 END AS g FROM {p}sample WHERE tag = $3 AND ts >= $4 AND ts < $5 AND NOT deleted$1= window start in ticks since the Unix epoch (start.Ticks - DateTime.UnixEpoch.Ticks,Bigint);$2= interval in ticks (Bigint);$4/$5= rounded window bounds.cast(ts AS LONG)is µs since 1970, so*10is ticks. That is exact because both sides are already µs-aligned (R6). At year 9999µs*10≈ 2.5e18 < 9.2e18, so it cannot overflow. - Common counts:
count() AS total_count, sum(u) AS usable_count, sum(g) AS good_count, count() - sum(u) AS bad_count, sum(CASE WHEN u = 1 AND kind IN (4, 5) THEN 1 ELSE 0 END) AS non_numeric_count. - Simple:
SELECT b, <counts>, avg(CASE WHEN u = 1 THEN num_value END) AS avg_value, min(…), max(…), sum(…) FROM (<s>) GROUP BY b ORDER BY b. - StartEnd: the window form, which is the same shape as SQL Server:
WITH s AS (<s>), st AS (SELECT b, <counts> FROM s GROUP BY b), f AS (SELECT b, kind, num_value, int_value, str_value, ts, row_number() OVER (PARTITION BY b ORDER BY ts) AS rf, row_number() OVER (PARTITION BY b ORDER BY ts DESC) AS rl FROM s WHERE u = 1) SELECT st.*, x.kind, x.num_value, x.int_value, x.str_value, x.ts, y.kind, …, y.ts FROM st LEFT JOIN (SELECT * FROM f WHERE rf = 1) x ON x.b = st.b LEFT JOIN (SELECT * FROM f WHERE rl = 1) y ON y.b = st.b ORDER BY st.b. A bucket without a usable sample has NULLtsbut kind0(BYTE has no NULL), so the evaluator must key "no first/last" on the NULL time. - ActualTime:
… r AS (SELECT b, num_value, ts, row_number() OVER (PARTITION BY b ORDER BY num_value ASC|DESC, ts ASC) AS rn FROM s WHERE u = 1 AND num_value IS NOT NULL) SELECT st.*, r.num_value, r.ts FROM st LEFT JOIN (SELECT * FROM r WHERE rn = 1) r ON r.b = st.b ORDER BY st.b. - One query per node holds for every family. The S11 two-statement fallback is not needed.
first()/last()andarg_min/arg_maxalso gave correct answers, but their tie order is not specified by anORDER BY. The window form states the earliest-timestamp tie-break explicitly, so it is the one to use. SAMPLE BYis not used.
Q9 — AtTime neighbour lookup
200k rows (2 tags × 100k, ~1.5 s apart, 5 % tombstones). 102 request times, including one before the first sample, one exact hit and one after the last sample.
form A SQL (one time): SELECT * FROM (SELECT 0 AS req, 0 AS dir, ts, kind, num_value, int_value, str_value, status FROM spike_q9
WHERE tag = $1 AND ts <= $2 AND NOT deleted ORDER BY ts DESC LIMIT 1) UNION ALL SELECT * FROM (SELECT 0 AS req, 1 AS dir, ts, ...
WHERE tag = $1 AND ts > $2 AND NOT deleted ORDER BY ts ASC LIMIT 1)
form A chunk= 1: 61 ms for 102 times, rows=202, mismatches=0
form A chunk= 10: 20 ms ... chunk= 25: 18 ms chunk= 50: 19 ms chunk= 60: 18 ms chunk= 63: 16 ms (all mismatches=0)
form A chunk= 64: FAIL 'SQL model is too complex to evaluate' (128 UNION ALL branches)
form A chunk=102: FAIL 'SQL model is too complex to evaluate' (204 UNION ALL branches)
form B: 1 ms for 102 times (prev only), rows=102, mismatches=0 [ASOF JOIN of a long_sequence(1) UNION ALL request set]
Decision: form A, chunked LIMIT 1 seeks, at most 50 request times (100 UNION ALL branches) per statement.
The exact limit is 126 UNION ALL branches (63 request times) per statement. A re-measure with single-seek
branches (review fix, below) showed 126 work and 127 fail. The form A runs above agree: 63 times (126 branches) work,
and 64 times (128 branches) fail. The literal req index and dir (0 = previous-or-equal, 1 = next) columns are
generated integers, not user input; $1 is the tag and $2.. the rounded times. It returns both neighbours in one
shape and costs ~0.2 ms per time. ASOF JOIN is faster, but it only gives the previous neighbour, so a second
seek form would still be needed for the next one. That does not justify two code paths at this scale.
Q10 — long strings and SYMBOL capacity
[OK] 400-char tag (SYMBOL) + 400-char str_value (VARCHAR)
[OK] 5000-char str_value
30,000 distinct SYMBOL values inserted in 723 ms
count()=30002 | count_distinct(tag)=30002
400-char round trip: tag equal=True str equal=True lengths=400,400 (includes non-ASCII αβγ…)
tag | SYMBOL | symbolCached=True | symbolCapacity=16384
point lookup by tag among 30k symbols: count=1 0 ms
Decision: VARCHAR and SYMBOL take 400 characters (and VARCHAR takes 5,000). SYMBOL grows past its capacity hint
(16,384) without error, so 30k tags is fine. No CAPACITY clause is needed.
Q11 — existing-timestamp lookup for a batch
[OK] range form: WHERE tag=$1 AND ts >= $2 AND ts <= $3 (tombstones included) -> 10 rows, incl. 00:00:08 deleted=True
[OK] IN-list form: ts IN ($2..$11) -> 4 rows (00, 06, 12, 18)
[OK] tombstone visible to range lookup -> 00:00:08 | True
Decision: both work with parameters. Live writer (§2.2 step 2): the range form
SELECT ts FROM {p}sample WHERE tag = $1 AND ts >= $2 AND ts <= $3 (no NOT deleted, so tombstones count as present),
then a client-side set test. Updater (sparse item times): the IN-list form SELECT ts, deleted FROM {p}sample WHERE tag = $1 AND ts IN ($2, …), chunked at 1,000 times.
Q12 — test cleanup and prefix listing
LIKE 't12_%' (note: _ is a wildcard) -> t12_sample, t12xsample <- WRONG: matches t12xsample
LIKE $1 with escaped _ ('t12\_%') -> t12_sample
starts_with(table_name, $1) -> t12_sample
left(table_name, 4) = $1 -> t12_sample
[OK] DROP TABLE IF EXISTS t12_sample (exists) [OK] DROP TABLE IF EXISTS t12_sample (already gone)
Decision: cleanup is DROP TABLE IF EXISTS {name}. List with SELECT table_name FROM tables() WHERE starts_with(table_name, $1). Never use LIKE 'prefix%', because _ in hist_ is a wildcard.
Q13 — binding extreme DateTimes
Seeds: 1970-01-01, 1990-05-05T00:00:00.123456, 2026-03-01, 2200-01-01. "Round" is R6's rule: MinValue → +10 ticks,
then ticks - ticks % 10.
DateTime.MinValue raw -> server sees µs -9222425352054775808 (Npgsql sends -infinity)
DateTime.MinValue Round -> 0001-01-01T00:00:00.0000010 (µs -62135596799999999)
DateTime.MaxValue raw -> server sees µs -9222425352054775809 (Npgsql sends +infinity)
DateTime.MaxValue Round -> 9999-12-31T23:59:59.9999990 (µs 253402300799999999)
0001-01-01 raw -> µs -9222425352054775808 (== MinValue -> -infinity)
1601-01-01 raw/Round -> 1601-01-01T00:00:00 (µs -11644473600000000)
9999-12-31T23:59:59.9999999 raw -> µs -9222425352054775809 (== MaxValue -> +infinity)
1969-12-31T23:59:59.9999995 raw -> 1970-01-01T00:00:00.0000000 (µs 0) Round -> 1969-12-31T23:59:59.9999990 (µs -1)
1990-05-05T00:00:00.1234567 raw -> 1990-05-05T00:00:00.1234570 Round -> ...1234560
1990-05-05T00:00:00.1234565 raw -> 1990-05-05T00:00:00.1234570 Round -> ...1234560
2026-03-01T00:00:00.0000005 raw -> 2026-03-01T00:00:00.0000000 Round -> same
range queries WHERE ts >= $1 AND ts < $2:
Min..Max raw count=0 <- silently empty
Min..Max Round count=4
0001..9999.9999999 Round count=4
1601..t0 Round count=2
1990.1234565..1990.1234575 raw count=0 <- seed .123456 missed (bound arrived as .123457)
1990.1234565..1990.1234575 Round count=1
inserting extreme designated timestamps:
[FAIL] insert ts=1969-12-31T23:59:59: PostgresException: 00000: designated timestamp before 1970-01-01 is not allowed
[FAIL] insert ts=1601-01-01: ... [FAIL] insert ts=Min+10 ticks: ...
[OK] insert ts=9999-12-31 Round -> 9999-12-31T23:59:59.9999990
Decision: R6's BindTimestamp is confirmed necessary and sufficient.
- A raw
DateTime.MinValue/MaxValue(and anything equal to them) becomes ±infinity, and QuestDB stores that as a near-long.MinValuevalue. A[Min, Max)range then matches nothing, with no error. - A raw pre-2000 sub-µs value rounds up, which shifts a bound across a stored sample.
- With Round, every bound behaves.
- New constraint: a designated timestamp before 1970-01-01 cannot be inserted. Reads with pre-1970 bounds work. See the spec §2.2 ruling candidate.
DDL and DeleteRaw paging (extra)
[OK] CREATE TABLE IF NOT EXISTS spike_hist_sample (...) TIMESTAMP(ts) PARTITION BY DAY TTL 30 DAYS WAL DEDUP UPSERT KEYS(ts, tag)
[OK] CREATE TABLE IF NOT EXISTS spike_hist_event (...) TIMESTAMP(ts) PARTITION BY DAY TTL 30 DAYS WAL DEDUP UPSERT KEYS(ts, event_id)
[OK] CREATE TABLE IF NOT EXISTS spike_hist_tag (...) TIMESTAMP(ts) PARTITION BY YEAR WAL DEDUP UPSERT KEYS(ts, name)
[OK] re-run (idempotent) x3
spike_hist_event | DAY | True | True | 30 | DAY
spike_hist_sample | DAY | True | True | 30 | DAY
spike_hist_tag | YEAR | True | True | 0 | HOUR
hist_tag upsert-by-name at ts=1970-01-01 (tagA twice, tagB once):
1970-01-01T00:00:00 | tagA | 4 | degF | second
1970-01-01T00:00:00 | tagB | 1 | NULL | NULL
DeleteRaw paging (page = 1000 rows): 2500 live rows + 1 outside range
pages=3 tombstones=2500 in 103 ms
deleted=False 1 deleted=True 2500
DeleteRaw loop, as confirmed: SELECT ts FROM {p}sample WHERE tag = $1 AND ts >= $2 AND ts < $3 AND NOT deleted ORDER BY ts LIMIT 1000 → one 1,000-row tombstone INSERT → the Q4 wait → continue from last ts + 10 ticks. The wait matters:
without it the next page can re-read rows it just tombstoned.
Review fixes (2026-10-11)
Raw LIMIT $n with a bound parameter
dotnet run -- limit against 10.0.1, with 10 rows seeded in [t0, t0+10 s). The SQL was SELECT ts, num_value FROM spike_lim WHERE tag = $1 AND ts >= $2 AND ts < $3 AND NOT deleted ORDER BY ts [DESC] LIMIT $4:
rows=3 first=0 last=2 [OK] ASC LIMIT $4 (Integer 3)
rows=3 first=9 last=7 [OK] DESC LIMIT $4 (Integer 3)
rows=3 first=0 last=2 [OK] ASC LIMIT $4 (Bigint 3)
rows=3 first=9 last=7 [OK] DESC LIMIT $4 (Bigint 3)
rows=10 first=0 last=9 [OK] ASC LIMIT $4 (Integer int.MaxValue)
rows=10 first=9 last=0 [OK] DESC LIMIT $4 (Integer int.MaxValue)
rows=10 first=0 last=9 [OK] ASC LIMIT $4 (Bigint long.MaxValue)
rows=10 first=9 last=0 [OK] DESC LIMIT $4 (Bigint long.MaxValue)
rows=0 first=NULL last=NULL [OK] ASC LIMIT $4 (Integer 0)
rows=0 first=NULL last=NULL [OK] DESC LIMIT $4 (Integer 0)
Decision: a bound LIMIT $n works, with NpgsqlDbType.Integer (Int32) or Bigint (Int64). Use Integer, since
top is an int. R8's unbounded value int.MaxValue returns every row. LIMIT 0 returns nothing, but R8 never sends
it. The inlined-literal fallback is not needed.
Exact UNION ALL branch limit
dotnet run -- union. Each branch is SELECT * FROM (SELECT k AS req, ts FROM spike_un WHERE tag = $1 AND ts <= $2 AND NOT deleted ORDER BY ts DESC LIMIT 1):
[OK] 100 branches [OK] 120 branches [OK] 125 branches [OK] 126 branches
[FAIL] 127 branches: PostgresException: 00000: SQL model is too complex to evaluate
[FAIL] 128 branches / 129 / 130: same
Decision: the limit is 126 branches, which is 63 AtTime request times (two branches per time). The chosen chunk of 50 times (100 branches) is unchanged.
Rulings candidates raised by the spike
- Pre-1970 timestamps cannot be stored (Q13). QuestDB rejects a designated timestamp before 1970-01-01, and the
whole multi-row INSERT fails. Proposed:
- the live writer drops such samples and logs one Warning per batch. Retrying cannot succeed, and sending the sample would fail the tag's whole group into #538 quarantine;
- HistoryUpdate Insert/Replace/Update items before 1970 return
BadOutOfRange; - DeleteAtTime/DeleteRaw find nothing there and return
BadNoEntryExists; - reads are unaffected. Conformance tests that seed pre-1970 data are skipped for QuestDB.
long.MinValueis QuestDB's LONG NULL (sentinel check). An Int64 sample of exactlylong.MinValuewould read backint_valueNULL, andFromColumnswould then serve 0. Proposed: the QuestDB read path repairs it. Whenkind = Int64,int_value IS NULLandnum_value = -9.223372036854775808E18(exact in a double), servelong.MinValue. That is a local fix-up before the sharedFromColumns, with no write-side change.- TTL metadata is unit-normalised and applied asynchronously (Q5). The bootstrapper compares in hours and does not
re-read straight after an
ALTER.