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:
+27
-19
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user