perf(historian): index QuestDB event.source and sample_modified.tag (lmxopcua#746)

Event reads filtered on source and every modified-value read filtered on tag
scanned that symbol column in every partition of the window; only
sample.tag carried an index (#683).

Both columns are now SYMBOL INDEX in the DDL, and the bootstrapper's index
migration is generalised over QuestDbTables.SymbolIndexes (sample.tag,
event.source, sample_modified.tag): a missing index is added with the same
idempotent ALTER ... ADD INDEX, Warning-only when AutoCreateSchema is false
or the ALTER is refused. Integration tests: new tables carry all three; a
pre-index table set gains them on boot and event reads answer the same.

This reverses #683's documented choice to leave the two unindexed;
docs/Historian.md carries the reasoning.

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
This commit is contained in:
Joseph Doherty
2026-10-22 17:00:54 -04:00
parent d44e58dcd6
commit bc2f5e1889
6 changed files with 134 additions and 32 deletions
@@ -16,8 +16,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb;
/// <item><b>TTL (RC3):</b> <c>tables()</c> normalises units (7 days reads back as <c>1 WEEK</c>) so values are compared
/// in hours, and an ALTER becomes visible only after the WAL applies it, so the bootstrapper never re-reads to verify
/// its own ALTER. A boot whose TTL already matches issues no ALTER.</item>
/// <item><b>Tag index:</b> a sample table created before the <c>tag</c> symbol index existed gains it with
/// <see cref="QuestDbTables.AddTagIndex"/> (idempotent; a boot that finds it indexed issues no ALTER, and like the TTL
/// <item><b>Symbol indexes:</b> a table created before its symbol index existed (the sample and modification-trail
/// <c>tag</c>, the event <c>source</c> — <see cref="QuestDbTables.SymbolIndexes"/>) gains it with
/// <see cref="QuestDbTables.AddIndex"/> (idempotent; a boot that finds it indexed issues no ALTER, and like the TTL
/// the ALTER is a WAL transaction the bootstrapper does not re-read). Building it is a one-off WAL apply — ~3 s for
/// 68M rows / 63 daily partitions measured — during which writes queue and reads keep scanning. A refused ALTER is
/// logged as a Warning, never fails the bootstrap (the index is optional); a failure at WAL-apply time would suspend
@@ -71,12 +72,12 @@ internal sealed class QuestDbSchemaBootstrapper
{
await CreateTablesAsync(conn, ct).ConfigureAwait(false);
await ReconcileTtlAsync(conn, ct).ConfigureAwait(false);
await EnsureTagIndexAsync(conn, apply: true, ct).ConfigureAwait(false);
await EnsureIndexesAsync(conn, apply: true, ct).ConfigureAwait(false);
}
else
{
await VerifyAsync(conn, ct).ConfigureAwait(false);
await EnsureTagIndexAsync(conn, apply: false, ct).ConfigureAwait(false);
await EnsureIndexesAsync(conn, apply: false, ct).ConfigureAwait(false);
}
_ready = true;
_failure.Clear();
@@ -127,23 +128,30 @@ internal sealed class QuestDbSchemaBootstrapper
}
}
/// <summary>
/// Indexes the sample table's <c>tag</c> column when it is not (a table created before the index existed).
/// <paramref name="apply"/> false (AutoCreateSchema off) only logs the statement to run.
/// </summary>
private async Task EnsureTagIndexAsync(NpgsqlConnection conn, bool apply, CancellationToken ct)
/// <summary>Ensures every <see cref="QuestDbTables.SymbolIndexes"/> column is indexed (see <see cref="EnsureIndexAsync"/>).</summary>
private async Task EnsureIndexesAsync(NpgsqlConnection conn, bool apply, CancellationToken ct)
{
var table = _conn.SampleTable;
await using (var cmd = _conn.CreateCommand(conn, QuestDbTables.TagIndexedQuery(table)))
foreach (var (table, column) in QuestDbTables.SymbolIndexes(_conn.TablePrefix))
await EnsureIndexAsync(conn, table, column, apply, ct).ConfigureAwait(false);
}
/// <summary>
/// Indexes <paramref name="table"/>'s symbol <paramref name="column"/> when it is not (a table created before the
/// index existed). <paramref name="apply"/> false (AutoCreateSchema off) only logs the statement to run.
/// </summary>
private async Task EnsureIndexAsync(NpgsqlConnection conn, string table, string column, bool apply, CancellationToken ct)
{
var statement = QuestDbTables.AddIndex(table, column);
await using (var cmd = _conn.CreateCommand(conn, QuestDbTables.IndexedQuery(table, column)))
{
var indexed = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
if (indexed is null or DBNull || Convert.ToBoolean(indexed, CultureInfo.InvariantCulture)) return;
}
if (!apply)
{
_log.LogWarning("QuestDB historian table {Table} has no index on its tag column, so the data-range lookups of " +
"processed reads scan partitions instead of seeking (#683). AutoCreateSchema is false; run: {Statement}",
table, QuestDbTables.AddTagIndex(table));
_log.LogWarning("QuestDB historian table {Table} has no index on its {Column} column, so reads filtering on it " +
"scan partitions instead of seeking. AutoCreateSchema is false; run: {Statement}",
table, column, statement);
return;
}
// A WAL transaction: table_columns() keeps reporting false until it applies, so do NOT re-read to verify.
@@ -152,17 +160,17 @@ internal sealed class QuestDbSchemaBootstrapper
// suspend the table) — the same blind spot as the TTL ALTER.
try
{
await using var alter = _conn.CreateCommand(conn, QuestDbTables.AddTagIndex(table));
await using var alter = _conn.CreateCommand(conn, statement);
await alter.ExecuteNonQueryAsync(ct).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException || !ct.IsCancellationRequested)
{
_log.LogWarning("Could not add the tag index to QuestDB table {Table} ({Exception}: {Message}); data-range lookups " +
"of processed reads scan partitions instead of seeking (#683). Run: {Statement}",
table, ex.GetType().Name, ex.Message, QuestDbTables.AddTagIndex(table));
_log.LogWarning("Could not add the {Column} index to QuestDB table {Table} ({Exception}: {Message}); reads " +
"filtering on it scan partitions instead of seeking. Run: {Statement}",
column, table, ex.GetType().Name, ex.Message, statement);
return;
}
_log.LogInformation("Added the tag index to QuestDB table {Table} (#683).", table);
_log.LogInformation("Added the {Column} index to QuestDB table {Table}.", column, table);
}
private async Task ReconcileTtlAsync(NpgsqlConnection conn, CancellationToken ct)
@@ -66,12 +66,16 @@ internal static class QuestDbTables
"int_value LONG, str_value VARCHAR, status LONG, deleted BOOLEAN, server_ts TIMESTAMP) " +
$"TIMESTAMP(ts) PARTITION BY DAY{Ttl(retentionDays)} WAL DEDUP UPSERT KEYS(ts, tag)";
/// <summary><c>CREATE TABLE IF NOT EXISTS</c> for the event table.</summary>
/// <summary>
/// <c>CREATE TABLE IF NOT EXISTS</c> for the event table. <c>source</c> carries a symbol index: a HistoryReadEvents
/// of one notifier filters on it, and unindexed that filter scans every partition in the window. A table created
/// before the index existed gains it through <see cref="SymbolIndexes"/>.
/// </summary>
/// <param name="prefix">The configured table-name prefix.</param>
/// <param name="retentionDays">Retention in days, or null for no TTL.</param>
/// <returns>The DDL statement.</returns>
public static string CreateEvent(string prefix, int? retentionDays) =>
$"CREATE TABLE IF NOT EXISTS {Event(prefix)} (ts TIMESTAMP, event_id VARCHAR, source SYMBOL, alarm_id VARCHAR, " +
$"CREATE TABLE IF NOT EXISTS {Event(prefix)} (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{Ttl(retentionDays)} WAL DEDUP UPSERT KEYS(ts, event_id)";
@@ -87,13 +91,14 @@ internal static class QuestDbTables
/// <c>CREATE TABLE IF NOT EXISTS</c> for the Part 11 modification trail: one row per sample a HistoryUpdate
/// inserted, replaced or deleted, carrying the PRIOR value (NULL kind/value/status for an insert). <c>ts</c> is the
/// sample's source time (so the TTL ages it out with the samples); <c>modified_id</c> orders the trail within one
/// source time. Append-only: no DEDUP, never updated or deleted.
/// source time. Append-only: no DEDUP, never updated or deleted. <c>tag</c> carries a symbol index (every modified
/// read filters on one tag; see <see cref="SymbolIndexes"/> for the migration of an older table).
/// </summary>
/// <param name="prefix">The configured table-name prefix.</param>
/// <param name="retentionDays">Retention in days, or null for no TTL.</param>
/// <returns>The DDL statement.</returns>
public static string CreateSampleModified(string prefix, int? retentionDays) =>
$"CREATE TABLE IF NOT EXISTS {SampleModified(prefix)} (ts TIMESTAMP, tag SYMBOL, modified_id LONG, " +
$"CREATE TABLE IF NOT EXISTS {SampleModified(prefix)} (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) " +
$"TIMESTAMP(ts) PARTITION BY DAY{Ttl(retentionDays)} WAL";
@@ -106,6 +111,29 @@ internal static class QuestDbTables
[CreateSample(prefix, retentionDays), CreateEvent(prefix, retentionDays), CreateTag(prefix),
CreateSampleModified(prefix, retentionDays)];
/// <summary>
/// The symbol columns that carry an index, as <c>(table, column)</c>: the sample table's <c>tag</c> (#683), the event
/// table's <c>source</c> and the modification trail's <c>tag</c>. The bootstrapper adds any that a table created before
/// the index existed lacks.
/// </summary>
/// <param name="prefix">The configured table-name prefix.</param>
/// <returns>The indexed columns.</returns>
public static (string Table, string Column)[] SymbolIndexes(string prefix) =>
[(Sample(prefix), "tag"), (Event(prefix), "source"), (SampleModified(prefix), "tag")];
/// <summary><c>ALTER TABLE t ALTER COLUMN c ADD INDEX</c> (idempotent on an indexed column, a WAL transaction).</summary>
/// <param name="table">The table name.</param>
/// <param name="column">The symbol column.</param>
/// <returns>The ALTER statement.</returns>
public static string AddIndex(string table, string column) => $"ALTER TABLE {table} ALTER COLUMN {column} ADD INDEX";
/// <summary>Whether <paramref name="table"/>'s <paramref name="column"/> is indexed (one BOOLEAN row; none when missing).</summary>
/// <param name="table">The table name (prefix-validated, inlined — <c>table_columns()</c> takes a constant).</param>
/// <param name="column">The column name (a constant from <see cref="SymbolIndexes"/>).</param>
/// <returns>The query text.</returns>
public static string IndexedQuery(string table, string column) =>
$"SELECT indexed FROM table_columns('{table}') WHERE \"column\" = '{column}'";
/// <summary>
/// <c>ALTER TABLE t ALTER COLUMN tag ADD INDEX</c> — the migration of a sample table created without the tag
/// index. A WAL transaction (visible once applied); re-issuing it on an indexed column is a harmless no-op (the table