1a7a8f6f22
QuestDB has no multi-table transaction, so a HistoryUpdate writes its trail row before the sample change. A failed or crashed change left an orphan trail row that modified reads served as a change that happened, and a trail INSERT re-sent after a client timeout duplicated (the table had no key). - sample_modified gains `pending` and DEDUP UPSERT KEYS(ts, tag, modified_id). The trail row is written pending; after the change lands the same row is re-upserted pending = false (the applied marker). - A transient trail INSERT failure is re-sent once with the same ids; a first attempt that did apply converges on the key. - ReadModifiedAsync serves a pending row only when it is the latest trail row at its time and the sample there was written at or after the modification (a crash between change and marker). The orphan of a change that never landed, typically followed by the client's retry, is dropped. ASOF JOIN over the window's samples of one tag; the resume filter sits outside the window function so it never cuts a time's partition. - The bootstrapper migrates an older trail (ADD COLUMN pending, DEDUP ENABLE) and waits for it to apply (bounded 10 s); old rows read pending = false and stay visible. Until applied, or with AutoCreateSchema=false, the old format is used. Rebased from the unpushed remed/lane-H2 branch (originally authored 2026-10-22 in session_01Ub9hGmYLQvn9XETzw7CMsT) onto current master. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
106 lines
5.6 KiB
C#
106 lines
5.6 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb.Tests;
|
|
|
|
public sealed class QuestDbTablesTests
|
|
{
|
|
[Fact]
|
|
public void Sample_ddl_without_ttl_is_pinned() =>
|
|
QuestDbTables.CreateSample("hist_", null).ShouldBe(
|
|
"CREATE TABLE IF NOT EXISTS hist_sample (ts TIMESTAMP, tag SYMBOL INDEX, kind BYTE, num_value DOUBLE, " +
|
|
"int_value LONG, str_value VARCHAR, status LONG, deleted BOOLEAN, server_ts TIMESTAMP) " +
|
|
"TIMESTAMP(ts) PARTITION BY DAY WAL DEDUP UPSERT KEYS(ts, tag)");
|
|
|
|
[Fact]
|
|
public void Sample_ddl_with_ttl_puts_ttl_between_partition_and_wal() =>
|
|
QuestDbTables.CreateSample("hist_", 7).ShouldBe(
|
|
"CREATE TABLE IF NOT EXISTS hist_sample (ts TIMESTAMP, tag SYMBOL INDEX, kind BYTE, num_value DOUBLE, " +
|
|
"int_value LONG, str_value VARCHAR, status LONG, deleted BOOLEAN, server_ts TIMESTAMP) " +
|
|
"TIMESTAMP(ts) PARTITION BY DAY TTL 7 DAYS WAL DEDUP UPSERT KEYS(ts, tag)");
|
|
|
|
[Fact]
|
|
public void Tag_index_migration_statements_are_pinned()
|
|
{
|
|
// #683: the migration for a sample table created before the tag index existed, and its probe.
|
|
QuestDbTables.AddTagIndex("hist_sample").ShouldBe("ALTER TABLE hist_sample ALTER COLUMN tag ADD INDEX");
|
|
QuestDbTables.TagIndexedQuery("hist_sample").ShouldBe(
|
|
"SELECT indexed FROM table_columns('hist_sample') WHERE \"column\" = 'tag'");
|
|
}
|
|
|
|
[Fact]
|
|
public void Every_filtered_symbol_column_is_indexed_and_has_a_migration()
|
|
{
|
|
QuestDbTables.SymbolIndexes("hist_").ShouldBe(
|
|
[("hist_sample", "tag"), ("hist_event", "source"), ("hist_sample_modified", "tag")]);
|
|
QuestDbTables.AddIndex("hist_event", "source").ShouldBe("ALTER TABLE hist_event ALTER COLUMN source ADD INDEX");
|
|
QuestDbTables.IndexedQuery("hist_event", "source").ShouldBe(
|
|
"SELECT indexed FROM table_columns('hist_event') WHERE \"column\" = 'source'");
|
|
// The CREATE statements carry every index the migration would add.
|
|
var ddl = QuestDbTables.CreateAll("hist_", null);
|
|
foreach (var (table, column) in QuestDbTables.SymbolIndexes("hist_"))
|
|
ddl.Single(d => d.StartsWith($"CREATE TABLE IF NOT EXISTS {table} (", StringComparison.Ordinal))
|
|
.ShouldContain($"{column} SYMBOL INDEX");
|
|
}
|
|
|
|
[Fact]
|
|
public void Event_ddl_is_pinned()
|
|
{
|
|
QuestDbTables.CreateEvent("hist_", null).ShouldBe(
|
|
"CREATE TABLE IF NOT EXISTS hist_event (ts TIMESTAMP, event_id VARCHAR, source SYMBOL INDEX, alarm_id VARCHAR, " +
|
|
"alarm_name VARCHAR, alarm_type_name VARCHAR, severity INT, event_kind VARCHAR, message VARCHAR, " +
|
|
"user_name VARCHAR, comment VARCHAR, received_utc TIMESTAMP) " +
|
|
"TIMESTAMP(ts) PARTITION BY DAY WAL DEDUP UPSERT KEYS(ts, event_id)");
|
|
QuestDbTables.CreateEvent("hist_", 30).ShouldContain("PARTITION BY DAY TTL 30 DAYS WAL DEDUP");
|
|
}
|
|
|
|
[Fact]
|
|
public void Tag_ddl_is_pinned_and_never_carries_a_ttl() =>
|
|
QuestDbTables.CreateTag("hist_").ShouldBe(
|
|
"CREATE TABLE IF NOT EXISTS hist_tag (ts TIMESTAMP, name SYMBOL, data_type BYTE, eng_unit VARCHAR, " +
|
|
"description VARCHAR, updated_utc TIMESTAMP) TIMESTAMP(ts) PARTITION BY YEAR WAL DEDUP UPSERT KEYS(ts, name)");
|
|
|
|
[Fact]
|
|
public void The_prefix_is_applied_to_every_table_name()
|
|
{
|
|
QuestDbTables.All("t1_").ShouldBe(["t1_sample", "t1_event", "t1_tag", "t1_sample_modified"]);
|
|
QuestDbTables.Expiring("t1_").ShouldBe(["t1_sample", "t1_event", "t1_sample_modified"]);
|
|
QuestDbTables.CreateAll("t1_", 3).ShouldAllBe(s => s.Contains("t1_"));
|
|
}
|
|
|
|
[Fact]
|
|
public void Sample_modified_ddl_is_pinned_with_its_applied_marker_and_dedup_key_and_carries_the_ttl()
|
|
{
|
|
QuestDbTables.CreateSampleModified("hist_", null).ShouldBe(
|
|
"CREATE TABLE IF NOT EXISTS hist_sample_modified (ts TIMESTAMP, tag SYMBOL INDEX, modified_id LONG, " +
|
|
"modification_ts TIMESTAMP, update_type BYTE, principal VARCHAR, api_key_id VARCHAR, kind BYTE, num_value DOUBLE, " +
|
|
"int_value LONG, str_value VARCHAR, status LONG, server_ts TIMESTAMP, pending BOOLEAN) TIMESTAMP(ts) PARTITION BY DAY " +
|
|
"WAL DEDUP UPSERT KEYS(ts, tag, modified_id)");
|
|
QuestDbTables.CreateSampleModified("hist_", 30).ShouldEndWith("PARTITION BY DAY TTL 30 DAYS WAL DEDUP UPSERT KEYS(ts, tag, modified_id)");
|
|
QuestDbTables.AddTrailPendingColumn("hist_sample_modified").ShouldBe("ALTER TABLE hist_sample_modified ADD COLUMN pending BOOLEAN");
|
|
QuestDbTables.EnableTrailDedup("hist_sample_modified")
|
|
.ShouldBe("ALTER TABLE hist_sample_modified DEDUP ENABLE UPSERT KEYS(ts, tag, modified_id)");
|
|
}
|
|
|
|
[Fact]
|
|
public void An_invalid_prefix_is_refused() =>
|
|
Should.Throw<ArgumentException>(() => QuestDbTables.CreateSample("a b", null));
|
|
|
|
[Fact]
|
|
public void Alter_ttl_is_pinned_and_null_removes_it()
|
|
{
|
|
QuestDbTables.AlterTtl("hist_sample", 30).ShouldBe("ALTER TABLE hist_sample SET TTL 30 DAYS");
|
|
QuestDbTables.AlterTtl("hist_sample", null).ShouldBe("ALTER TABLE hist_sample SET TTL 0 DAYS");
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(1, "WEEK", 168)] // 7 days reads back as 1 WEEK
|
|
[InlineData(2, "WEEK", 336)]
|
|
[InlineData(30, "DAY", 720)]
|
|
[InlineData(0, "HOUR", 0)] // no TTL
|
|
[InlineData(5, "hour", 5)]
|
|
[InlineData(1, "MONTH", -1)] // unknown unit never equals a wanted value
|
|
public void Ttl_is_compared_in_hours(long value, string unit, long hours) =>
|
|
QuestDbTables.TtlHours(value, unit).ShouldBe(hours);
|
|
}
|