Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
12 KiB
QuestDB historian — live gate (T9), 2026-10-11
Verdict: PASSED. All six legs of spec §7 passed on the docker-dev rig. The rig ran feat/questdb-historian at
2ebe0a42, with the central pair on image 0701f6a833ea, rebuilt for this gate. The QuestDB backend pointed at the
overlay questdb service (questdb/questdb:10.0.1, docker-dev/docker-compose.questdb.yml), with default table
prefix hist_.
The gate also produced two findings, neither in the QuestDB backend. Both are described under "Gate findings" below:
- A crash failover (SIGKILL of the recording Primary) leaves a recording gap of about 30 s. This is the #537 Primary-gated capture waiting for Akka downing; it is backend-independent.
- One
libgssapi_krb5.so.2load error is printed per central boot. It is benign.
Setup
-
docker-dev/docker-compose.ymlgainedServerHistorian__QuestDb__RetentionDays(OTOPCUA_HISTORIAN_QUESTDB_RETENTION_DAYS, default empty = no TTL) on both centrals, beside the existingServerHistorian__QuestDb__ConnectionString(OTOPCUA_HISTORIAN_QUESTDB). -
Central-1 and central-2 were started with:
OTOPCUA_HISTORIAN_ENABLED=true OTOPCUA_HISTORIAN_BACKEND=QuestDb OTOPCUA_CONTHIST_ENABLED=true \ docker compose -f docker-dev/docker-compose.yml -f docker-dev/docker-compose.questdb.yml up -d --no-deps central-1 central-2With
RetentionDaysempty, both nodes booted cleanly; the empty value binds as "no TTL". Each loggedQuestDB historian connection pool: Maximum Pool Size = 24 (default cap).(the final-review I-1 fix). -
Cluster
MAINfixtures on10.100.0.35: opc-plc (opcplc/plc/FastUInt1, UInt32, about 1 Hz) and pymodbusstandard(pymodbus/plc/HR5). -
Gate configuration, written to the config DB and deployed headlessly (
deploy.sh, deployment0b4f5208):- New Calculation String tag
Calc/eng/FastLabel="v" + FastUInt1(scriptMAIN-script-fastlabel). FastUInt1,FastLabelandHR5were markedisHistorized.- A UNS reference
a/l/e1/FastUInt1was added.
- New Calculation String tag
-
Tags were provisioned at deploy (
dispatched=3, requested=3, ensured=3, skipped=0, failed=0on both nodes).hist_tag:name data_type Calc/eng/FastLabel9 (String) opcplc/plc/FastUInt15 (Int64) pymodbus/plc/HR54 -
Tables at first boot, from
tables():hist_sample,hist_event: WAL,PARTITION BY DAY,ttlValue 0.hist_tag: WAL,PARTITION BY YEAR.
-
Two data-plane API keys were minted for the gate, each with a
Cluster-scoped grant. The ACL flag stayed off, which is the rig default.gate-historyupdate: ReadOnly | HistoryUpdate (4239)gate-writeop: ReadOnly | WriteOperate (159)
Direct queries used QuestDB REST: curl -G localhost:19000/exec --data-urlencode "query=…".
1. Recording: Primary-only, typed, real status, one row per value
- Rows carry real kinds and statuses:
kind=2Int64int_value 278(withnum_value 278.0), status0.kind=4Stringv278, status0.- At startup,
kind=0Empty with2150760448(0x80320000BadWaitingForInitialData) and2150694912(0x80310000BadNoCommunication). These match the SQL Server gate.
- Primary-only:
- Central-2 was the recording Primary, and its outbox
hlog.log.0grew. - Central-1 logged "this node is not the recording Primary … suppressing value capture". Its
hlog.log.0kept its 2026-10-09 19:13 mtime and was not written.
- Central-2 was the recording Primary, and its outbox
- One row per value, from 07:43:20 onwards:
- 57 FastUInt1 rows over 57 distinct timestamps; 57 FastLabel rows over 57.
- All 57 pair one-to-one: same
ts, andstr_value = 'v' + int_value.
- Outbox carry-over across a backend switch. Two rows carry source time
2026-10-09T19:13:40.129127Z(296/v296) withserver_ts 07:42:27, before the deploy. They were un-drained entries left in central-2's outbox by the 2026-10-09 SQL Server gate, and they drained into QuestDB at boot. An outbox entry outlives the backend it was captured under, by design: the outbox is backend-neutral.
2. HistoryRead through the raw and UNS NodeIds
All reads went to central-1 (:4840).
- Raw read, 07:44:00–07:44:10, on
ns=2;s=opcplc/plc/FastUInt1and onns=3;s=a/l/e1/FastUInt1:- Both return the identical 10 rows,
271…280, all Good. - They equal the direct query
select ts, int_value, status from hist_sample where tag=… and not deletedexactly, at microsecond precision (for example07:44:00.952990Z).
- Both return the identical 10 rows,
- Average at 10 s, 07:44:00–07:44:30, through both NodeIds:
- Both return
275.5 / 285.5 / 295.5, all Good. - The direct
avg(int_value)per 10 s bucket gives275.5 / 285.5 / 295.5, over 10, 10 and 10 samples.
- Both return
3. HistoryUpdate
These calls went to central-1 on ns=2;s=opcplc/plc/FastUInt1, with -U apikey. Each was checked by historyread
and by a direct query.
| Call | Result | Confirmed |
|---|---|---|
insert 06:00:00 = 42 (gate-historyupdate) |
GoodEntryInserted |
row 42, status 0, deleted=false |
| repeat insert = 43 | BadEntryExists |
row still 42 |
| replace 06:00:00 = 99 | GoodEntryReplaced |
row 99 |
| replace 06:00:30 (no entry) | BadNoEntryExists |
— |
| update 06:01:00 = 11 (absent) | GoodEntryInserted |
row 11 |
| delete-at-time 06:00:00 | Good |
tombstone row: int_value NULL, status 0x809B0000, deleted=true |
| insert over the tombstone, 06:00:00 = 55 | GoodEntryInserted (ledger ruling: an insert over a tombstone is an insert) |
read returns 55 |
| delete-at-time 06:00:00 again | Good |
read returns only 06:01:00 = 11 |
insert with gate-writeop (WriteOperate only) |
Node result BadUserAccessDenied |
no row |
| insert, anonymous | Node result BadUserAccessDenied |
no row |
| insert at 1969-12-31 (RC1) | BadOutOfRange |
no row |
No QuestDB UPDATE or DELETE was issued: every change above is a new row under DEDUP UPSERT KEYS(ts, tag).
A delete is not resurrected by an outbox redelivery
This was proven with a real replay, not a simulation:
- 07:47:26–07:47:35:
docker stopQuestDB. The Primary (central-1) kept capturing; its writes failed, so the entries stayed pending in its outbox. - 07:47:35: the outbox was snapshotted while QuestDB was down (commit point
commit.1303.0). - 07:47:35: QuestDB was restarted. The 16-value backlog (
476…491) drained as one batch (server_ts 07:47:41.620267), with no gap. This is a second result: QuestDB loss is ridden out by the outbox. - 07:50:45–46:
delete-at-timeon two backlog rows,07:47:28.950591(479) and07:47:31.949216(482). Both became tombstones (deleted=true,0x809B0000). - 07:50:46: central-1 was stopped. Its outbox directory (on the
otopcua-localdb-central-1volume) was replaced with the pre-delivery snapshot. - 07:51:15: central-1 was started. It re-drained the snapshot's entries, and the outbox commit point advanced
1303 → 1328.
After the replay:
- Both tombstones are unchanged: same
server_ts 07:50:45.669146 / 07:50:46.074579, stilldeleted=true. - Every other row in the window kept its original
server_ts 07:47:41.620267, so nothing was re-written. - 24 rows over 12 keys (2 tags × 12): no duplicates.
historyread07:47:27–07:47:33 returns478, 480, 481, 483;479and482stay deleted.
A first attempt used ALTER TABLE hist_sample SUSPEND WAL plus a SIGKILL of the Primary. It could not produce a
redelivery. The writer deliberately treats a suspended table as committed and acknowledges it (R5: rows committed to
the WAL become visible later, and are not re-sent). It logged "QuestDB table hist_sample is suspended (writerTxn 327
< sequencerTxn 337); not waiting" and acked. That attempt exposed the crash-failover finding below.
4. Failover
- 07:50:46: central-1 was stopped (step 5 above). Central-2 logged "now the recording Primary" at 07:50:46.
- 07:51:57:
docker stopcentral-2, then the Primary. Central-1 logged "this node is now the recording Primary … resuming value capture" at 07:51:57, as central-2 began leaving. - 07:52:13: central-2 was restarted and rejoined as standby ("not the recording Primary"). Its outbox
hlog.log.0stayed frozen at 57856 bytes (mtime 07:51:58). Central-1's grew from 65536 to 84480. - Rate across both failovers was 1×. Per-30 s FastUInt1 counts from 07:50:00 to 07:54:30:
30, 29, 30, 30, 30, 30, 29, 29, 30.
- There was no gap at the boundary:
743…754are contiguous across 07:51:57. - There was no doubling: 267 rows over 267 distinct timestamps.
- There was no gap at the boundary:
5. Device loss: driver Bad sample and comms-loss outage marker
This leg ran on Modbus (HR5). otopcua-pymodbus-standard was stopped at 07:54:40 and restarted at 07:56:51.
| ts | kind | value | status | server_ts |
|---|---|---|---|---|
| 07:51:18.715101 | 2 | 5 | 0 |
07:51:18 |
| 07:54:51.960244 | 0 (Empty) | NULL | 0x80050000 BadCommunicationError (the driver's own Bad publish) |
07:56:13 |
| 07:56:48.652185 | 0 (Empty) | NULL | 0x80310000 BadNoCommunication (the #527 watchdog outage marker) |
07:56:48 |
| 07:56:58.688150 | 2 | 5 | 0 (recovery re-publish) |
07:56:58 |
Reads over the outage:
- Raw
historyread07:50–07:58 shows Good, Bad, Bad, Good. - Average at 120 s, 07:52–07:58, gives
(null) Bad/(null) Bad/5 Uncertain. The outage is visible and is not bridged.
The Bad sample's server_ts is 82 s after its source time. Its source time is the driver's failed-read stamp; it
reached the recorder later. Its position in the series is correct either way, because ordering and reads key on
source time.
6. TTL visible in table metadata
Both centrals were recreated with OTOPCUA_HISTORIAN_QUESTDB_RETENTION_DAYS=30. At 07:57:41 the bootstrapper logged
Applied TTL 30 day(s) to QuestDB table hist_sample. and the same for hist_event. The TTL was applied to the
already existing tables.
tables() before and after:
| table | before | after |
|---|---|---|
hist_event |
0 HOUR | 30 DAY |
hist_sample |
0 HOUR | 30 DAY |
hist_tag |
0 HOUR | 0 HOUR (no TTL on the tag catalog, by design) |
SHOW CREATE TABLE hist_sample:
… timestamp(ts) PARTITION BY DAY TTL 30 DAYS DEDUP UPSERT KEYS(ts,tag);
Recording resumed after the restart: 57 FastUInt1 rows and 56 FastLabel rows in the first minute.
Gate findings
- A crash failover leaves a recording gap of about 30 s. This is backend-independent and predates this work.
- What happened: after
docker kill(SIGKILL) of the recording Primary at 07:45:58, the next FastUInt1 row is at 07:46:29. Central-1 became the recording Primary only at 07:46:30, when the killed node rejoined as a new incarnation. - Why: #537 gates capture to the Primary. A crashed Primary stays unreachable but not yet downed for the Akka
failure-detector window plus
auto-down-unreachable-after = 15s, so no node captures during that time. - Contrast: a clean
docker stophands over at once (leg 4). The SQL Server gate only exercised the clean path. - Status: proposed follow-up issue, not fixed here.
- What happened: after
Cannot load library libgssapi_krb5.so.2is printed once per central boot. It does not appear on the site nodes, which have neither a ConfigDb nor a QuestDB client. It is most likely Npgsql's default GSS-encryption probe, which falls back cleanly. Everything worked. If it is confirmed as Npgsql,GSS Encryption Mode=Disablein the connection string silences it.
Restore
- The rig was restored afterwards:
- central-1 and central-2 were recreated with the compose defaults (historian off,
Backend=Gateway). - The gate tag, script, UNS reference, API keys and ACL rows were removed.
- The
HR5andFastUInt1TagConfigs were reverted. - The configuration was redeployed.
- The overlay
questdbcontainer was stopped and removed; theotopcua-questdb-datavolume was kept. - The opc-plc and pymodbus fixtures were stopped (normally down).
- central-1 and central-2 were recreated with the compose defaults (historian off,
- The compose keys stay:
Backenddefaults toGateway, andRetentionDaysdefaults to empty.