using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Npgsql; using NpgsqlTypes; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common; namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb; /// /// OPC UA HistoryUpdate against the QuestDB sample table (spec §2.3). Every operation is an INSERT: the /// table's DEDUP UPSERT KEYS(ts, tag) performs the replace, and a delete writes a tombstone /// (deleted = true, NULL values, BadNoData). QuestDB UPDATE/DELETE are never issued. /// Every status comes from , so codes equal the SQL Server backend's. /// "Exists" means present and not deleted, so Insert over a tombstone succeeds (GoodEntryInserted) while /// Replace and DeleteAtTime treat a tombstone as no entry. Existence is read with one range query and filtered /// client-side (never an IN list). /// Concurrency: calls are serialized per tag inside this instance (64 striped async locks held from /// the existence read through the visibility wait), so concurrent Insert/Replace/Delete on one tag cannot /// both act on a stale read. This does NOT cover the live writer racing on the same key, nor a second node; /// that residual is accepted. /// Pre-1970 (RC1): QuestDB cannot store such a designated timestamp. Insert/Replace/Update of such an /// item reports BadOutOfRange and sends nothing (a null/MinValue timestamp is still /// BadInvalidTimestamp); DeleteAtTime reports BadNoEntryExists; a DeleteRaw window may start before /// 1970 (read bounds are not clamped). /// Read-your-writes: after writing, waits for the rows to become /// visible, bounded by VisibilityTimeout; a timeout only logs a Warning, never changes a code. /// Modified-value history (Part 11, #668). Every sample this updater inserts, replaces or tombstones first /// gets a row in {prefix}sample_modified carrying its PRIOR live value (the existence read already fetches it; /// none for an insert — a tombstone is "absent", so an insert over one records no prior value), the caller's /// , the and the modification time. QuestDB has /// no multi-table transaction, so the trail row is written BEFORE the change: a crash (or a failed sample INSERT) /// between the two leaves an orphan trail row describing a change that never landed — never a change without its /// trail row. This is the documented deviation from SQL Server, whose trail commits atomically with the change. /// internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater { /// Rows per INSERT and per DeleteRaw page (T0 Q2: 9,000 parameters, far under the 32,767 limit). internal const int BatchSize = 1000; private const long ResolutionTicks = 10; private const int LockStripes = 64; private readonly SemaphoreSlim[] _locks = Enumerable.Range(0, LockStripes).Select(_ => new SemaphoreSlim(1, 1)).ToArray(); private long _liveRowsRead; private static long _lastModifiedId; private readonly QuestDbConnection _conn; private readonly QuestDbTagCatalog _tags; private readonly QuestDbWalVisibility _wal; private readonly ILogger _log; /// Creates the updater. /// Connection helper. /// Tag catalog (R7: the only known-tag authority). /// WAL visibility wait (R5). /// Optional logger. public QuestDbHistorianDataUpdater(QuestDbConnection conn, QuestDbTagCatalog tags, QuestDbWalVisibility wal, ILogger? log = null) { _conn = conn; _tags = tags; _wal = wal; _log = log ?? NullLogger.Instance; } /// Rows returned by existence reads so far (test/diagnostic counter). internal long LiveRowsRead => Interlocked.Read(ref _liveRowsRead); private async Task LockAsync(string tag, CancellationToken ct) { var sem = _locks[(int)((uint)StringComparer.Ordinal.GetHashCode(tag) % LockStripes)]; await sem.WaitAsync(ct).ConfigureAwait(false); return new Releaser(sem); } private sealed class Releaser(SemaphoreSlim sem) : IDisposable { public void Dispose() => sem.Release(); } /// public async Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(context); // RC1: a valid-timestamp item before 1970 can never be stored; refuse it and plan the rest. var final = new uint[values.Count]; var kept = new List(values.Count); for (var i = 0; i < values.Count; i++) { if (values[i].TimestampUtc is { } ts && ts != DateTime.MinValue && !QuestDbConnection.IsStorable(ts)) final[i] = HistorianStatusCodes.BadOutOfRange; else kept.Add(i); } var plan = HistoryUpdatePlanner.Plan(tag, mode, kept.Select(i => values[i]).ToList(), ResolutionTicks); IReadOnlyList planned; if (plan.Winners.Count == 0) { planned = HistoryUpdatePlanner.Complete(plan, mode, new Dictionary()); } else { using var _ = await LockAsync(tag, ct).ConfigureAwait(false); var known = mode == HistoryUpdateMode.Replace ? await _tags.ExistsAsync(tag, ct).ConfigureAwait(false) : await EnsureAsync(tag, plan.Winners[0].Row.Value.Kind, ct).ConfigureAwait(false); var outcome = known ? await ApplyAsync(tag, mode, plan.Winners.Select(w => w.Row).ToList(), context, ct).ConfigureAwait(false) : new Dictionary(); planned = HistoryUpdatePlanner.Complete(plan, mode, outcome); } for (var k = 0; k < kept.Count; k++) final[kept[k]] = planned[k]; return final; } /// public async Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(context); if (!HistorianTagNames.IsValid(tag)) return HistorianStatusCodes.BadInvalidArgument; if (!await _tags.ExistsAsync(tag, ct).ConfigureAwait(false)) return HistoryUpdatePlanner.DeleteRawResult(tagKnown: false, 0); using var _ = await LockAsync(tag, ct).ConfigureAwait(false); var cursor = QuestDbConnection.Round(startUtc); var end = QuestDbConnection.Round(endUtc); long total = 0; while (cursor < end) { var page = new List(BatchSize); var priors = new List(BatchSize); await using (var conn = await _conn.OpenAsync(ct).ConfigureAwait(false)) await using (var cmd = _conn.CreateCommand(conn, $"SELECT {LiveColumns} FROM {_conn.SampleTable} WHERE tag = $1 AND ts >= $2 AND ts < $3 AND NOT deleted ORDER BY ts LIMIT {BatchSize}")) { QuestDbConnection.BindText(cmd, tag); QuestDbConnection.BindTimestamp(cmd, cursor); QuestDbConnection.BindTimestamp(cmd, end); await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await rd.ReadAsync(ct).ConfigureAwait(false)) { var row = ReadLiveRow(rd); page.Add(row.TimeUtc); priors.Add(row); } } if (page.Count == 0) break; // Trail BEFORE the change (no multi-table transaction): a crash here leaves an orphan trail row, never a // tombstone without its trail row. await WriteTrailAsync(tag, HistoryModificationType.Delete, priors.Select(r => (r.TimeUtc, (LiveRow?)r)).ToList(), context, ct).ConfigureAwait(false); await WriteTombstonesAsync(tag, page, ct).ConfigureAwait(false); // The wait between pages is required: without it the next page could re-read rows just tombstoned. await _wal.WaitAsync(_conn.SampleTable, ct).ConfigureAwait(false); total += page.Count; if (page.Count < BatchSize) break; cursor = page[^1].AddTicks(ResolutionTicks); } return HistoryUpdatePlanner.DeleteRawResult(tagKnown: true, total); } /// public async Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct) { ArgumentNullException.ThrowIfNull(tag); ArgumentNullException.ThrowIfNull(timesUtc); ArgumentNullException.ThrowIfNull(context); if (!HistorianTagNames.IsValid(tag)) { var refused = new uint[timesUtc.Count]; Array.Fill(refused, HistorianStatusCodes.BadInvalidArgument); return refused; } // M-2: round BEFORE both the delete and the comparison, so a sub-µs request time matches the row it deleted. // MinValue stays as is: the planner turns it into BadInvalidTimestamp. var rounded = timesUtc.Select(t => t == DateTime.MinValue ? t : QuestDbConnection.Round(t)).ToList(); var candidates = new HashSet(); foreach (var t in rounded) if (t != DateTime.MinValue && t >= DateTime.UnixEpoch) candidates.Add(t.Ticks); // pre-1970 can hold no row var deleted = new HashSet(); if (candidates.Count == 0 || !await _tags.ExistsAsync(tag, ct).ConfigureAwait(false)) return HistoryUpdatePlanner.DeleteAtTimeResults(rounded, deleted, tagKnown: false); using var _ = await LockAsync(tag, ct).ConfigureAwait(false); var live = await ReadLiveAsync(tag, candidates.Select(t => new DateTime(t, DateTimeKind.Utc)).ToList(), ct).ConfigureAwait(false); var toDelete = candidates.Where(live.ContainsKey).OrderBy(x => x).ToList(); foreach (var chunk in toDelete.Chunk(BatchSize)) { // Trail BEFORE the change (see the class remarks). await WriteTrailAsync(tag, HistoryModificationType.Delete, chunk.Select(t => (live[t].TimeUtc, (LiveRow?)live[t])).ToList(), context, ct).ConfigureAwait(false); await WriteTombstonesAsync(tag, chunk.Select(t => new DateTime(t, DateTimeKind.Utc)).ToList(), ct).ConfigureAwait(false); } if (toDelete.Count > 0) await _wal.WaitAsync(_conn.SampleTable, ct).ConfigureAwait(false); deleted.UnionWith(toDelete); return HistoryUpdatePlanner.DeleteAtTimeResults(rounded, deleted, tagKnown: true); } private async Task EnsureAsync(string tag, HistorianValueKind kind, CancellationToken ct) { await _tags.EnsureAsync(tag, HistorianDataTypes.FromKind(kind), ct).ConfigureAwait(false); return true; } private async Task> ApplyAsync(string tag, HistoryUpdateMode mode, List rows, HistoryUpdateContext context, CancellationToken ct) { // Key = the planner's tick (what Complete looks up); the stored time is Round(of the same row). var live = await ReadLiveAsync(tag, rows.Select(r => QuestDbConnection.Round(r.TimeUtc)).ToList(), ct).ConfigureAwait(false); var outcome = new Dictionary(); var toWrite = new List(); var trail = new List<(DateTime, LiveRow?)>(); foreach (var r in rows) { var stored = QuestDbConnection.Round(r.TimeUtc); var present = live.TryGetValue(stored.Ticks, out var prior); if (mode == HistoryUpdateMode.Insert && present) continue; if (mode == HistoryUpdateMode.Replace && !present) continue; outcome[r.TimeUtc.Ticks] = present ? UpsertOutcome.Replaced : UpsertOutcome.Inserted; toWrite.Add(r); trail.Add((stored, present ? prior : null)); } // Trail BEFORE the change (see the class remarks). await WriteTrailAsync(tag, ToModificationType(mode), trail, context, ct).ConfigureAwait(false); foreach (var chunk in toWrite.Chunk(BatchSize)) await WriteRowsAsync(tag, chunk, ct).ConfigureAwait(false); if (toWrite.Count > 0) await _wal.WaitAsync(_conn.SampleTable, ct).ConfigureAwait(false); return outcome; } /// The live (present, not deleted) rows near the given times, keyed by stored ticks: one range query per cluster (I-2). private async Task> ReadLiveAsync(string tag, IReadOnlyList times, CancellationToken ct) { var live = new Dictionary(); await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); foreach (var (lo, hi) in QuestDbTimeClusters.Cluster(times, QuestDbTimeClusters.ExistenceGap)) { await using var cmd = _conn.CreateCommand(conn, $"SELECT {LiveColumns} FROM {_conn.SampleTable} WHERE tag = $1 AND ts >= $2 AND ts <= $3 AND NOT deleted"); QuestDbConnection.BindText(cmd, tag); QuestDbConnection.BindTimestamp(cmd, lo); QuestDbConnection.BindTimestamp(cmd, hi); await using var rd = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false); while (await rd.ReadAsync(ct).ConfigureAwait(false)) { var row = ReadLiveRow(rd); live[row.TimeUtc.Ticks] = row; Interlocked.Increment(ref _liveRowsRead); } } return live; } /// The columns of a live sample the trail needs (prior value + status + server time). private const string LiveColumns = "ts, kind, num_value, int_value, str_value, status, server_ts"; /// A live sample as it was before a modification. private readonly record struct LiveRow(DateTime TimeUtc, HistorianValue Value, uint Status, DateTime? ServerTimeUtc); private static LiveRow ReadLiveRow(NpgsqlDataReader rd) { var value = QuestDbHistorianDataSource.ToValue( QuestDbConnection.ReadKind(rd, 1), rd.IsDBNull(2) ? null : rd.GetDouble(2), rd.IsDBNull(3) ? null : rd.GetInt64(3), rd.IsDBNull(4) ? null : rd.GetString(4)); var status = rd.IsDBNull(5) ? HistorianStatusCodes.BadNoData : SampleColumnMapper.FromLongStatus(rd.GetInt64(5)); return new LiveRow(QuestDbConnection.ReadUtc(rd, 0), value, status, rd.IsDBNull(6) ? null : QuestDbConnection.ReadUtc(rd, 6)); } /// The Part 11 modification type recorded for an update mode. private static HistoryModificationType ToModificationType(HistoryUpdateMode mode) => mode switch { HistoryUpdateMode.Insert => HistoryModificationType.Insert, HistoryUpdateMode.Replace => HistoryModificationType.Replace, _ => HistoryModificationType.Update, }; /// /// A process-wide, strictly increasing modification id (≈ UTC ticks, bumped past the last issued value). It orders a /// tag's trail within one source time; ids from two nodes interleave by wall clock, which is the modification order. /// internal static long NextModifiedId() { while (true) { var last = Interlocked.Read(ref _lastModifiedId); var next = Math.Max(DateTime.UtcNow.Ticks, last + 1); if (Interlocked.CompareExchange(ref _lastModifiedId, next, last) == last) return next; } } /// /// Appends one modification-trail row per entry (prior = null ⇒ an insert: kind Empty, NULL value and /// status), then waits for the trail's WAL visibility so a modified read right after the update sees it. /// private async Task WriteTrailAsync(string tag, HistoryModificationType type, IReadOnlyList<(DateTime TimeUtc, LiveRow? Prior)> entries, HistoryUpdateContext context, CancellationToken ct) { if (entries.Count == 0) return; var now = DateTime.UtcNow; foreach (var chunk in entries.Chunk(BatchSize)) { await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var cmd = _conn.CreateCommand(conn, ""); var sb = new System.Text.StringBuilder( $"INSERT INTO {_conn.SampleModifiedTable} (ts, tag, modified_id, modification_ts, update_type, principal, " + "api_key_id, kind, num_value, int_value, str_value, status, server_ts) VALUES "); for (var i = 0; i < chunk.Length; i++) { var (time, prior) = chunk[i]; QuestDbConnection.BindTimestamp(cmd, time, clampToEpoch: true); QuestDbConnection.BindText(cmd, tag); QuestDbConnection.BindLong(cmd, NextModifiedId()); QuestDbConnection.BindTimestamp(cmd, now); QuestDbConnection.BindByte(cmd, (byte)type); QuestDbConnection.BindText(cmd, context.StoredPrincipal); QuestDbConnection.BindText(cmd, context.StoredApiKeyId); if (prior is { } p) { var c = SampleColumnMapper.ToColumns(p.Value); QuestDbConnection.BindByte(cmd, c.ValueKind); Typed(cmd, NpgsqlDbType.Double, c.NumValue); Typed(cmd, NpgsqlDbType.Bigint, c.IntValue); Typed(cmd, NpgsqlDbType.Varchar, c.StrValue); QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(p.Status)); Typed(cmd, NpgsqlDbType.TimestampTz, p.ServerTimeUtc); } else { // No prior value: QuestDB BYTE has no NULL, so kind is Empty (0) and the NULL status marks "none". QuestDbConnection.BindByte(cmd, (byte)HistorianValueKind.Empty); Typed(cmd, NpgsqlDbType.Double, null); Typed(cmd, NpgsqlDbType.Bigint, null); Typed(cmd, NpgsqlDbType.Varchar, null); Typed(cmd, NpgsqlDbType.Bigint, null); Typed(cmd, NpgsqlDbType.TimestampTz, null); } var b = i * 13; if (i > 0) sb.Append(','); sb.Append('('); for (var q = 1; q <= 13; q++) sb.Append(q > 1 ? "," : "").Append('$').Append(b + q); sb.Append(')'); } cmd.CommandText = sb.ToString(); try { await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { _log.LogWarning(ex, "QuestDB modification-trail INSERT of {Count} rows into {Table} failed; the update was not applied.", chunk.Length, _conn.SampleModifiedTable); throw; } } await _wal.WaitAsync(_conn.SampleModifiedTable, ct).ConfigureAwait(false); } private Task WriteRowsAsync(string tag, IReadOnlyList rows, CancellationToken ct) => InsertAsync(rows.Count, ct, (cmd, i) => { var row = rows[i]; var c = SampleColumnMapper.ToColumns(row.Value); QuestDbConnection.BindTimestamp(cmd, row.TimeUtc, clampToEpoch: true); QuestDbConnection.BindText(cmd, tag); QuestDbConnection.BindByte(cmd, c.ValueKind); Typed(cmd, NpgsqlDbType.Double, c.NumValue); Typed(cmd, NpgsqlDbType.Bigint, c.IntValue); Typed(cmd, NpgsqlDbType.Varchar, c.StrValue); QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(row.StatusCode)); Typed(cmd, NpgsqlDbType.Boolean, false); }); private Task WriteTombstonesAsync(string tag, IReadOnlyList times, CancellationToken ct) => InsertAsync(times.Count, ct, (cmd, i) => { QuestDbConnection.BindTimestamp(cmd, times[i], clampToEpoch: true); QuestDbConnection.BindText(cmd, tag); QuestDbConnection.BindByte(cmd, (byte)HistorianValueKind.Empty); Typed(cmd, NpgsqlDbType.Double, null); Typed(cmd, NpgsqlDbType.Bigint, null); Typed(cmd, NpgsqlDbType.Varchar, null); QuestDbConnection.BindLong(cmd, SampleColumnMapper.ToLongStatus(HistorianStatusCodes.BadNoData)); Typed(cmd, NpgsqlDbType.Boolean, true); }); /// Multi-row INSERT; adds the first eight parameters of a row, this adds server_ts. private async Task InsertAsync(int count, CancellationToken ct, Action bindRow) { if (count == 0) return; await using var conn = await _conn.OpenAsync(ct).ConfigureAwait(false); await using var cmd = _conn.CreateCommand(conn, ""); var now = DateTime.UtcNow; var sb = new System.Text.StringBuilder( $"INSERT INTO {_conn.SampleTable} (ts, tag, kind, num_value, int_value, str_value, status, deleted, server_ts) VALUES "); for (var i = 0; i < count; i++) { bindRow(cmd, i); QuestDbConnection.BindTimestamp(cmd, now); var b = i * 9; if (i > 0) sb.Append(','); sb.Append('('); for (var p = 1; p <= 9; p++) sb.Append(p > 1 ? "," : "").Append('$').Append(b + p); sb.Append(')'); } cmd.CommandText = sb.ToString(); try { await cmd.ExecuteNonQueryAsync(ct).ConfigureAwait(false); } catch (Exception ex) when (ex is not OperationCanceledException) { _log.LogWarning(ex, "QuestDB HistoryUpdate INSERT of {Count} rows into {Table} failed.", count, _conn.SampleTable); throw; } } private static void Typed(NpgsqlCommand cmd, NpgsqlDbType type, object? value) => cmd.Parameters.Add(new NpgsqlParameter { NpgsqlDbType = type, Value = value ?? DBNull.Value }); }