|
|
|
@@ -0,0 +1,151 @@
|
|
|
|
|
using System.Globalization;
|
|
|
|
|
using Microsoft.Data.Sqlite;
|
|
|
|
|
using ZB.MOM.WW.LocalDb.Internal;
|
|
|
|
|
using ZB.MOM.WW.LocalDb.Registration;
|
|
|
|
|
|
|
|
|
|
namespace ZB.MOM.WW.LocalDb.Replication.Internal;
|
|
|
|
|
|
|
|
|
|
/// <summary>Outcome of applying one inbound batch. <see cref="AppliedThruSeq"/> is the batch's highest seq regardless of per-entry disposition, so the watermark advances even over discarded/dead-lettered entries.</summary>
|
|
|
|
|
internal sealed record ApplyResult(long AppliedThruSeq, int Applied, int Discarded, int DeadLettered);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Applies a peer's oplog batch under last-writer-wins in one transaction: the applying guard is
|
|
|
|
|
/// raised so capture triggers skip these writes, per-entry LWW resolves against the local
|
|
|
|
|
/// row-version (hlc, then node_id ordinal tie-break), poison rows are dead-lettered while the
|
|
|
|
|
/// batch continues, and the remote watermark advances in the SAME transaction as the data so a
|
|
|
|
|
/// crash-replay of the batch is a no-op (LWW's strict-greater compare loses on equal versions).
|
|
|
|
|
/// This never writes <c>__localdb_oplog</c>: the 2-node topology does not re-forward applied deltas.
|
|
|
|
|
/// </summary>
|
|
|
|
|
internal sealed class LwwApplier(SqliteLocalDb db)
|
|
|
|
|
{
|
|
|
|
|
public async Task<ApplyResult> ApplyBatchAsync(IReadOnlyList<OplogEntryRecord> entries, CancellationToken ct)
|
|
|
|
|
{
|
|
|
|
|
if (entries.Count == 0)
|
|
|
|
|
return new ApplyResult(0, 0, 0, 0);
|
|
|
|
|
|
|
|
|
|
// Validated before opening the transaction: an unknown table is a schema disagreement
|
|
|
|
|
// between the peers (a protocol violation), not a per-row poison — reject the whole batch
|
|
|
|
|
// so nothing is half-applied.
|
|
|
|
|
foreach (var entry in entries)
|
|
|
|
|
if (!db.ReplicatedTables.ContainsKey(entry.TableName))
|
|
|
|
|
throw new LocalDbSchemaMismatchException(
|
|
|
|
|
$"Received an oplog entry for table '{entry.TableName}', which is not registered for replication on this node.");
|
|
|
|
|
|
|
|
|
|
var utcNow = DateTimeOffset.UtcNow.UtcDateTime.ToString(OplogStore.TombstoneUtcFormat, CultureInfo.InvariantCulture);
|
|
|
|
|
var maxSeq = 0L;
|
|
|
|
|
var maxHlc = 0L;
|
|
|
|
|
foreach (var entry in entries)
|
|
|
|
|
{
|
|
|
|
|
if (entry.Seq > maxSeq) maxSeq = entry.Seq;
|
|
|
|
|
if (entry.Hlc > maxHlc) maxHlc = entry.Hlc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var sqlByTable = new Dictionary<string, (string Delete, string Upsert)>(StringComparer.Ordinal);
|
|
|
|
|
var applied = 0;
|
|
|
|
|
var discarded = 0;
|
|
|
|
|
var deadLettered = 0;
|
|
|
|
|
|
|
|
|
|
await using var tx = await db.BeginTransactionAsync(ct);
|
|
|
|
|
await tx.ExecuteAsync("UPDATE __localdb_applying SET applying = 1 WHERE id = 1", null, ct);
|
|
|
|
|
|
|
|
|
|
foreach (var entry in entries)
|
|
|
|
|
{
|
|
|
|
|
var local = await tx.QueryAsync(
|
|
|
|
|
"SELECT hlc, node_id FROM __localdb_row_version WHERE table_name = @t AND pk_json = @pk",
|
|
|
|
|
static r => (Hlc: r.GetInt64(0), NodeId: r.GetString(1)),
|
|
|
|
|
new { t = entry.TableName, pk = entry.PkJson }, ct);
|
|
|
|
|
|
|
|
|
|
var remoteWins = local.Count == 0
|
|
|
|
|
|| entry.Hlc > local[0].Hlc
|
|
|
|
|
|| (entry.Hlc == local[0].Hlc && string.CompareOrdinal(entry.NodeId, local[0].NodeId) > 0);
|
|
|
|
|
|
|
|
|
|
if (!remoteWins)
|
|
|
|
|
{
|
|
|
|
|
discarded++;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!sqlByTable.TryGetValue(entry.TableName, out var sql))
|
|
|
|
|
{
|
|
|
|
|
sql = BuildTableSql(db.ReplicatedTables[entry.TableName]);
|
|
|
|
|
sqlByTable[entry.TableName] = sql;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try
|
|
|
|
|
{
|
|
|
|
|
if (entry.IsTombstone)
|
|
|
|
|
await tx.ExecuteAsync(sql.Delete, new { pk = entry.PkJson }, ct);
|
|
|
|
|
else
|
|
|
|
|
await tx.ExecuteAsync(sql.Upsert, new { row = entry.RowJson }, ct);
|
|
|
|
|
|
|
|
|
|
await tx.ExecuteAsync(
|
|
|
|
|
"INSERT OR REPLACE INTO __localdb_row_version " +
|
|
|
|
|
"(table_name, pk_json, hlc, node_id, is_tombstone, tombstone_utc) " +
|
|
|
|
|
"VALUES (@t, @pk, @hlc, @node, @tomb, @tut)",
|
|
|
|
|
new
|
|
|
|
|
{
|
|
|
|
|
t = entry.TableName,
|
|
|
|
|
pk = entry.PkJson,
|
|
|
|
|
hlc = entry.Hlc,
|
|
|
|
|
node = entry.NodeId,
|
|
|
|
|
tomb = entry.IsTombstone ? 1 : 0,
|
|
|
|
|
tut = entry.IsTombstone ? utcNow : null,
|
|
|
|
|
}, ct);
|
|
|
|
|
applied++;
|
|
|
|
|
}
|
|
|
|
|
catch (SqliteException ex)
|
|
|
|
|
{
|
|
|
|
|
// A statement-level SQLite error (e.g. json_extract on malformed row_json) aborts only
|
|
|
|
|
// that statement, not the transaction, so the batch continues after dead-lettering.
|
|
|
|
|
await tx.ExecuteAsync(
|
|
|
|
|
"INSERT INTO __localdb_dead_letter " +
|
|
|
|
|
"(received_utc, table_name, pk_json, row_json, hlc, node_id, error) " +
|
|
|
|
|
"VALUES (@utc, @t, @pk, @row, @hlc, @node, @err)",
|
|
|
|
|
new
|
|
|
|
|
{
|
|
|
|
|
utc = utcNow,
|
|
|
|
|
t = entry.TableName,
|
|
|
|
|
pk = entry.PkJson,
|
|
|
|
|
row = entry.RowJson,
|
|
|
|
|
hlc = entry.Hlc,
|
|
|
|
|
node = entry.NodeId,
|
|
|
|
|
err = ex.Message,
|
|
|
|
|
}, ct);
|
|
|
|
|
deadLettered++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await tx.ExecuteAsync(
|
|
|
|
|
"UPDATE __localdb_peer_state SET " +
|
|
|
|
|
"last_applied_remote_seq = MAX(last_applied_remote_seq, @maxSeq), " +
|
|
|
|
|
"last_seen_hlc = MAX(last_seen_hlc, @maxHlc), last_sync_utc = @utc WHERE id = 1",
|
|
|
|
|
new { maxSeq, maxHlc, utc = utcNow }, ct);
|
|
|
|
|
|
|
|
|
|
await tx.ExecuteAsync("UPDATE __localdb_applying SET applying = 0 WHERE id = 1", null, ct);
|
|
|
|
|
await tx.CommitAsync(ct);
|
|
|
|
|
|
|
|
|
|
db.Clock.Observe(maxHlc);
|
|
|
|
|
return new ApplyResult(maxSeq, applied, discarded, deadLettered);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static (string Delete, string Upsert) BuildTableSql(ReplicatedTable table)
|
|
|
|
|
{
|
|
|
|
|
var name = Ident(table.Name);
|
|
|
|
|
|
|
|
|
|
var pkMatch = string.Join(" AND ", table.PkColumns.Select(p => $"{Ident(p)} = json_extract(@pk, {JsonPath(p)})"));
|
|
|
|
|
var delete = $"DELETE FROM {name} WHERE {pkMatch}";
|
|
|
|
|
|
|
|
|
|
var columns = string.Join(", ", table.Columns.Select(c => Ident(c.Name)));
|
|
|
|
|
var values = string.Join(", ", table.Columns.Select(c => $"json_extract(@row, {JsonPath(c.Name)})"));
|
|
|
|
|
var upsert = $"INSERT OR REPLACE INTO {name} ({columns}) VALUES ({values})";
|
|
|
|
|
|
|
|
|
|
return (delete, upsert);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Local copy of TriggerSqlGenerator's convention (that helper lives in the core assembly).
|
|
|
|
|
private static string Ident(string name) => "\"" + name.Replace("\"", "\"\"") + "\"";
|
|
|
|
|
|
|
|
|
|
// A single-quoted JSON path with a quoted key member, so a column name with reserved characters
|
|
|
|
|
// still resolves: $."col". The key's double quotes are backslash-escaped per JSON path grammar.
|
|
|
|
|
private static string JsonPath(string column) => "'$.\"" + column.Replace("\"", "\\\"") + "\"'";
|
|
|
|
|
}
|