142 lines
6.7 KiB
C#
142 lines
6.7 KiB
C#
using Microsoft.Data.Sqlite;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward;
|
|
|
|
/// <summary>
|
|
/// DDL for the <c>sf_messages</c> table, extracted from
|
|
/// <see cref="StoreAndForwardStorage"/> so it can be applied by whoever owns the
|
|
/// database file.
|
|
/// <para>
|
|
/// Deliberately depends only on <c>Microsoft.Data.Sqlite</c>, not on the LocalDb library.
|
|
/// The Host applies this DDL to a LocalDb-managed connection before
|
|
/// <c>RegisterReplicated</c> installs the capture triggers; nothing about the schema
|
|
/// itself is LocalDb-specific, and the store still calls it so a directly-constructed
|
|
/// store (tests, tooling) remains self-sufficient.
|
|
/// </para>
|
|
/// </summary>
|
|
public static class StoreAndForwardSchema
|
|
{
|
|
/// <summary>
|
|
/// Creates the <c>sf_messages</c> table and its indexes when absent, and additively
|
|
/// upgrades a table created by an older build. Idempotent — safe to run on every
|
|
/// startup.
|
|
/// </summary>
|
|
/// <param name="connection">An open connection to the database that should hold the table.</param>
|
|
public static void Apply(SqliteConnection connection)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(connection);
|
|
|
|
using (var command = connection.CreateCommand())
|
|
{
|
|
command.CommandText = @"
|
|
CREATE TABLE IF NOT EXISTS sf_messages (
|
|
id TEXT PRIMARY KEY,
|
|
category INTEGER NOT NULL,
|
|
target TEXT NOT NULL,
|
|
payload_json TEXT NOT NULL,
|
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
max_retries INTEGER NOT NULL DEFAULT 50,
|
|
retry_interval_ms INTEGER NOT NULL DEFAULT 30000,
|
|
created_at TEXT NOT NULL,
|
|
last_attempt_at TEXT,
|
|
status INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT,
|
|
origin_instance TEXT
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_sf_messages_status ON sf_messages(status);
|
|
CREATE INDEX IF NOT EXISTS idx_sf_messages_category ON sf_messages(category);
|
|
";
|
|
command.ExecuteNonQuery();
|
|
}
|
|
|
|
// Additively add the execution_id /
|
|
// source_script columns. CREATE TABLE IF NOT EXISTS above does NOT add
|
|
// columns to a table that already exists from before these fields, so a
|
|
// databases created by an older build needs the columns ALTER-ed in.
|
|
// SQLite has no "ADD COLUMN IF NOT EXISTS"; the column presence is
|
|
// probed first and the ALTER skipped when already there. Both columns
|
|
// are nullable with no default, so any row buffered before this
|
|
// migration reads back ExecutionId/SourceScript = null (back-compat).
|
|
AddColumnIfMissing(connection, "execution_id", "TEXT");
|
|
AddColumnIfMissing(connection, "source_script", "TEXT");
|
|
|
|
// Additively add the
|
|
// parent_execution_id column the same way — a sibling to execution_id.
|
|
// Nullable with no default, so any row buffered before this migration
|
|
// reads back ParentExecutionId = null (back-compat).
|
|
AddColumnIfMissing(connection, "parent_execution_id", "TEXT");
|
|
|
|
// Additively add the epoch-ms sibling of last_attempt_at. The
|
|
// ISO-8601 text column stays authoritative for reads / back-compat; this
|
|
// INTEGER column drives the due predicate so the sweep no longer parses
|
|
// julianday() per row.
|
|
AddColumnIfMissing(connection, "last_attempt_at_ms", "INTEGER");
|
|
|
|
// One-time backfill for rows persisted before the ms column existed: derive
|
|
// epoch-ms from the text timestamp. The "... IS NULL" guard makes this run
|
|
// once per legacy DB and never match again — this is the only remaining
|
|
// julianday() use in the store.
|
|
using (var backfill = connection.CreateCommand())
|
|
{
|
|
backfill.CommandText = @"
|
|
UPDATE sf_messages
|
|
SET last_attempt_at_ms = CAST((julianday(last_attempt_at) - 2440587.5) * 86400000 AS INTEGER)
|
|
WHERE last_attempt_at IS NOT NULL AND last_attempt_at_ms IS NULL";
|
|
backfill.ExecuteNonQuery();
|
|
}
|
|
|
|
// Covering index for the due query (status filter + ms ordering column).
|
|
// Created after the ALTER above so the column exists.
|
|
using (var dueIndex = connection.CreateCommand())
|
|
{
|
|
dueIndex.CommandText =
|
|
"CREATE INDEX IF NOT EXISTS idx_sf_messages_status_due ON sf_messages(status, last_attempt_at_ms)";
|
|
dueIndex.ExecuteNonQuery();
|
|
}
|
|
|
|
// Covering index for GetMessagesForRetryAsync's ORDER BY created_at ASC.
|
|
// idx_sf_messages_status_due (above) matches the status filter and the
|
|
// last_attempt_at_ms half of the due predicate, but its column order does
|
|
// NOT match the query's "ORDER BY created_at ASC", so SQLite still needs a
|
|
// sort/scan step to satisfy that ordering on a large due-sweep. This index's
|
|
// leading (status, created_at) column order lets the planner walk matching
|
|
// rows already in created_at order and stop at LIMIT without touching
|
|
// non-pending rows or re-sorting (arch-review WP1.4). idx_sf_messages_status_due
|
|
// is left in place — it still backs status+due-time lookups that don't order by
|
|
// created_at.
|
|
using (var orderIndex = connection.CreateCommand())
|
|
{
|
|
orderIndex.CommandText =
|
|
"CREATE INDEX IF NOT EXISTS idx_sf_messages_status_created ON sf_messages(status, created_at)";
|
|
orderIndex.ExecuteNonQuery();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a column to <c>sf_messages</c>
|
|
/// only when it is not already present. SQLite lacks <c>ADD COLUMN IF NOT
|
|
/// EXISTS</c>, so the schema is probed via <c>PRAGMA table_info</c> first.
|
|
/// Idempotent — safe to run on every <see cref="Apply"/>.
|
|
/// </summary>
|
|
private static void AddColumnIfMissing(
|
|
SqliteConnection connection, string columnName, string columnType)
|
|
{
|
|
using (var probe = connection.CreateCommand())
|
|
{
|
|
probe.CommandText = "SELECT COUNT(*) FROM pragma_table_info('sf_messages') WHERE name = @name";
|
|
probe.Parameters.AddWithValue("@name", columnName);
|
|
if (Convert.ToInt32(probe.ExecuteScalar()) > 0)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
using var alter = connection.CreateCommand();
|
|
// Column name + type are caller-controlled constants, never user input —
|
|
// safe to interpolate (parameters are not permitted in DDL).
|
|
alter.CommandText = $"ALTER TABLE sf_messages ADD COLUMN {columnName} {columnType}";
|
|
alter.ExecuteNonQuery();
|
|
}
|
|
}
|