Harden Surreal migration with retry/coverage fixes and XML docs cleanup
All checks were successful
NuGet Package Publish / nuget (push) Successful in 1m17s

This commit is contained in:
Joseph Doherty
2026-02-22 05:39:00 -05:00
parent 9c2a77dc3c
commit bd10914828
27 changed files with 1402 additions and 19 deletions

View File

@@ -124,7 +124,9 @@ public class ConsoleInteractiveService : BackgroundService
var ts = DateTime.Now.ToString("HH:mm:ss.fff");
var user = new User
{
Id = Guid.NewGuid().ToString(), Name = $"User-{ts}", Age = new Random().Next(18, 90),
Id = Guid.NewGuid().ToString(),
Name = $"User-{ts}",
Age = new Random().Next(18, 90),
Address = new Address { City = "AutoCity" }
};
await _db.Users.InsertAsync(user);
@@ -138,7 +140,9 @@ public class ConsoleInteractiveService : BackgroundService
var ts = DateTime.Now.ToString("HH:mm:ss.fff");
var user = new User
{
Id = Guid.NewGuid().ToString(), Name = $"User-{ts}", Age = new Random().Next(18, 90),
Id = Guid.NewGuid().ToString(),
Name = $"User-{ts}",
Age = new Random().Next(18, 90),
Address = new Address { City = "SpamCity" }
};
await _db.Users.InsertAsync(user);
@@ -158,9 +162,9 @@ public class ConsoleInteractiveService : BackgroundService
else if (input.StartsWith("p"))
{
var alice = new User
{ Id = Guid.NewGuid().ToString(), Name = "Alice", Age = 30, Address = new Address { City = "Paris" } };
{ Id = Guid.NewGuid().ToString(), Name = "Alice", Age = 30, Address = new Address { City = "Paris" } };
var bob = new User
{ Id = Guid.NewGuid().ToString(), Name = "Bob", Age = 25, Address = new Address { City = "Rome" } };
{ Id = Guid.NewGuid().ToString(), Name = "Bob", Age = 25, Address = new Address { City = "Rome" } };
await _db.Users.InsertAsync(alice);
await _db.Users.InsertAsync(bob);
await _db.SaveChangesAsync();

View File

@@ -14,6 +14,11 @@ public class SampleDbContext : IDisposable
private readonly bool _ownsClient;
/// <summary>
/// Initializes a new instance of the <see cref="SampleDbContext"/> class.
/// </summary>
/// <param name="surrealEmbeddedClient">The embedded SurrealDB client.</param>
/// <param name="schemaInitializer">The schema initializer.</param>
public SampleDbContext(
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
ICBDDCSurrealSchemaInitializer schemaInitializer)
@@ -29,6 +34,10 @@ public class SampleDbContext : IDisposable
SchemaInitializer);
}
/// <summary>
/// Initializes a new instance of the <see cref="SampleDbContext"/> class.
/// </summary>
/// <param name="databasePath">The database path used for the embedded store.</param>
public SampleDbContext(string databasePath)
{
string normalizedPath = NormalizeDatabasePath(databasePath);
@@ -54,21 +63,41 @@ public class SampleDbContext : IDisposable
SchemaInitializer);
}
/// <summary>
/// Gets the embedded SurrealDB client.
/// </summary>
public ICBDDCSurrealEmbeddedClient SurrealEmbeddedClient { get; }
/// <summary>
/// Gets the schema initializer.
/// </summary>
public ICBDDCSurrealSchemaInitializer SchemaInitializer { get; private set; }
/// <summary>
/// Gets the users collection.
/// </summary>
public SampleSurrealCollection<User> Users { get; private set; }
/// <summary>
/// Gets the todo lists collection.
/// </summary>
public SampleSurrealCollection<TodoList> TodoLists { get; private set; }
/// <summary>
/// Gets the operation log entries collection.
/// </summary>
public SampleSurrealReadOnlyCollection<SampleOplogEntry> OplogEntries { get; private set; }
/// <summary>
/// Ensures schema changes are applied before persisting updates.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
{
await SchemaInitializer.EnsureInitializedAsync(cancellationToken);
}
/// <inheritdoc />
public void Dispose()
{
Users.Dispose();
@@ -101,11 +130,16 @@ public sealed class SampleSurrealSchemaInitializer : ICBDDCSurrealSchemaInitiali
private readonly ICBDDCSurrealEmbeddedClient _client;
private int _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="SampleSurrealSchemaInitializer"/> class.
/// </summary>
/// <param name="client">The embedded SurrealDB client.</param>
public SampleSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <inheritdoc />
public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
{
if (Volatile.Read(ref _initialized) == 1) return;
@@ -124,6 +158,13 @@ public sealed class SampleSurrealCollection<TEntity> : ISurrealWatchableCollecti
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
private readonly string _tableName;
/// <summary>
/// Initializes a new instance of the <see cref="SampleSurrealCollection{TEntity}"/> class.
/// </summary>
/// <param name="tableName">The backing table name.</param>
/// <param name="keySelector">The key selector for entities.</param>
/// <param name="surrealEmbeddedClient">The embedded SurrealDB client.</param>
/// <param name="schemaInitializer">The schema initializer.</param>
public SampleSurrealCollection(
string tableName,
Func<TEntity, string> keySelector,
@@ -139,21 +180,25 @@ public sealed class SampleSurrealCollection<TEntity> : ISurrealWatchableCollecti
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
}
/// <inheritdoc />
public IDisposable Subscribe(IObserver<SurrealCollectionChange<TEntity>> observer)
{
return _changeFeed.Subscribe(observer);
}
/// <inheritdoc />
public async Task InsertAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await UpsertAsync(entity, cancellationToken);
}
/// <inheritdoc />
public async Task UpdateAsync(TEntity entity, CancellationToken cancellationToken = default)
{
await UpsertAsync(entity, cancellationToken);
}
/// <inheritdoc />
public async Task DeleteAsync(string id, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(id))
@@ -164,11 +209,22 @@ public sealed class SampleSurrealCollection<TEntity> : ISurrealWatchableCollecti
_changeFeed.PublishDelete(id);
}
/// <summary>
/// Finds an entity by identifier.
/// </summary>
/// <param name="id">The entity identifier.</param>
/// <returns>The matching entity when found; otherwise <see langword="null"/>.</returns>
public TEntity? FindById(string id)
{
return FindByIdAsync(id).GetAwaiter().GetResult();
}
/// <summary>
/// Finds an entity by identifier asynchronously.
/// </summary>
/// <param name="id">The entity identifier.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The matching entity when found; otherwise <see langword="null"/>.</returns>
public async Task<TEntity?> FindByIdAsync(string id, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(id))
@@ -179,11 +235,13 @@ public sealed class SampleSurrealCollection<TEntity> : ISurrealWatchableCollecti
return record?.Entity;
}
/// <inheritdoc />
public IEnumerable<TEntity> FindAll()
{
return FindAllAsync().GetAwaiter().GetResult();
}
/// <inheritdoc />
public async Task<IReadOnlyList<TEntity>> FindAllAsync(CancellationToken cancellationToken = default)
{
await EnsureReadyAsync(cancellationToken);
@@ -195,12 +253,14 @@ public sealed class SampleSurrealCollection<TEntity> : ISurrealWatchableCollecti
?? [];
}
/// <inheritdoc />
public IEnumerable<TEntity> Find(Func<TEntity, bool> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
return FindAll().Where(predicate);
}
/// <inheritdoc />
public void Dispose()
{
_changeFeed.Dispose();
@@ -235,6 +295,12 @@ public sealed class SampleSurrealReadOnlyCollection<TEntity>
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
private readonly string _tableName;
/// <summary>
/// Initializes a new instance of the <see cref="SampleSurrealReadOnlyCollection{TEntity}"/> class.
/// </summary>
/// <param name="tableName">The backing table name.</param>
/// <param name="surrealEmbeddedClient">The embedded SurrealDB client.</param>
/// <param name="schemaInitializer">The schema initializer.</param>
public SampleSurrealReadOnlyCollection(
string tableName,
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
@@ -248,11 +314,20 @@ public sealed class SampleSurrealReadOnlyCollection<TEntity>
_schemaInitializer = schemaInitializer ?? throw new ArgumentNullException(nameof(schemaInitializer));
}
/// <summary>
/// Returns all entities from the collection.
/// </summary>
/// <returns>The entities in the collection.</returns>
public IEnumerable<TEntity> FindAll()
{
return FindAllAsync().GetAwaiter().GetResult();
}
/// <summary>
/// Returns all entities from the collection asynchronously.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>The entities in the collection.</returns>
public async Task<IReadOnlyList<TEntity>> FindAllAsync(CancellationToken cancellationToken = default)
{
await _schemaInitializer.EnsureInitializedAsync(cancellationToken);
@@ -260,6 +335,11 @@ public sealed class SampleSurrealReadOnlyCollection<TEntity>
return rows?.ToList() ?? [];
}
/// <summary>
/// Returns entities that match the provided predicate.
/// </summary>
/// <param name="predicate">The predicate used to filter entities.</param>
/// <returns>The entities that satisfy the predicate.</returns>
public IEnumerable<TEntity> Find(Func<TEntity, bool> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
@@ -270,30 +350,54 @@ public sealed class SampleSurrealReadOnlyCollection<TEntity>
public sealed class SampleEntityRecord<TEntity> : Record
where TEntity : class
{
/// <summary>
/// Gets or sets the stored entity payload.
/// </summary>
[JsonPropertyName("entity")]
public TEntity? Entity { get; set; }
}
public sealed class SampleOplogEntry : Record
{
/// <summary>
/// Gets or sets the collection name.
/// </summary>
[JsonPropertyName("collection")]
public string Collection { get; set; } = "";
/// <summary>
/// Gets or sets the entity key.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; set; } = "";
/// <summary>
/// Gets or sets the operation code.
/// </summary>
[JsonPropertyName("operation")]
public int Operation { get; set; }
/// <summary>
/// Gets or sets the node identifier portion of the timestamp.
/// </summary>
[JsonPropertyName("timestampNodeId")]
public string TimestampNodeId { get; set; } = "";
/// <summary>
/// Gets or sets the physical time portion of the timestamp.
/// </summary>
[JsonPropertyName("timestampPhysicalTime")]
public long TimestampPhysicalTime { get; set; }
/// <summary>
/// Gets or sets the logical counter portion of the timestamp.
/// </summary>
[JsonPropertyName("timestampLogicalCounter")]
public int TimestampLogicalCounter { get; set; }
/// <summary>
/// Gets or sets the hash for the operation entry.
/// </summary>
[JsonPropertyName("hash")]
public string Hash { get; set; } = "";
}

View File

@@ -15,6 +15,13 @@ public class SampleDocumentStore : SurrealDocumentStore<SampleDbContext>
private const string UsersCollection = "Users";
private const string TodoListsCollection = "TodoLists";
/// <summary>
/// Initializes a new instance of the <see cref="SampleDocumentStore"/> class.
/// </summary>
/// <param name="context">Sample database context.</param>
/// <param name="configProvider">Peer configuration provider.</param>
/// <param name="vectorClockService">Vector clock service.</param>
/// <param name="logger">Optional logger.</param>
public SampleDocumentStore(
SampleDbContext context,
IPeerNodeConfigurationProvider configProvider,
@@ -35,6 +42,7 @@ public class SampleDocumentStore : SurrealDocumentStore<SampleDbContext>
WatchCollection(TodoListsCollection, context.TodoLists, t => t.Id);
}
/// <inheritdoc />
protected override async Task ApplyContentToEntityAsync(
string collection,
string key,
@@ -44,6 +52,7 @@ public class SampleDocumentStore : SurrealDocumentStore<SampleDbContext>
await UpsertEntityAsync(collection, key, content, cancellationToken);
}
/// <inheritdoc />
protected override async Task ApplyContentToEntitiesBatchAsync(
IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
CancellationToken cancellationToken)
@@ -52,6 +61,7 @@ public class SampleDocumentStore : SurrealDocumentStore<SampleDbContext>
await UpsertEntityAsync(collection, key, content, cancellationToken);
}
/// <inheritdoc />
protected override async Task<JsonElement?> GetEntityAsJsonAsync(
string collection,
string key,
@@ -65,6 +75,7 @@ public class SampleDocumentStore : SurrealDocumentStore<SampleDbContext>
};
}
/// <inheritdoc />
protected override async Task RemoveEntityAsync(
string collection,
string key,
@@ -73,6 +84,7 @@ public class SampleDocumentStore : SurrealDocumentStore<SampleDbContext>
await DeleteEntityAsync(collection, key, cancellationToken);
}
/// <inheritdoc />
protected override async Task RemoveEntitiesBatchAsync(
IEnumerable<(string Collection, string Key)> documents,
CancellationToken cancellationToken)
@@ -81,6 +93,7 @@ public class SampleDocumentStore : SurrealDocumentStore<SampleDbContext>
await DeleteEntityAsync(collection, key, cancellationToken);
}
/// <inheritdoc />
protected override async Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
string collection,
CancellationToken cancellationToken)

View File

@@ -55,14 +55,16 @@ public class RetryPolicy : IRetryPolicy
return await operation();
}
catch (Exception ex) when (attempt < config.RetryAttempts && IsTransient(ex))
{
lastException = ex;
int delay = config.RetryDelayMs * attempt; // Exponential backoff
_logger.LogWarning(ex,
"Operation {Operation} failed (attempt {Attempt}/{Max}). Retrying in {Delay}ms...",
operationName, attempt, config.RetryAttempts, delay);
catch (Exception ex) when (IsTransient(ex))
{
lastException = ex;
if (attempt >= config.RetryAttempts) break;
int delay = config.RetryDelayMs * attempt; // Exponential backoff
_logger.LogWarning(ex,
"Operation {Operation} failed (attempt {Attempt}/{Max}). Retrying in {Delay}ms...",
operationName, attempt, config.RetryAttempts, delay);
await Task.Delay(delay, cancellationToken);
}
@@ -111,4 +113,4 @@ public class RetryPolicy : IRetryPolicy
return false;
}
}
}

View File

@@ -16,11 +16,15 @@ public interface ICBDDCSurrealEmbeddedClient : IAsyncDisposable, IDisposable
/// <summary>
/// Connects and selects namespace/database exactly once.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task InitializeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Executes a raw SurrealQL statement.
/// </summary>
/// <param name="query">The SurrealQL query to execute.</param>
/// <param name="parameters">Optional named parameters for the query.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<SurrealDbResponse> RawQueryAsync(string query,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default);
@@ -28,5 +32,6 @@ public interface ICBDDCSurrealEmbeddedClient : IAsyncDisposable, IDisposable
/// <summary>
/// Checks whether the embedded client responds to health probes.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task<bool> HealthAsync(CancellationToken cancellationToken = default);
}

View File

@@ -8,5 +8,6 @@ public interface ICBDDCSurrealReadinessProbe
/// <summary>
/// Returns true when client initialization, schema initialization, and health checks pass.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
Task<bool> IsReadyAsync(CancellationToken cancellationToken = default);
}

View File

@@ -8,5 +8,6 @@ public interface ICBDDCSurrealSchemaInitializer
/// <summary>
/// Creates required tables/indexes/checkpoint schema for CBDDC stores.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task EnsureInitializedAsync(CancellationToken cancellationToken = default);
}

View File

@@ -13,15 +13,18 @@ public interface ISurrealCdcWorkerLifecycle
/// <summary>
/// Starts the CDC worker.
/// </summary>
/// <param name="cancellationToken">The token used to cancel the asynchronous operation.</param>
Task StartCdcWorkerAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Executes one CDC polling pass across all watched collections.
/// </summary>
/// <param name="cancellationToken">The token used to cancel the asynchronous operation.</param>
Task PollCdcOnceAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Stops the CDC worker.
/// </summary>
/// <param name="cancellationToken">The token used to cancel the asynchronous operation.</param>
Task StopCdcWorkerAsync(CancellationToken cancellationToken = default);
}

View File

@@ -150,30 +150,56 @@ public sealed class SurrealCdcCheckpointPersistence : ISurrealCdcCheckpointPersi
internal sealed class SurrealCdcCheckpointRecord : Record
{
/// <summary>
/// Gets or sets the CDC consumer identifier.
/// </summary>
[JsonPropertyName("consumerId")]
public string ConsumerId { get; set; } = "";
/// <summary>
/// Gets or sets the physical time component of the checkpoint timestamp.
/// </summary>
[JsonPropertyName("timestampPhysicalTime")]
public long TimestampPhysicalTime { get; set; }
/// <summary>
/// Gets or sets the logical counter component of the checkpoint timestamp.
/// </summary>
[JsonPropertyName("timestampLogicalCounter")]
public int TimestampLogicalCounter { get; set; }
/// <summary>
/// Gets or sets the node identifier component of the checkpoint timestamp.
/// </summary>
[JsonPropertyName("timestampNodeId")]
public string TimestampNodeId { get; set; } = "";
/// <summary>
/// Gets or sets the hash associated with the checkpoint.
/// </summary>
[JsonPropertyName("lastHash")]
public string LastHash { get; set; } = "";
/// <summary>
/// Gets or sets the last update time in Unix milliseconds.
/// </summary>
[JsonPropertyName("updatedUtcMs")]
public long UpdatedUtcMs { get; set; }
/// <summary>
/// Gets or sets the optional encoded versionstamp cursor.
/// </summary>
[JsonPropertyName("versionstampCursor")]
public long? VersionstampCursor { get; set; }
}
internal static class SurrealCdcCheckpointRecordMappers
{
/// <summary>
/// Converts a checkpoint record into the domain checkpoint model.
/// </summary>
/// <param name="record">The Surreal checkpoint record.</param>
/// <returns>The mapped domain checkpoint instance.</returns>
public static SurrealCdcCheckpoint ToDomain(this SurrealCdcCheckpointRecord record)
{
return new SurrealCdcCheckpoint

View File

@@ -13,6 +13,12 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
private readonly ISurrealDbClient _surrealClient;
/// <summary>
/// Initializes a new instance of the <see cref="SurrealDocumentMetadataStore" /> class.
/// </summary>
/// <param name="surrealEmbeddedClient">The embedded Surreal client provider.</param>
/// <param name="schemaInitializer">The schema initializer.</param>
/// <param name="logger">Optional logger.</param>
public SurrealDocumentMetadataStore(
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
ICBDDCSurrealSchemaInitializer schemaInitializer,
@@ -24,6 +30,7 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
_logger = logger ?? NullLogger<SurrealDocumentMetadataStore>.Instance;
}
/// <inheritdoc />
public override async Task<DocumentMetadata?> GetMetadataAsync(string collection, string key,
CancellationToken cancellationToken = default)
{
@@ -31,6 +38,7 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
return existing?.ToDomain();
}
/// <inheritdoc />
public override async Task<IEnumerable<DocumentMetadata>> GetMetadataByCollectionAsync(string collection,
CancellationToken cancellationToken = default)
{
@@ -41,6 +49,7 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
.ToList();
}
/// <inheritdoc />
public override async Task UpsertMetadataAsync(DocumentMetadata metadata,
CancellationToken cancellationToken = default)
{
@@ -55,6 +64,7 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
cancellationToken);
}
/// <inheritdoc />
public override async Task UpsertMetadataBatchAsync(IEnumerable<DocumentMetadata> metadatas,
CancellationToken cancellationToken = default)
{
@@ -62,6 +72,7 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
await UpsertMetadataAsync(metadata, cancellationToken);
}
/// <inheritdoc />
public override async Task MarkDeletedAsync(string collection, string key, HlcTimestamp timestamp,
CancellationToken cancellationToken = default)
{
@@ -69,6 +80,7 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
await UpsertMetadataAsync(metadata, cancellationToken);
}
/// <inheritdoc />
public override async Task<IEnumerable<DocumentMetadata>> GetMetadataAfterAsync(HlcTimestamp since,
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default)
{
@@ -86,24 +98,28 @@ public class SurrealDocumentMetadataStore : DocumentMetadataStore
.ToList();
}
/// <inheritdoc />
public override async Task DropAsync(CancellationToken cancellationToken = default)
{
await EnsureReadyAsync(cancellationToken);
await _surrealClient.Delete(CBDDCSurrealSchemaNames.DocumentMetadataTable, cancellationToken);
}
/// <inheritdoc />
public override async Task<IEnumerable<DocumentMetadata>> ExportAsync(CancellationToken cancellationToken = default)
{
var all = await SelectAllAsync(cancellationToken);
return all.Select(m => m.ToDomain()).ToList();
}
/// <inheritdoc />
public override async Task ImportAsync(IEnumerable<DocumentMetadata> items,
CancellationToken cancellationToken = default)
{
foreach (var item in items) await UpsertMetadataAsync(item, cancellationToken);
}
/// <inheritdoc />
public override async Task MergeAsync(IEnumerable<DocumentMetadata> items,
CancellationToken cancellationToken = default)
{

View File

@@ -61,6 +61,15 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
/// <summary>
/// Initializes a new instance of the <see cref="SurrealDocumentStore{TContext}" /> class.
/// </summary>
/// <param name="context">The application context used by the concrete store.</param>
/// <param name="surrealEmbeddedClient">The embedded Surreal client provider.</param>
/// <param name="schemaInitializer">The Surreal schema initializer.</param>
/// <param name="configProvider">The peer node configuration provider.</param>
/// <param name="vectorClockService">The vector clock service used for local oplog state.</param>
/// <param name="conflictResolver">Optional conflict resolver; defaults to last-write-wins.</param>
/// <param name="checkpointPersistence">Optional CDC checkpoint persistence component.</param>
/// <param name="cdcPollingOptions">Optional CDC polling options.</param>
/// <param name="logger">Optional logger instance.</param>
protected SurrealDocumentStore(
TContext context,
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
@@ -128,21 +137,28 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
{
private readonly ILogger _inner;
/// <summary>
/// Initializes a new instance of the <see cref="ForwardingLogger" /> class.
/// </summary>
/// <param name="inner">The logger instance to forward calls to.</param>
public ForwardingLogger(ILogger inner)
{
_inner = inner;
}
/// <inheritdoc />
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return _inner.BeginScope(state);
}
/// <inheritdoc />
public bool IsEnabled(LogLevel logLevel)
{
return _inner.IsEnabled(logLevel);
}
/// <inheritdoc />
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
@@ -191,6 +207,7 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
/// <param name="collectionName">Logical collection name used by oplog and metadata records.</param>
/// <param name="collection">Watchable change source.</param>
/// <param name="keySelector">Function used to resolve the entity key.</param>
/// <param name="subscribeForInMemoryEvents">Whether to subscribe to in-memory collection events.</param>
protected void WatchCollection<TEntity>(
string collectionName,
ISurrealWatchableCollection<TEntity> collection,
@@ -220,6 +237,12 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
private readonly Func<TEntity, string> _keySelector;
private readonly SurrealDocumentStore<TContext> _store;
/// <summary>
/// Initializes a new instance of the <see cref="CdcObserver{TEntity}" /> class.
/// </summary>
/// <param name="collectionName">The logical collection name.</param>
/// <param name="keySelector">The key selector for observed entities.</param>
/// <param name="store">The owning document store.</param>
public CdcObserver(
string collectionName,
Func<TEntity, string> keySelector,
@@ -230,6 +253,7 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
_store = store;
}
/// <inheritdoc />
public void OnNext(SurrealCollectionChange<TEntity> changeEvent)
{
if (_store.IsCdcPollingWorkerActiveForCollection(_collectionName)) return;
@@ -267,10 +291,12 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
.GetAwaiter().GetResult();
}
/// <inheritdoc />
public void OnError(Exception error)
{
}
/// <inheritdoc />
public void OnCompleted()
{
}
@@ -760,22 +786,58 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
#region Abstract Methods - Implemented by subclass
/// <summary>
/// Applies JSON content to a single entity in the backing store.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <param name="content">The JSON payload to persist.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected abstract Task ApplyContentToEntityAsync(
string collection, string key, JsonElement content, CancellationToken cancellationToken);
/// <summary>
/// Applies JSON content to multiple entities in the backing store.
/// </summary>
/// <param name="documents">The documents to persist.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected abstract Task ApplyContentToEntitiesBatchAsync(
IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
CancellationToken cancellationToken);
/// <summary>
/// Gets a single entity as JSON content.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The JSON content when found; otherwise <see langword="null" />.</returns>
protected abstract Task<JsonElement?> GetEntityAsJsonAsync(
string collection, string key, CancellationToken cancellationToken);
/// <summary>
/// Removes a single entity from the backing store.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected abstract Task RemoveEntityAsync(
string collection, string key, CancellationToken cancellationToken);
/// <summary>
/// Removes multiple entities from the backing store.
/// </summary>
/// <param name="documents">The documents to remove.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected abstract Task RemoveEntitiesBatchAsync(
IEnumerable<(string Collection, string Key)> documents, CancellationToken cancellationToken);
/// <summary>
/// Gets all entities from a collection as JSON content.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A sequence of key/content pairs.</returns>
protected abstract Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
string collection, CancellationToken cancellationToken);
@@ -1055,6 +1117,12 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
/// <summary>
/// Handles a local collection change and records oplog/metadata when not suppressed.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <param name="operationType">The detected operation type.</param>
/// <param name="content">Optional JSON content for non-delete operations.</param>
/// <param name="pendingCursorCheckpoint">Optional pending cursor checkpoint to persist.</param>
/// <param name="cancellationToken">The cancellation token.</param>
protected async Task OnLocalChangeDetectedAsync(
string collection,
string key,
@@ -1315,11 +1383,16 @@ public abstract class SurrealDocumentStore<TContext> : IDocumentStore, ISurrealC
private readonly SemaphoreSlim _guard;
private int _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="RemoteSyncScope" /> class.
/// </summary>
/// <param name="guard">The guard semaphore to release on dispose.</param>
public RemoteSyncScope(SemaphoreSlim guard)
{
_guard = guard;
}
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;

View File

@@ -127,6 +127,11 @@ public sealed class SurrealCollectionChangeFeed<TEntity> : ISurrealWatchableColl
private readonly IObserver<SurrealCollectionChange<TEntity>> _observer;
private int _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="Subscription" /> class.
/// </summary>
/// <param name="owner">The owning change feed.</param>
/// <param name="observer">The observer to unsubscribe on disposal.</param>
public Subscription(
SurrealCollectionChangeFeed<TEntity> owner,
IObserver<SurrealCollectionChange<TEntity>> observer)
@@ -135,6 +140,7 @@ public sealed class SurrealCollectionChangeFeed<TEntity> : ISurrealWatchableColl
_observer = observer;
}
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 1) return;

View File

@@ -14,6 +14,16 @@ public class SurrealOplogStore : OplogStore
private readonly ICBDDCSurrealSchemaInitializer? _schemaInitializer;
private readonly ISurrealDbClient? _surrealClient;
/// <summary>
/// Initializes a new instance of the <see cref="SurrealOplogStore" /> class.
/// </summary>
/// <param name="surrealEmbeddedClient">The Surreal embedded client provider.</param>
/// <param name="schemaInitializer">The schema initializer used to prepare storage.</param>
/// <param name="documentStore">The document store used for entity operations.</param>
/// <param name="conflictResolver">The conflict resolver for replicated mutations.</param>
/// <param name="vectorClockService">The vector clock service for causal ordering.</param>
/// <param name="snapshotMetadataStore">The optional snapshot metadata store.</param>
/// <param name="logger">The optional logger instance.</param>
public SurrealOplogStore(
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
ICBDDCSurrealSchemaInitializer schemaInitializer,
@@ -36,6 +46,7 @@ public class SurrealOplogStore : OplogStore
InitializeVectorClock();
}
/// <inheritdoc />
public override async Task<IEnumerable<OplogEntry>> GetChainRangeAsync(string startHash, string endHash,
CancellationToken cancellationToken = default)
{
@@ -61,12 +72,14 @@ public class SurrealOplogStore : OplogStore
.ToList();
}
/// <inheritdoc />
public override async Task<OplogEntry?> GetEntryByHashAsync(string hash, CancellationToken cancellationToken = default)
{
var existing = await FindByHashAsync(hash, cancellationToken);
return existing?.ToDomain();
}
/// <inheritdoc />
public override async Task<IEnumerable<OplogEntry>> GetOplogAfterAsync(HlcTimestamp timestamp,
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default)
{
@@ -85,6 +98,7 @@ public class SurrealOplogStore : OplogStore
.ToList();
}
/// <inheritdoc />
public override async Task<IEnumerable<OplogEntry>> GetOplogForNodeAfterAsync(string nodeId, HlcTimestamp since,
IEnumerable<string>? collections = null, CancellationToken cancellationToken = default)
{
@@ -104,6 +118,7 @@ public class SurrealOplogStore : OplogStore
.ToList();
}
/// <inheritdoc />
public override async Task PruneOplogAsync(HlcTimestamp cutoff, CancellationToken cancellationToken = default)
{
var all = await SelectAllAsync(cancellationToken);
@@ -121,6 +136,7 @@ public class SurrealOplogStore : OplogStore
}
}
/// <inheritdoc />
public override async Task DropAsync(CancellationToken cancellationToken = default)
{
await EnsureReadyAsync(cancellationToken);
@@ -128,12 +144,14 @@ public class SurrealOplogStore : OplogStore
_vectorClock.Invalidate();
}
/// <inheritdoc />
public override async Task<IEnumerable<OplogEntry>> ExportAsync(CancellationToken cancellationToken = default)
{
var all = await SelectAllAsync(cancellationToken);
return all.Select(o => o.ToDomain()).ToList();
}
/// <inheritdoc />
public override async Task ImportAsync(IEnumerable<OplogEntry> items, CancellationToken cancellationToken = default)
{
foreach (var item in items)
@@ -144,6 +162,7 @@ public class SurrealOplogStore : OplogStore
}
}
/// <inheritdoc />
public override async Task MergeAsync(IEnumerable<OplogEntry> items, CancellationToken cancellationToken = default)
{
foreach (var item in items)
@@ -155,6 +174,7 @@ public class SurrealOplogStore : OplogStore
}
}
/// <inheritdoc />
protected override void InitializeVectorClock()
{
if (_vectorClock.IsInitialized) return;
@@ -206,6 +226,7 @@ public class SurrealOplogStore : OplogStore
_vectorClock.IsInitialized = true;
}
/// <inheritdoc />
protected override async Task InsertOplogEntryAsync(OplogEntry entry, CancellationToken cancellationToken = default)
{
var existing = await FindByHashAsync(entry.Hash, cancellationToken);
@@ -214,6 +235,7 @@ public class SurrealOplogStore : OplogStore
await UpsertAsync(entry, SurrealStoreRecordIds.Oplog(entry.Hash), cancellationToken);
}
/// <inheritdoc />
protected override async Task<string?> QueryLastHashForNodeAsync(string nodeId,
CancellationToken cancellationToken = default)
{
@@ -226,6 +248,7 @@ public class SurrealOplogStore : OplogStore
return lastEntry?.Hash;
}
/// <inheritdoc />
protected override async Task<(long Wall, int Logic)?> QueryLastHashTimestampFromOplogAsync(string hash,
CancellationToken cancellationToken = default)
{

View File

@@ -12,6 +12,12 @@ public class SurrealPeerConfigurationStore : PeerConfigurationStore
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
private readonly ISurrealDbClient _surrealClient;
/// <summary>
/// Initializes a new instance of the <see cref="SurrealPeerConfigurationStore"/> class.
/// </summary>
/// <param name="surrealEmbeddedClient">The embedded SurrealDB client.</param>
/// <param name="schemaInitializer">The schema initializer.</param>
/// <param name="logger">The logger instance.</param>
public SurrealPeerConfigurationStore(
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
ICBDDCSurrealSchemaInitializer schemaInitializer,
@@ -23,6 +29,7 @@ public class SurrealPeerConfigurationStore : PeerConfigurationStore
_logger = logger ?? NullLogger<SurrealPeerConfigurationStore>.Instance;
}
/// <inheritdoc />
public override async Task<IEnumerable<RemotePeerConfiguration>> GetRemotePeersAsync(
CancellationToken cancellationToken = default)
{
@@ -30,6 +37,7 @@ public class SurrealPeerConfigurationStore : PeerConfigurationStore
return all.Select(p => p.ToDomain()).ToList();
}
/// <inheritdoc />
public override async Task<RemotePeerConfiguration?> GetRemotePeerAsync(string nodeId,
CancellationToken cancellationToken)
{
@@ -37,6 +45,7 @@ public class SurrealPeerConfigurationStore : PeerConfigurationStore
return existing?.ToDomain();
}
/// <inheritdoc />
public override async Task RemoveRemotePeerAsync(string nodeId, CancellationToken cancellationToken = default)
{
await EnsureReadyAsync(cancellationToken);
@@ -52,6 +61,7 @@ public class SurrealPeerConfigurationStore : PeerConfigurationStore
_logger.LogInformation("Removed remote peer configuration: {NodeId}", nodeId);
}
/// <inheritdoc />
public override async Task SaveRemotePeerAsync(RemotePeerConfiguration peer,
CancellationToken cancellationToken = default)
{
@@ -67,6 +77,7 @@ public class SurrealPeerConfigurationStore : PeerConfigurationStore
_logger.LogInformation("Saved remote peer configuration: {NodeId} ({Type})", peer.NodeId, peer.Type);
}
/// <inheritdoc />
public override async Task DropAsync(CancellationToken cancellationToken = default)
{
_logger.LogWarning(
@@ -76,6 +87,7 @@ public class SurrealPeerConfigurationStore : PeerConfigurationStore
_logger.LogInformation("Peer configuration store dropped successfully.");
}
/// <inheritdoc />
public override async Task<IEnumerable<RemotePeerConfiguration>> ExportAsync(
CancellationToken cancellationToken = default)
{

View File

@@ -15,6 +15,12 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
private readonly ISurrealDbClient _surrealClient;
/// <summary>
/// Initializes a new instance of the <see cref="SurrealPeerOplogConfirmationStore"/> class.
/// </summary>
/// <param name="surrealEmbeddedClient">Embedded Surreal client wrapper.</param>
/// <param name="schemaInitializer">Schema initializer.</param>
/// <param name="logger">Optional logger.</param>
public SurrealPeerOplogConfirmationStore(
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
ICBDDCSurrealSchemaInitializer schemaInitializer,
@@ -26,6 +32,7 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
_logger = logger ?? NullLogger<SurrealPeerOplogConfirmationStore>.Instance;
}
/// <inheritdoc />
public override async Task EnsurePeerRegisteredAsync(
string peerNodeId,
string address,
@@ -68,6 +75,7 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
await UpsertAsync(existing, recordId, cancellationToken);
}
/// <inheritdoc />
public override async Task UpdateConfirmationAsync(
string peerNodeId,
string sourceNodeId,
@@ -118,6 +126,7 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
await UpsertAsync(existing, recordId, cancellationToken);
}
/// <inheritdoc />
public override async Task<IEnumerable<PeerOplogConfirmation>> GetConfirmationsAsync(
CancellationToken cancellationToken = default)
{
@@ -128,6 +137,7 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
.ToList();
}
/// <inheritdoc />
public override async Task<IEnumerable<PeerOplogConfirmation>> GetConfirmationsForPeerAsync(
string peerNodeId,
CancellationToken cancellationToken = default)
@@ -143,6 +153,7 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
.ToList();
}
/// <inheritdoc />
public override async Task RemovePeerTrackingAsync(string peerNodeId, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(peerNodeId))
@@ -167,6 +178,7 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
}
}
/// <inheritdoc />
public override async Task<IEnumerable<string>> GetActiveTrackedPeersAsync(
CancellationToken cancellationToken = default)
{
@@ -178,18 +190,21 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
.ToList();
}
/// <inheritdoc />
public override async Task DropAsync(CancellationToken cancellationToken = default)
{
await EnsureReadyAsync(cancellationToken);
await _surrealClient.Delete(CBDDCSurrealSchemaNames.PeerOplogConfirmationsTable, cancellationToken);
}
/// <inheritdoc />
public override async Task<IEnumerable<PeerOplogConfirmation>> ExportAsync(CancellationToken cancellationToken = default)
{
var all = await SelectAllAsync(cancellationToken);
return all.Select(c => c.ToDomain()).ToList();
}
/// <inheritdoc />
public override async Task ImportAsync(IEnumerable<PeerOplogConfirmation> items,
CancellationToken cancellationToken = default)
{
@@ -202,6 +217,7 @@ public class SurrealPeerOplogConfirmationStore : PeerOplogConfirmationStore
}
}
/// <inheritdoc />
public override async Task MergeAsync(IEnumerable<PeerOplogConfirmation> items,
CancellationToken cancellationToken = default)
{

View File

@@ -17,6 +17,12 @@ internal static class SurrealShowChangesCborDecoder
{
private static readonly string[] PutChangeKinds = ["create", "update", "upsert", "insert", "set", "replace"];
/// <summary>
/// Decodes change rows returned by a SurrealDB show changes query.
/// </summary>
/// <param name="rows">The CBOR rows to decode.</param>
/// <param name="expectedTableName">The expected table name used to validate row identifiers.</param>
/// <returns>The decoded set of change rows.</returns>
public static IReadOnlyList<SurrealPolledChangeRow> DecodeRows(
IEnumerable<CborObject> rows,
string expectedTableName)

View File

@@ -12,6 +12,12 @@ public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
private readonly ISurrealDbClient _surrealClient;
/// <summary>
/// Initializes a new instance of the <see cref="SurrealSnapshotMetadataStore" /> class.
/// </summary>
/// <param name="surrealEmbeddedClient">The Surreal embedded client provider.</param>
/// <param name="schemaInitializer">The schema initializer used to prepare storage.</param>
/// <param name="logger">The optional logger instance.</param>
public SurrealSnapshotMetadataStore(
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
ICBDDCSurrealSchemaInitializer schemaInitializer,
@@ -23,18 +29,21 @@ public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
_logger = logger ?? NullLogger<SurrealSnapshotMetadataStore>.Instance;
}
/// <inheritdoc />
public override async Task DropAsync(CancellationToken cancellationToken = default)
{
await EnsureReadyAsync(cancellationToken);
await _surrealClient.Delete(CBDDCSurrealSchemaNames.SnapshotMetadataTable, cancellationToken);
}
/// <inheritdoc />
public override async Task<IEnumerable<SnapshotMetadata>> ExportAsync(CancellationToken cancellationToken = default)
{
var all = await SelectAllAsync(cancellationToken);
return all.Select(m => m.ToDomain()).ToList();
}
/// <inheritdoc />
public override async Task<SnapshotMetadata?> GetSnapshotMetadataAsync(string nodeId,
CancellationToken cancellationToken = default)
{
@@ -42,12 +51,14 @@ public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
return existing?.ToDomain();
}
/// <inheritdoc />
public override async Task<string?> GetSnapshotHashAsync(string nodeId, CancellationToken cancellationToken = default)
{
var existing = await FindByNodeIdAsync(nodeId, cancellationToken);
return existing?.Hash;
}
/// <inheritdoc />
public override async Task ImportAsync(IEnumerable<SnapshotMetadata> items,
CancellationToken cancellationToken = default)
{
@@ -59,6 +70,7 @@ public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
}
}
/// <inheritdoc />
public override async Task InsertSnapshotMetadataAsync(SnapshotMetadata metadata,
CancellationToken cancellationToken = default)
{
@@ -67,6 +79,7 @@ public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
await UpsertAsync(metadata, recordId, cancellationToken);
}
/// <inheritdoc />
public override async Task MergeAsync(IEnumerable<SnapshotMetadata> items, CancellationToken cancellationToken = default)
{
foreach (var metadata in items)
@@ -88,6 +101,7 @@ public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
}
}
/// <inheritdoc />
public override async Task UpdateSnapshotMetadataAsync(SnapshotMetadata existingMeta,
CancellationToken cancellationToken)
{
@@ -98,6 +112,7 @@ public class SurrealSnapshotMetadataStore : SnapshotMetadataStore
await UpsertAsync(existingMeta, recordId, cancellationToken);
}
/// <inheritdoc />
public override async Task<IEnumerable<SnapshotMetadata>> GetAllSnapshotMetadataAsync(
CancellationToken cancellationToken = default)
{

View File

@@ -11,11 +11,22 @@ namespace ZB.MOM.WW.CBDDC.Persistence.Surreal;
internal static class SurrealStoreRecordIds
{
/// <summary>
/// Creates the record identifier for an oplog entry.
/// </summary>
/// <param name="hash">The oplog entry hash.</param>
/// <returns>The SurrealDB record identifier.</returns>
public static RecordId Oplog(string hash)
{
return RecordId.From(CBDDCSurrealSchemaNames.OplogEntriesTable, hash);
}
/// <summary>
/// Creates the record identifier for document metadata.
/// </summary>
/// <param name="collection">The document collection name.</param>
/// <param name="key">The document key.</param>
/// <returns>The SurrealDB record identifier.</returns>
public static RecordId DocumentMetadata(string collection, string key)
{
return RecordId.From(
@@ -23,16 +34,32 @@ internal static class SurrealStoreRecordIds
CompositeKey("docmeta", collection, key));
}
/// <summary>
/// Creates the record identifier for snapshot metadata.
/// </summary>
/// <param name="nodeId">The node identifier.</param>
/// <returns>The SurrealDB record identifier.</returns>
public static RecordId SnapshotMetadata(string nodeId)
{
return RecordId.From(CBDDCSurrealSchemaNames.SnapshotMetadataTable, nodeId);
}
/// <summary>
/// Creates the record identifier for a remote peer configuration.
/// </summary>
/// <param name="nodeId">The peer node identifier.</param>
/// <returns>The SurrealDB record identifier.</returns>
public static RecordId RemotePeer(string nodeId)
{
return RecordId.From(CBDDCSurrealSchemaNames.RemotePeerConfigurationsTable, nodeId);
}
/// <summary>
/// Creates the record identifier for a peer oplog confirmation.
/// </summary>
/// <param name="peerNodeId">The peer node identifier.</param>
/// <param name="sourceNodeId">The source node identifier.</param>
/// <returns>The SurrealDB record identifier.</returns>
public static RecordId PeerOplogConfirmation(string peerNodeId, string sourceNodeId)
{
return RecordId.From(
@@ -49,114 +76,212 @@ internal static class SurrealStoreRecordIds
internal sealed class SurrealOplogRecord : Record
{
/// <summary>
/// Gets or sets the collection name.
/// </summary>
[JsonPropertyName("collection")]
public string Collection { get; set; } = "";
/// <summary>
/// Gets or sets the document key.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; set; } = "";
/// <summary>
/// Gets or sets the operation type value.
/// </summary>
[JsonPropertyName("operation")]
public int Operation { get; set; }
/// <summary>
/// Gets or sets the serialized payload JSON.
/// </summary>
[JsonPropertyName("payloadJson")]
public string PayloadJson { get; set; } = "";
/// <summary>
/// Gets or sets the timestamp physical time.
/// </summary>
[JsonPropertyName("timestampPhysicalTime")]
public long TimestampPhysicalTime { get; set; }
/// <summary>
/// Gets or sets the timestamp logical counter.
/// </summary>
[JsonPropertyName("timestampLogicalCounter")]
public int TimestampLogicalCounter { get; set; }
/// <summary>
/// Gets or sets the timestamp node identifier.
/// </summary>
[JsonPropertyName("timestampNodeId")]
public string TimestampNodeId { get; set; } = "";
/// <summary>
/// Gets or sets the entry hash.
/// </summary>
[JsonPropertyName("hash")]
public string Hash { get; set; } = "";
/// <summary>
/// Gets or sets the previous entry hash.
/// </summary>
[JsonPropertyName("previousHash")]
public string PreviousHash { get; set; } = "";
}
internal sealed class SurrealDocumentMetadataRecord : Record
{
/// <summary>
/// Gets or sets the collection name.
/// </summary>
[JsonPropertyName("collection")]
public string Collection { get; set; } = "";
/// <summary>
/// Gets or sets the document key.
/// </summary>
[JsonPropertyName("key")]
public string Key { get; set; } = "";
/// <summary>
/// Gets or sets the HLC physical time.
/// </summary>
[JsonPropertyName("hlcPhysicalTime")]
public long HlcPhysicalTime { get; set; }
/// <summary>
/// Gets or sets the HLC logical counter.
/// </summary>
[JsonPropertyName("hlcLogicalCounter")]
public int HlcLogicalCounter { get; set; }
/// <summary>
/// Gets or sets the HLC node identifier.
/// </summary>
[JsonPropertyName("hlcNodeId")]
public string HlcNodeId { get; set; } = "";
/// <summary>
/// Gets or sets a value indicating whether the document is deleted.
/// </summary>
[JsonPropertyName("isDeleted")]
public bool IsDeleted { get; set; }
}
internal sealed class SurrealRemotePeerRecord : Record
{
/// <summary>
/// Gets or sets the peer node identifier.
/// </summary>
[JsonPropertyName("nodeId")]
public string NodeId { get; set; } = "";
/// <summary>
/// Gets or sets the peer network address.
/// </summary>
[JsonPropertyName("address")]
public string Address { get; set; } = "";
/// <summary>
/// Gets or sets the peer type value.
/// </summary>
[JsonPropertyName("type")]
public int Type { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the peer is enabled.
/// </summary>
[JsonPropertyName("isEnabled")]
public bool IsEnabled { get; set; }
/// <summary>
/// Gets or sets the serialized list of collection interests.
/// </summary>
[JsonPropertyName("interestsJson")]
public string InterestsJson { get; set; } = "";
}
internal sealed class SurrealPeerOplogConfirmationRecord : Record
{
/// <summary>
/// Gets or sets the peer node identifier.
/// </summary>
[JsonPropertyName("peerNodeId")]
public string PeerNodeId { get; set; } = "";
/// <summary>
/// Gets or sets the source node identifier.
/// </summary>
[JsonPropertyName("sourceNodeId")]
public string SourceNodeId { get; set; } = "";
/// <summary>
/// Gets or sets the confirmed wall clock component.
/// </summary>
[JsonPropertyName("confirmedWall")]
public long ConfirmedWall { get; set; }
/// <summary>
/// Gets or sets the confirmed logical component.
/// </summary>
[JsonPropertyName("confirmedLogic")]
public int ConfirmedLogic { get; set; }
/// <summary>
/// Gets or sets the confirmed hash.
/// </summary>
[JsonPropertyName("confirmedHash")]
public string ConfirmedHash { get; set; } = "";
/// <summary>
/// Gets or sets the last confirmation time in Unix milliseconds.
/// </summary>
[JsonPropertyName("lastConfirmedUtcMs")]
public long LastConfirmedUtcMs { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the confirmation is active.
/// </summary>
[JsonPropertyName("isActive")]
public bool IsActive { get; set; }
}
internal sealed class SurrealSnapshotMetadataRecord : Record
{
/// <summary>
/// Gets or sets the node identifier.
/// </summary>
[JsonPropertyName("nodeId")]
public string NodeId { get; set; } = "";
/// <summary>
/// Gets or sets the timestamp physical time.
/// </summary>
[JsonPropertyName("timestampPhysicalTime")]
public long TimestampPhysicalTime { get; set; }
/// <summary>
/// Gets or sets the timestamp logical counter.
/// </summary>
[JsonPropertyName("timestampLogicalCounter")]
public int TimestampLogicalCounter { get; set; }
/// <summary>
/// Gets or sets the snapshot hash.
/// </summary>
[JsonPropertyName("hash")]
public string Hash { get; set; } = "";
}
internal static class SurrealStoreRecordMappers
{
/// <summary>
/// Maps a domain oplog entry to a SurrealDB record.
/// </summary>
/// <param name="entry">The domain oplog entry.</param>
/// <returns>The SurrealDB oplog record.</returns>
public static SurrealOplogRecord ToSurrealRecord(this OplogEntry entry)
{
return new SurrealOplogRecord
@@ -173,6 +298,11 @@ internal static class SurrealStoreRecordMappers
};
}
/// <summary>
/// Maps a SurrealDB oplog record to a domain oplog entry.
/// </summary>
/// <param name="record">The SurrealDB oplog record.</param>
/// <returns>The domain oplog entry.</returns>
public static OplogEntry ToDomain(this SurrealOplogRecord record)
{
JsonElement? payload = null;
@@ -189,6 +319,11 @@ internal static class SurrealStoreRecordMappers
record.Hash);
}
/// <summary>
/// Maps domain document metadata to a SurrealDB record.
/// </summary>
/// <param name="metadata">The domain document metadata.</param>
/// <returns>The SurrealDB document metadata record.</returns>
public static SurrealDocumentMetadataRecord ToSurrealRecord(this DocumentMetadata metadata)
{
return new SurrealDocumentMetadataRecord
@@ -202,6 +337,11 @@ internal static class SurrealStoreRecordMappers
};
}
/// <summary>
/// Maps a SurrealDB document metadata record to domain document metadata.
/// </summary>
/// <param name="record">The SurrealDB document metadata record.</param>
/// <returns>The domain document metadata.</returns>
public static DocumentMetadata ToDomain(this SurrealDocumentMetadataRecord record)
{
return new DocumentMetadata(
@@ -211,6 +351,11 @@ internal static class SurrealStoreRecordMappers
record.IsDeleted);
}
/// <summary>
/// Maps a domain remote peer configuration to a SurrealDB record.
/// </summary>
/// <param name="peer">The domain remote peer configuration.</param>
/// <returns>The SurrealDB remote peer record.</returns>
public static SurrealRemotePeerRecord ToSurrealRecord(this RemotePeerConfiguration peer)
{
return new SurrealRemotePeerRecord
@@ -225,6 +370,11 @@ internal static class SurrealStoreRecordMappers
};
}
/// <summary>
/// Maps a SurrealDB remote peer record to a domain remote peer configuration.
/// </summary>
/// <param name="record">The SurrealDB remote peer record.</param>
/// <returns>The domain remote peer configuration.</returns>
public static RemotePeerConfiguration ToDomain(this SurrealRemotePeerRecord record)
{
var result = new RemotePeerConfiguration
@@ -242,6 +392,11 @@ internal static class SurrealStoreRecordMappers
return result;
}
/// <summary>
/// Maps a domain peer oplog confirmation to a SurrealDB record.
/// </summary>
/// <param name="confirmation">The domain peer oplog confirmation.</param>
/// <returns>The SurrealDB peer oplog confirmation record.</returns>
public static SurrealPeerOplogConfirmationRecord ToSurrealRecord(this PeerOplogConfirmation confirmation)
{
return new SurrealPeerOplogConfirmationRecord
@@ -256,6 +411,11 @@ internal static class SurrealStoreRecordMappers
};
}
/// <summary>
/// Maps a SurrealDB peer oplog confirmation record to a domain model.
/// </summary>
/// <param name="record">The SurrealDB peer oplog confirmation record.</param>
/// <returns>The domain peer oplog confirmation.</returns>
public static PeerOplogConfirmation ToDomain(this SurrealPeerOplogConfirmationRecord record)
{
return new PeerOplogConfirmation
@@ -270,6 +430,11 @@ internal static class SurrealStoreRecordMappers
};
}
/// <summary>
/// Maps domain snapshot metadata to a SurrealDB record.
/// </summary>
/// <param name="metadata">The domain snapshot metadata.</param>
/// <returns>The SurrealDB snapshot metadata record.</returns>
public static SurrealSnapshotMetadataRecord ToSurrealRecord(this SnapshotMetadata metadata)
{
return new SurrealSnapshotMetadataRecord
@@ -281,6 +446,11 @@ internal static class SurrealStoreRecordMappers
};
}
/// <summary>
/// Maps a SurrealDB snapshot metadata record to a domain model.
/// </summary>
/// <param name="record">The SurrealDB snapshot metadata record.</param>
/// <returns>The domain snapshot metadata.</returns>
public static SnapshotMetadata ToDomain(this SurrealSnapshotMetadataRecord record)
{
return new SnapshotMetadata

360
surreal.md Normal file
View File

@@ -0,0 +1,360 @@
# BLite -> SurrealDB (Embedded + RocksDB) Migration Plan
## 1) Goal and Scope
Replace all BLite-backed persistence in this repository with SurrealDB embedded using RocksDB persistence, while preserving current CBDDC behavior:
1. Automatic CDC-driven oplog generation for local writes.
2. Reliable sync across peers (including reconnect and snapshot flows).
3. Existing storage contracts (`IDocumentStore`, `IOplogStore`, `IPeerConfigurationStore`, `IDocumentMetadataStore`, `ISnapshotMetadataStore`, `IPeerOplogConfirmationStore`) and test semantics.
4. Full removal of BLite dependencies, APIs, and documentation references.
## 2) Current-State Inventory (Repository-Specific)
Primary BLite implementation and integration points currently live in:
1. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/CBDDCBLiteExtensions.cs`
2. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/CBDDCDocumentDbContext.cs`
3. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/BLiteDocumentStore.cs`
4. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/BLiteOplogStore.cs`
5. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/BLiteDocumentMetadataStore.cs`
6. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/BLitePeerConfigurationStore.cs`
7. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/BLitePeerOplogConfirmationStore.cs`
8. `src/ZB.MOM.WW.CBDDC.Persistence/BLite/BLiteSnapshotMetadataStore.cs`
9. `samples/ZB.MOM.WW.CBDDC.Sample.Console/SampleDbContext.cs`
10. `samples/ZB.MOM.WW.CBDDC.Sample.Console/SampleDocumentStore.cs`
11. `samples/ZB.MOM.WW.CBDDC.Sample.Console/Program.cs`
12. `tests/ZB.MOM.WW.CBDDC.Sample.Console.Tests/*.cs` (BLite-focused tests)
13. `tests/ZB.MOM.WW.CBDDC.E2E.Tests/ClusterCrudSyncE2ETests.cs`
14. `src/ZB.MOM.WW.CBDDC.Persistence/ZB.MOM.WW.CBDDC.Persistence.csproj` and sample/test package references
15. `README.md` and related docs that currently describe BLite as the embedded provider.
## 3) Target Architecture
### 3.1 Provider Surface
Create a Surreal provider namespace and extension entrypoint that mirrors current integration shape:
1. Add `AddCBDDCSurrealEmbedded<...>()` in a new file (e.g., `src/ZB.MOM.WW.CBDDC.Persistence/Surreal/CBDDCSurrealExtensions.cs`).
2. Register Surreal-backed implementations for all existing persistence interfaces.
3. Keep singleton lifetime for store services and Surreal client factory (equivalent to current BLite singleton model).
4. Expose options object including:
- RocksDB endpoint/path (`rocksdb://...`)
- Namespace
- Database
- CDC polling interval
- CDC batch size
- CDC retention duration
### 3.2 Surreal Connection and Embedded Startup
Use official embedded .NET guidance:
1. Add Surreal embedded packages.
2. Use `SurrealDbEmbeddedClient`/RocksDB embedded client with `rocksdb://` endpoint.
3. Run `USE NS <ns> DB <db>` at startup.
4. Dispose/close client on host shutdown.
### 3.3 Table Design (Schema + Indexing)
Define internal tables as `SCHEMAFULL` and strongly typed fields to reduce runtime drift.
Proposed tables:
1. `oplog_entries`
2. `snapshot_metadatas`
3. `remote_peer_configurations`
4. `document_metadatas`
5. `peer_oplog_confirmations`
6. `cdc_checkpoints` (new: durable cursor per watched table)
7. Optional: `cdc_dedup` (new: idempotency window for duplicate/overlapping reads)
Indexes and IDs:
1. Prefer deterministic record IDs for point lookups (`table:id`) where possible.
2. Add unique indexes for business keys currently enforced in BLite:
- `oplog_entries.hash`
- `snapshot_metadatas.node_id`
- `(document_metadatas.collection, document_metadatas.key)`
- `(peer_oplog_confirmations.peer_node_id, peer_oplog_confirmations.source_node_id)`
3. Add composite indexes for hot sync queries:
- Oplog by `(timestamp_physical, timestamp_logical)`
- Oplog by `(timestamp_node_id, timestamp_physical, timestamp_logical)`
- Metadata by `(hlc_physical, hlc_logical)`
4. Use `EXPLAIN FULL` during test/benchmark phase to verify index usage.
### 3.4 CDC Strategy (Durable + Low Latency)
Implement CDC with Surreal Change Feeds as source of truth and Live Queries as optional accelerators.
1. Enable `CHANGEFEED <duration>` per watched table (`INCLUDE ORIGINAL` when old values are required for conflict handling/debug).
2. Persist checkpoint cursor (`versionstamp` preferred) in `cdc_checkpoints`.
3. Poll with `SHOW CHANGES FOR TABLE <table> SINCE <cursor> LIMIT <N>`.
4. Process changes idempotently; tolerate duplicate windows when timestamp cursors overlap.
5. Commit checkpoint only after oplog + metadata writes commit successfully.
6. Optionally run `LIVE SELECT` subscribers for lower-latency wakeups, but never rely on live events alone for durability.
7. On startup/reconnect, always catch up via `SHOW CHANGES` from last persisted cursor.
### 3.5 Transaction Boundaries
Use explicit SurrealQL transactions for atomic state transitions:
1. Local CDC event -> write oplog entry + document metadata + vector clock backing data in one transaction.
2. Remote apply batch -> apply documents + merge oplog + metadata updates atomically in bounded batches.
3. Snapshot replace/merge -> table-level clear/import or merge in deterministic order with rollback on failure.
## 4) Execution Plan (Phased)
## Phase 0: Design Freeze and Safety Rails
1. Finalize data model and table schema DDL.
2. Finalize CDC cursor semantics (`versionstamp` vs timestamp fallback).
3. Freeze shared contracts in `ZB.MOM.WW.CBDDC.Core` (no signature churn during provider port).
4. Add migration feature flag for temporary cutover control (`UseSurrealPersistence`), removed in final cleanup.
Exit criteria:
1. Design doc approved.
2. DDL + index plan reviewed.
3. CDC retention value chosen (must exceed maximum offline peer window).
## Phase 1: Surreal Infrastructure Layer
1. Add Surreal packages and connection factory.
2. Implement startup initialization: NS/DB selection, table/index creation, capability checks.
3. Introduce provider options and DI extension (`AddCBDDCSurrealEmbedded`).
4. Add health probe for embedded connection and schema readiness.
Exit criteria:
1. `dotnet build` succeeds.
2. Basic smoke test can connect, create, read, and delete records in RocksDB-backed embedded Surreal.
## Phase 2: Port Store Implementations
Port each BLite store to Surreal while preserving interface behavior:
1. `BLiteOplogStore` -> `SurrealOplogStore`
2. `BLiteDocumentMetadataStore` -> `SurrealDocumentMetadataStore`
3. `BLitePeerConfigurationStore` -> `SurrealPeerConfigurationStore`
4. `BLitePeerOplogConfirmationStore` -> `SurrealPeerOplogConfirmationStore`
5. `BLiteSnapshotMetadataStore` -> `SurrealSnapshotMetadataStore`
Implementation requirements:
1. Keep existing merge/drop/export/import semantics.
2. Preserve ordering guarantees for hash-chain methods.
3. Preserve vector clock bootstrap behavior (snapshot metadata first, oplog second).
Exit criteria:
1. Store-level unit tests pass with Surreal backend.
2. No BLite store classes used in DI path.
## Phase 3: Document Store + CDC Engine
1. Replace `BLiteDocumentStore<TDbContext>` with Surreal-aware document store base.
2. Implement collection registration + watched table catalog.
3. Implement CDC worker:
- Poll `SHOW CHANGES`
- Map CDC events to `OperationType`
- Generate oplog + metadata
- Enforce remote-sync suppression/idempotency
4. Keep equivalent remote apply guard semantics to prevent CDC loopback during sync replay.
5. Add graceful start/stop lifecycle hooks for CDC worker.
Exit criteria:
1. Local direct writes produce expected oplog entries.
2. Remote replay does not create duplicate local oplog entries.
3. Restart resumes CDC from persisted checkpoint without missing changes.
## Phase 4: Sample App and E2E Harness Migration
1. Replace sample BLite context usage with Surreal-backed sample persistence.
2. Replace `AddCBDDCBLite` usage in sample and tests.
3. Update `ClusterCrudSyncE2ETests` internals that currently access BLite collections directly.
4. Refactor fallback CDC assertion logic to Surreal-based observability hooks.
Exit criteria:
1. Sample runs two-node sync with Surreal embedded RocksDB.
2. E2E CRUD bidirectional test passes unchanged in behavior.
## Phase 5: Data Migration Tooling and Cutover
1. Build one-time migration utility:
- Read BLite data via existing stores
- Write to Surreal tables
- Preserve hashes/timestamps exactly
2. Add verification routine comparing counts, hashes, and key spot checks.
3. Document migration command and rollback artifacts.
Exit criteria:
1. Dry-run migration succeeds on fixture DB.
2. Post-migration parity checks are clean.
## Phase 6: Remove BLite Completely
1. Delete `src/ZB.MOM.WW.CBDDC.Persistence/BLite/*` after Surreal parity is proven.
2. Remove BLite package references and BLite source generators from project files.
3. Remove `.blite` path assumptions from sample/tests/docs.
4. Update docs and READMEs to SurrealDB terminology.
5. Ensure `rg -n "BLite|blite|AddCBDDCBLite|CBDDCDocumentDbContext"` returns no functional references (except historical notes if intentionally retained).
Exit criteria:
1. Solution builds/tests pass with zero BLite runtime dependency.
2. Docs reflect Surreal-only provider path.
## 5) Safe Parallel Subagent Plan
Use parallel subagents only with strict ownership boundaries and integration gates.
## 5.1 Subagent Work Split
1. Subagent A (Infrastructure/DI)
- Owns: new Surreal options, connection factory, DI extension, startup schema init.
- Files: new `src/.../Surreal/*` infra files, `*.csproj` package refs.
2. Subagent B (Core Stores)
- Owns: oplog/document metadata/snapshot metadata/peer config/peer confirmation Surreal stores.
- Files: `src/ZB.MOM.WW.CBDDC.Persistence/Surreal/*Store.cs`.
3. Subagent C (CDC + DocumentStore)
- Owns: Surreal document store base, CDC poller, checkpoint persistence, suppression loop prevention.
- Files: `src/ZB.MOM.WW.CBDDC.Persistence/Surreal/*DocumentStore*`, CDC worker files.
4. Subagent D (Tests)
- Owns: unit/integration/E2E tests migrated to Surreal.
- Files: `tests/*` touched by provider swap.
5. Subagent E (Sample + Docs)
- Owns: sample console migration and doc rewrites.
- Files: `samples/*`, `README.md`, `docs/*` provider docs.
## 5.2 Parallel Safety Rules
1. No overlapping file ownership between active subagents.
2. Shared contract files are locked unless explicitly assigned to one subagent.
3. Each subagent must submit:
- changed file list
- rationale
- commands run
- test evidence
4. Integrator rebases/merges sequentially, never blindly squashing conflicting edits.
5. If a subagent encounters unrelated dirty changes, it must stop and escalate before editing.
## 5.3 Integration Order
1. Merge A -> B -> C -> D -> E.
2. Run full verification after each merge step, not only at the end.
## 6) Required Unit/Integration Test Matrix
## 6.1 Store Contract Tests
1. Oplog append/export/import/merge/drop parity.
2. `GetChainRangeAsync` correctness by hash chain ordering.
3. `GetLastEntryHashAsync` behavior with oplog hit and snapshot fallback.
4. Pruning respects cutoff and confirmations.
5. Document metadata upsert/mark-deleted/get-after ordering.
6. Peer config save/get/remove/merge semantics.
7. Peer confirmation registration/update/deactivate/merge semantics.
8. Snapshot metadata insert/update/merge and hash lookup.
## 6.2 CDC Tests
1. Local write on watched table emits exactly one oplog entry.
2. Delete mutation emits delete oplog + metadata tombstone.
3. Remote apply path does not re-emit local CDC oplog entries.
4. CDC checkpoint persists only after atomic write success.
5. Restart from checkpoint catches missed changes.
6. Duplicate window replay is idempotent.
7. Changefeed retention boundary behavior is explicit and logged.
## 6.3 Snapshot and Recovery Tests
1. `CreateSnapshotAsync` includes docs/oplog/peers/confirmations.
2. `ReplaceDatabaseAsync` restores full state.
3. `MergeSnapshotAsync` conflict behavior unchanged.
4. Recovery after process restart retains Surreal RocksDB data.
## 6.4 E2E Sync Tests
1. Two peers replicate create/update/delete bidirectionally.
2. Peer reconnect performs incremental catch-up from CDC cursor.
3. Multi-change burst preserves deterministic final state.
4. Optional fault-injection test: crash between oplog write and checkpoint update should replay safely on restart.
## 7) Verification After Each Subagent Completion
Run this checklist after each merged subagent contribution:
1. `dotnet restore`
2. `dotnet build CBDDC.slnx -c Release`
3. Targeted tests for modified projects (fast gate)
4. Full test suite before moving to next major phase:
- `dotnet test CBDDC.slnx -c Release`
5. Regression grep checks:
- `rg -n "BLite|AddCBDDCBLite|\.blite|CBDDCDocumentDbContext" src samples tests README.md docs`
6. Surreal smoke test:
- create temp RocksDB path
- start sample node
- perform write/update/delete
- restart process and verify persisted state
7. CDC durability test:
- stop node
- mutate source
- restart node
- confirm catch-up via `SHOW CHANGES` cursor
## 8) Rollout and Rollback
## Rollout
1. Internal canary branch with Surreal-only provider.
2. Run full CI + extended E2E soak (long-running sync/reconnect).
3. Migrate one test dataset from BLite to Surreal and validate parity.
4. Promote after acceptance criteria are met.
## Rollback
1. Keep BLite export snapshots until Surreal cutover is accepted.
2. If severe defect appears, restore from pre-cutover snapshot and redeploy previous BLite-tagged build.
3. Preserve migration logs and parity reports for audit.
## 9) Definition of Done
1. No runtime BLite dependency remains.
2. All store contracts pass with Surreal backend.
3. CDC is durable (checkpointed), idempotent, and restart-safe.
4. Sample + E2E prove sync parity.
5. Documentation and onboarding instructions updated to Surreal embedded RocksDB.
6. Migration utility + validation report available for production cutover.
## 10) SurrealDB Best-Practice Notes Applied in This Plan
This plan explicitly applies official Surreal guidance:
1. Embedded .NET with RocksDB endpoint (`rocksdb://`) and explicit NS/DB usage.
2. Schema-first design with strict table/field definitions and typed record references.
3. Query/index discipline (`EXPLAIN FULL`, indexed lookups, avoid broad scans).
4. CDC durability with changefeeds and checkpointed `SHOW CHANGES` replay.
5. Live queries used as low-latency signals, not as sole durable CDC transport.
6. Security hardening (authentication, encryption/backups, restricted capabilities) for any non-embedded server deployments used in tooling/CI.
## References (Primary Sources)
1. SurrealDB .NET embedded engine docs: [https://surrealdb.com/docs/surrealdb/embedding/dotnet](https://surrealdb.com/docs/surrealdb/embedding/dotnet)
2. SurrealDB .NET SDK embedding guide: [https://surrealdb.com/docs/sdk/dotnet/embedding](https://surrealdb.com/docs/sdk/dotnet/embedding)
3. SurrealDB connection strings (protocol formats incl. RocksDB): [https://surrealdb.com/docs/surrealdb/reference-guide/connection-strings](https://surrealdb.com/docs/surrealdb/reference-guide/connection-strings)
4. SurrealDB schema best practices: [https://surrealdb.com/docs/surrealdb/reference-guide/schema-creation-best-practices](https://surrealdb.com/docs/surrealdb/reference-guide/schema-creation-best-practices)
5. SurrealDB performance best practices: [https://surrealdb.com/docs/surrealdb/reference-guide/performance-best-practices](https://surrealdb.com/docs/surrealdb/reference-guide/performance-best-practices)
6. SurrealDB real-time/events best practices: [https://surrealdb.com/docs/surrealdb/reference-guide/realtime-best-practices](https://surrealdb.com/docs/surrealdb/reference-guide/realtime-best-practices)
7. SurrealQL `DEFINE TABLE` (changefeed options): [https://surrealdb.com/docs/surrealql/statements/define/table](https://surrealdb.com/docs/surrealql/statements/define/table)
8. SurrealQL `SHOW CHANGES` (durable CDC read): [https://surrealdb.com/docs/surrealql/statements/show](https://surrealdb.com/docs/surrealql/statements/show)
9. SurrealQL `LIVE SELECT` behavior and caveats: [https://surrealdb.com/docs/surrealql/statements/live](https://surrealdb.com/docs/surrealql/statements/live)
10. SurrealDB security best practices: [https://surrealdb.com/docs/surrealdb/security/security-best-practices](https://surrealdb.com/docs/surrealdb/security/security-best-practices)
11. SurrealQL transactions (`BEGIN`/`COMMIT`): [https://surrealdb.com/docs/surrealql/statements/begin](https://surrealdb.com/docs/surrealql/statements/begin), [https://surrealdb.com/docs/surrealql/statements/commit](https://surrealdb.com/docs/surrealql/statements/commit)

View File

@@ -0,0 +1,87 @@
using System.Text.Json;
using ZB.MOM.WW.CBDDC.Core.Cache;
using ZB.MOM.WW.CBDDC.Core.Network;
namespace ZB.MOM.WW.CBDDC.Core.Tests;
public class DocumentCacheTests
{
/// <summary>
/// Verifies cache hit/miss statistics after get and set operations.
/// </summary>
[Fact]
public async Task GetAndSet_ShouldTrackCacheHitsAndMisses()
{
var cache = new DocumentCache(CreateConfigProvider(maxDocumentCacheSize: 2));
Document? missing = await cache.Get("users", "1");
missing.ShouldBeNull();
var document = CreateDocument("users", "1");
await cache.Set("users", "1", document);
Document? hit = await cache.Get("users", "1");
hit.ShouldNotBeNull();
hit.Key.ShouldBe("1");
var stats = cache.GetStatistics();
stats.Hits.ShouldBe(1);
stats.Misses.ShouldBe(1);
stats.Size.ShouldBe(1);
stats.HitRate.ShouldBe(0.5d);
}
/// <summary>
/// Verifies least-recently-used eviction when cache capacity is reached.
/// </summary>
[Fact]
public async Task Set_WhenCacheIsFull_EvictsLeastRecentlyUsedEntry()
{
var cache = new DocumentCache(CreateConfigProvider(maxDocumentCacheSize: 2));
await cache.Set("users", "1", CreateDocument("users", "1"));
await cache.Set("users", "2", CreateDocument("users", "2"));
// Touch key 1 so key 2 becomes the LRU entry.
(await cache.Get("users", "1")).ShouldNotBeNull();
await cache.Set("users", "3", CreateDocument("users", "3"));
(await cache.Get("users", "2")).ShouldBeNull();
(await cache.Get("users", "1")).ShouldNotBeNull();
(await cache.Get("users", "3")).ShouldNotBeNull();
}
/// <summary>
/// Verifies remove and clear operations delete entries from the cache.
/// </summary>
[Fact]
public async Task RemoveAndClear_ShouldDeleteEntriesFromCache()
{
var cache = new DocumentCache(CreateConfigProvider(maxDocumentCacheSize: 3));
await cache.Set("users", "1", CreateDocument("users", "1"));
await cache.Set("users", "2", CreateDocument("users", "2"));
cache.GetStatistics().Size.ShouldBe(2);
cache.Remove("users", "1");
(await cache.Get("users", "1")).ShouldBeNull();
cache.GetStatistics().Size.ShouldBe(1);
cache.Clear();
cache.GetStatistics().Size.ShouldBe(0);
}
private static Document CreateDocument(string collection, string key)
{
using var json = JsonDocument.Parse("""{"name":"test"}""");
return new Document(collection, key, json.RootElement.Clone(), new HlcTimestamp(1, 0, "node-a"), false);
}
private static IPeerNodeConfigurationProvider CreateConfigProvider(int maxDocumentCacheSize)
{
var configProvider = Substitute.For<IPeerNodeConfigurationProvider>();
configProvider.GetConfiguration().Returns(new PeerNodeConfiguration
{
MaxDocumentCacheSize = maxDocumentCacheSize
});
return configProvider;
}
}

View File

@@ -0,0 +1,92 @@
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Sync;
namespace ZB.MOM.WW.CBDDC.Core.Tests;
public class OfflineQueueTests
{
/// <summary>
/// Verifies that enqueuing beyond capacity drops the oldest operation.
/// </summary>
[Fact]
public async Task Enqueue_WhenQueueIsFull_DropsOldestOperation()
{
var queue = new OfflineQueue(CreateConfigProvider(maxQueueSize: 2));
await queue.Enqueue(CreateOperation("1"));
await queue.Enqueue(CreateOperation("2"));
await queue.Enqueue(CreateOperation("3"));
var flushed = new List<string>();
(int successful, int failed) = await queue.FlushAsync(op =>
{
flushed.Add(op.Key);
return Task.CompletedTask;
});
successful.ShouldBe(2);
failed.ShouldBe(0);
flushed.ShouldBe(["2", "3"]);
}
/// <summary>
/// Verifies that flush continues when an executor throws and returns the failure count.
/// </summary>
[Fact]
public async Task FlushAsync_WhenExecutorThrows_ContinuesAndReturnsFailureCount()
{
var queue = new OfflineQueue(CreateConfigProvider(maxQueueSize: 10));
await queue.Enqueue(CreateOperation("1"));
await queue.Enqueue(CreateOperation("2"));
(int successful, int failed) = await queue.FlushAsync(op =>
{
if (op.Key == "1") throw new InvalidOperationException("boom");
return Task.CompletedTask;
});
successful.ShouldBe(1);
failed.ShouldBe(1);
queue.Count.ShouldBe(0);
}
/// <summary>
/// Verifies that clear removes all queued operations.
/// </summary>
[Fact]
public async Task Clear_RemovesAllQueuedOperations()
{
var queue = new OfflineQueue(CreateConfigProvider(maxQueueSize: 10));
await queue.Enqueue(CreateOperation("1"));
await queue.Enqueue(CreateOperation("2"));
queue.Count.ShouldBe(2);
await queue.Clear();
queue.Count.ShouldBe(0);
(int successful, int failed) = await queue.FlushAsync(_ => Task.CompletedTask);
successful.ShouldBe(0);
failed.ShouldBe(0);
}
private static PendingOperation CreateOperation(string key)
{
return new PendingOperation
{
Type = "upsert",
Collection = "users",
Key = key,
Data = new { Value = key },
QueuedAt = DateTime.UtcNow
};
}
private static IPeerNodeConfigurationProvider CreateConfigProvider(int maxQueueSize)
{
var configProvider = Substitute.For<IPeerNodeConfigurationProvider>();
configProvider.GetConfiguration().Returns(new PeerNodeConfiguration
{
MaxQueueSize = maxQueueSize
});
return configProvider;
}
}

View File

@@ -0,0 +1,78 @@
using ZB.MOM.WW.CBDDC.Core.Exceptions;
using ZB.MOM.WW.CBDDC.Core.Network;
using ZB.MOM.WW.CBDDC.Core.Resilience;
namespace ZB.MOM.WW.CBDDC.Core.Tests;
public class RetryPolicyTests
{
/// <summary>
/// Verifies transient failures are retried until a successful result is returned.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenTransientFailureEventuallySucceeds_RetriesAndReturnsResult()
{
var policy = new RetryPolicy(CreateConfigProvider(retryAttempts: 3, retryDelayMs: 1));
var attempts = 0;
int result = await policy.ExecuteAsync(async () =>
{
attempts++;
if (attempts < 3) throw new NetworkException("transient");
await Task.CompletedTask;
return 42;
}, "test-op");
result.ShouldBe(42);
attempts.ShouldBe(3);
}
/// <summary>
/// Verifies transient failures throw retry exhausted when all retries are consumed.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenTransientFailureExhausted_ThrowsRetryExhaustedException()
{
var policy = new RetryPolicy(CreateConfigProvider(retryAttempts: 2, retryDelayMs: 1));
var attempts = 0;
var ex = await Should.ThrowAsync<CBDDCException>(() => policy.ExecuteAsync<int>(() =>
{
attempts++;
throw new NetworkException("still transient");
}, "test-op"));
ex.ErrorCode.ShouldBe("RETRY_EXHAUSTED");
ex.InnerException.ShouldBeOfType<NetworkException>();
attempts.ShouldBe(2);
}
/// <summary>
/// Verifies non-transient failures are not retried.
/// </summary>
[Fact]
public async Task ExecuteAsync_WhenFailureIsNonTransient_DoesNotRetry()
{
var policy = new RetryPolicy(CreateConfigProvider(retryAttempts: 3, retryDelayMs: 1));
var attempts = 0;
await Should.ThrowAsync<InvalidOperationException>(() => policy.ExecuteAsync<int>(() =>
{
attempts++;
throw new InvalidOperationException("non-transient");
}, "test-op"));
attempts.ShouldBe(1);
}
private static IPeerNodeConfigurationProvider CreateConfigProvider(int retryAttempts, int retryDelayMs)
{
var configProvider = Substitute.For<IPeerNodeConfigurationProvider>();
configProvider.GetConfiguration().Returns(new PeerNodeConfiguration
{
RetryAttempts = retryAttempts,
RetryDelayMs = retryDelayMs
});
return configProvider;
}
}

View File

@@ -428,12 +428,12 @@ public class ClusterCrudSyncE2ETests
&& replicated.Address?.City == payload.Address?.City;
}, 60, "Node B did not converge after crash-window recovery.", () => BuildDiagnostics(recoveredNodeA, nodeB));
await AssertEventuallyAsync(
() => recoveredNodeA.GetOplogCountForKey("Users", userId) == 1 &&
nodeB.GetOplogCountForKey("Users", userId) == 1,
60,
"Crash-window recovery created duplicate oplog entries.",
() => BuildDiagnostics(recoveredNodeA, nodeB));
await AssertEventuallyAsync(
() => recoveredNodeA.GetOplogCountForKey("Users", userId) == 1 &&
nodeB.GetOplogCountForKey("Users", userId) == 1,
60,
"Crash-window recovery created duplicate oplog entries.",
() => BuildDiagnostics(recoveredNodeA, nodeB));
}
}
finally
@@ -569,6 +569,9 @@ public class ClusterCrudSyncE2ETests
/// <param name="tcpPort">The TCP port used by the node listener.</param>
/// <param name="authToken">The cluster authentication token.</param>
/// <param name="knownPeers">The known peers this node can connect to.</param>
/// <param name="workDirOverride">An optional working directory override for test artifacts.</param>
/// <param name="preserveWorkDirOnDispose">A value indicating whether to preserve the working directory on dispose.</param>
/// <param name="useFaultInjectedCheckpointStore">A value indicating whether to inject a checkpoint persistence that fails once.</param>
/// <returns>A configured <see cref="TestPeerNode" /> instance.</returns>
public static TestPeerNode Create(
string nodeId,
@@ -690,6 +693,12 @@ public class ClusterCrudSyncE2ETests
return Context.Users.Find(u => u.Id == userId).FirstOrDefault();
}
/// <summary>
/// Gets the local oplog entry count for a collection/key pair produced by this node.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <returns>The number of local oplog entries matching the key.</returns>
public int GetLocalOplogCountForKey(string collection, string key)
{
return Context.OplogEntries.FindAll()
@@ -699,6 +708,12 @@ public class ClusterCrudSyncE2ETests
string.Equals(e.TimestampNodeId, _nodeId, StringComparison.Ordinal));
}
/// <summary>
/// Gets the total oplog entry count for a collection/key pair across nodes.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <returns>The number of oplog entries matching the key.</returns>
public int GetOplogCountForKey(string collection, string key)
{
return Context.OplogEntries.FindAll()
@@ -824,6 +839,14 @@ public class ClusterCrudSyncE2ETests
private const string UsersCollection = "Users";
private const string TodoListsCollection = "TodoLists";
/// <summary>
/// Initializes a new instance of the <see cref="FaultInjectedSampleDocumentStore" /> class.
/// </summary>
/// <param name="context">The sample database context.</param>
/// <param name="configProvider">The peer node configuration provider.</param>
/// <param name="vectorClockService">The vector clock service.</param>
/// <param name="checkpointPersistence">The checkpoint persistence implementation.</param>
/// <param name="logger">The optional logger instance.</param>
public FaultInjectedSampleDocumentStore(
SampleDbContext context,
IPeerNodeConfigurationProvider configProvider,
@@ -849,6 +872,7 @@ public class ClusterCrudSyncE2ETests
WatchCollection(TodoListsCollection, context.TodoLists, t => t.Id);
}
/// <inheritdoc />
protected override async Task ApplyContentToEntityAsync(
string collection,
string key,
@@ -858,6 +882,7 @@ public class ClusterCrudSyncE2ETests
await UpsertEntityAsync(collection, key, content, cancellationToken);
}
/// <inheritdoc />
protected override async Task ApplyContentToEntitiesBatchAsync(
IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
CancellationToken cancellationToken)
@@ -866,6 +891,7 @@ public class ClusterCrudSyncE2ETests
await UpsertEntityAsync(collection, key, content, cancellationToken);
}
/// <inheritdoc />
protected override async Task<JsonElement?> GetEntityAsJsonAsync(
string collection,
string key,
@@ -879,6 +905,7 @@ public class ClusterCrudSyncE2ETests
};
}
/// <inheritdoc />
protected override async Task RemoveEntityAsync(
string collection,
string key,
@@ -887,6 +914,7 @@ public class ClusterCrudSyncE2ETests
await DeleteEntityAsync(collection, key, cancellationToken);
}
/// <inheritdoc />
protected override async Task RemoveEntitiesBatchAsync(
IEnumerable<(string Collection, string Key)> documents,
CancellationToken cancellationToken)
@@ -895,6 +923,7 @@ public class ClusterCrudSyncE2ETests
await DeleteEntityAsync(collection, key, cancellationToken);
}
/// <inheritdoc />
protected override async Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
string collection,
CancellationToken cancellationToken)
@@ -967,6 +996,7 @@ public class ClusterCrudSyncE2ETests
{
private int _failOnNextAdvance = 1;
/// <inheritdoc />
public Task<SurrealCdcCheckpoint?> GetCheckpointAsync(
string? consumerId = null,
CancellationToken cancellationToken = default)
@@ -974,6 +1004,7 @@ public class ClusterCrudSyncE2ETests
return Task.FromResult<SurrealCdcCheckpoint?>(null);
}
/// <inheritdoc />
public Task UpsertCheckpointAsync(
HlcTimestamp timestamp,
string lastHash,
@@ -984,6 +1015,7 @@ public class ClusterCrudSyncE2ETests
return Task.CompletedTask;
}
/// <inheritdoc />
public Task AdvanceCheckpointAsync(
OplogEntry entry,
string? consumerId = null,

View File

@@ -13,6 +13,9 @@ namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests;
[Collection("SurrealCdcDurability")]
public class SurrealCdcDurabilityTests
{
/// <summary>
/// Verifies checkpoints persist latest local changes per consumer across restarts.
/// </summary>
[Fact]
public async Task CheckpointPersistence_ShouldTrackLatestLocalChange_AndPersistPerConsumer()
{
@@ -85,6 +88,9 @@ public class SurrealCdcDurabilityTests
}
}
/// <summary>
/// Verifies recovery resumes from a persisted checkpoint and advances after catch-up.
/// </summary>
[Fact]
public async Task RestartRecovery_ShouldResumeCatchUpFromPersistedCheckpoint_InRocksDb()
{
@@ -153,6 +159,9 @@ public class SurrealCdcDurabilityTests
}
}
/// <summary>
/// Verifies duplicate remote apply windows are idempotent without loopback entries.
/// </summary>
[Fact]
public async Task RemoteApply_ShouldBeIdempotentAcrossDuplicateWindow_WithoutLoopbackEntries()
{
@@ -207,6 +216,9 @@ public class SurrealCdcDurabilityTests
}
}
/// <summary>
/// Verifies local deletes persist tombstone metadata and advance checkpoints.
/// </summary>
[Fact]
public async Task LocalDelete_ShouldPersistTombstoneMetadata_AndAdvanceCheckpoint()
{
@@ -358,21 +370,46 @@ internal sealed class CdcTestHarness : IAsyncDisposable
NullLogger<SurrealDocumentMetadataStore>.Instance);
}
/// <summary>
/// Gets the sample database context.
/// </summary>
public SampleDbContext Context { get; }
/// <summary>
/// Gets the checkpointed sample document store.
/// </summary>
public CheckpointedSampleDocumentStore DocumentStore { get; }
/// <summary>
/// Gets the oplog store used by the harness.
/// </summary>
public SurrealOplogStore OplogStore { get; }
/// <summary>
/// Gets the document metadata store.
/// </summary>
public SurrealDocumentMetadataStore MetadataStore { get; }
/// <summary>
/// Gets checkpoint persistence used for CDC progress tracking.
/// </summary>
public ISurrealCdcCheckpointPersistence CheckpointPersistence { get; }
/// <summary>
/// Polls CDC once through the document store.
/// </summary>
public async Task PollAsync()
{
await DocumentStore.PollCdcOnceAsync();
}
/// <summary>
/// Creates a harness instance with retries for transient RocksDB lock contention.
/// </summary>
/// <param name="databasePath">Database directory path.</param>
/// <param name="nodeId">Node identifier.</param>
/// <param name="consumerId">CDC consumer identifier.</param>
/// <returns>Initialized test harness.</returns>
public static async Task<CdcTestHarness> OpenWithRetriesAsync(
string databasePath,
string nodeId,
@@ -391,6 +428,11 @@ internal sealed class CdcTestHarness : IAsyncDisposable
throw new InvalidOperationException("Unable to acquire RocksDB lock for test harness.");
}
/// <summary>
/// Gets oplog entries for a collection ordered by timestamp.
/// </summary>
/// <param name="collection">Collection name.</param>
/// <returns>Ordered oplog entries.</returns>
public async Task<List<OplogEntry>> GetEntriesByCollectionAsync(string collection)
{
return (await OplogStore.ExportAsync())
@@ -400,6 +442,12 @@ internal sealed class CdcTestHarness : IAsyncDisposable
.ToList();
}
/// <summary>
/// Gets oplog entries for a collection key ordered by timestamp.
/// </summary>
/// <param name="collection">Collection name.</param>
/// <param name="key">Document key.</param>
/// <returns>Ordered oplog entries.</returns>
public async Task<List<OplogEntry>> GetEntriesByKeyAsync(string collection, string key)
{
return (await OplogStore.ExportAsync())
@@ -410,6 +458,7 @@ internal sealed class CdcTestHarness : IAsyncDisposable
.ToList();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
DocumentStore.Dispose();
@@ -428,6 +477,15 @@ internal sealed class CheckpointedSampleDocumentStore : SurrealDocumentStore<Sam
private const string UsersCollection = "Users";
private const string TodoListsCollection = "TodoLists";
/// <summary>
/// Initializes a new instance of the <see cref="CheckpointedSampleDocumentStore"/> class.
/// </summary>
/// <param name="context">Sample database context.</param>
/// <param name="configProvider">Peer configuration provider.</param>
/// <param name="vectorClockService">Vector clock service.</param>
/// <param name="checkpointPersistence">Checkpoint persistence implementation.</param>
/// <param name="surrealOptions">Optional Surreal embedded options.</param>
/// <param name="logger">Optional logger.</param>
public CheckpointedSampleDocumentStore(
SampleDbContext context,
IPeerNodeConfigurationProvider configProvider,
@@ -450,6 +508,7 @@ internal sealed class CheckpointedSampleDocumentStore : SurrealDocumentStore<Sam
WatchCollection(TodoListsCollection, context.TodoLists, t => t.Id, subscribeForInMemoryEvents: false);
}
/// <inheritdoc />
protected override async Task ApplyContentToEntityAsync(
string collection,
string key,
@@ -459,6 +518,7 @@ internal sealed class CheckpointedSampleDocumentStore : SurrealDocumentStore<Sam
await UpsertEntityAsync(collection, key, content, cancellationToken);
}
/// <inheritdoc />
protected override async Task ApplyContentToEntitiesBatchAsync(
IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
CancellationToken cancellationToken)
@@ -467,6 +527,7 @@ internal sealed class CheckpointedSampleDocumentStore : SurrealDocumentStore<Sam
await UpsertEntityAsync(collection, key, content, cancellationToken);
}
/// <inheritdoc />
protected override async Task<JsonElement?> GetEntityAsJsonAsync(
string collection,
string key,
@@ -480,6 +541,7 @@ internal sealed class CheckpointedSampleDocumentStore : SurrealDocumentStore<Sam
};
}
/// <inheritdoc />
protected override async Task RemoveEntityAsync(
string collection,
string key,
@@ -488,6 +550,7 @@ internal sealed class CheckpointedSampleDocumentStore : SurrealDocumentStore<Sam
await DeleteEntityAsync(collection, key, cancellationToken);
}
/// <inheritdoc />
protected override async Task RemoveEntitiesBatchAsync(
IEnumerable<(string Collection, string Key)> documents,
CancellationToken cancellationToken)
@@ -496,6 +559,7 @@ internal sealed class CheckpointedSampleDocumentStore : SurrealDocumentStore<Sam
await DeleteEntityAsync(collection, key, cancellationToken);
}
/// <inheritdoc />
protected override async Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
string collection,
CancellationToken cancellationToken)

View File

@@ -13,6 +13,11 @@ namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests;
public class SurrealCdcMatrixCompletionTests
{
/// <summary>
/// Verifies retention-boundary classifier behavior across expected exception message patterns.
/// </summary>
/// <param name="message">The exception message sample.</param>
/// <param name="expected">Expected classifier outcome.</param>
[Theory]
[InlineData("versionstamp is outside the configured retention window", true)]
[InlineData("change feed history since cursor is unavailable", true)]
@@ -29,6 +34,9 @@ public class SurrealCdcMatrixCompletionTests
actual.ShouldBe(expected);
}
/// <summary>
/// Verifies a local write produces exactly one oplog entry.
/// </summary>
[Fact]
public async Task LocalWrite_ShouldEmitExactlyOneOplogEntry()
{
@@ -63,6 +71,9 @@ public class SurrealCdcMatrixCompletionTests
}
}
/// <summary>
/// Verifies checkpoint persistence does not advance when atomic write fails.
/// </summary>
[Fact]
public async Task Checkpoint_ShouldNotAdvance_WhenAtomicWriteFails()
{
@@ -139,6 +150,14 @@ public class SurrealCdcMatrixCompletionTests
internal sealed class FailureInjectedDocumentStore : SurrealDocumentStore<object>
{
/// <summary>
/// Initializes a new instance of the <see cref="FailureInjectedDocumentStore" /> class.
/// </summary>
/// <param name="surrealEmbeddedClient">The embedded Surreal client provider.</param>
/// <param name="schemaInitializer">The schema initializer.</param>
/// <param name="configProvider">The node configuration provider.</param>
/// <param name="vectorClockService">The vector clock service.</param>
/// <param name="checkpointPersistence">The CDC checkpoint persistence dependency.</param>
public FailureInjectedDocumentStore(
ICBDDCSurrealEmbeddedClient surrealEmbeddedClient,
ICBDDCSurrealSchemaInitializer schemaInitializer,
@@ -158,6 +177,15 @@ internal sealed class FailureInjectedDocumentStore : SurrealDocumentStore<object
{
}
/// <summary>
/// Triggers local change handling for testing failure scenarios.
/// </summary>
/// <param name="collection">The collection name.</param>
/// <param name="key">The document key.</param>
/// <param name="operationType">The operation type.</param>
/// <param name="content">Optional document content payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when processing is finished.</returns>
public Task TriggerLocalChangeAsync(
string collection,
string key,
@@ -174,6 +202,7 @@ internal sealed class FailureInjectedDocumentStore : SurrealDocumentStore<object
cancellationToken);
}
/// <inheritdoc />
protected override Task ApplyContentToEntityAsync(
string collection,
string key,
@@ -183,6 +212,7 @@ internal sealed class FailureInjectedDocumentStore : SurrealDocumentStore<object
return Task.CompletedTask;
}
/// <inheritdoc />
protected override Task ApplyContentToEntitiesBatchAsync(
IEnumerable<(string Collection, string Key, JsonElement Content)> documents,
CancellationToken cancellationToken)
@@ -190,6 +220,7 @@ internal sealed class FailureInjectedDocumentStore : SurrealDocumentStore<object
return Task.CompletedTask;
}
/// <inheritdoc />
protected override Task<JsonElement?> GetEntityAsJsonAsync(
string collection,
string key,
@@ -198,11 +229,13 @@ internal sealed class FailureInjectedDocumentStore : SurrealDocumentStore<object
return Task.FromResult<JsonElement?>(null);
}
/// <inheritdoc />
protected override Task RemoveEntityAsync(string collection, string key, CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc />
protected override Task RemoveEntitiesBatchAsync(
IEnumerable<(string Collection, string Key)> documents,
CancellationToken cancellationToken)
@@ -210,6 +243,7 @@ internal sealed class FailureInjectedDocumentStore : SurrealDocumentStore<object
return Task.CompletedTask;
}
/// <inheritdoc />
protected override Task<IEnumerable<(string Key, JsonElement Content)>> GetAllEntitiesAsJsonAsync(
string collection,
CancellationToken cancellationToken)

View File

@@ -0,0 +1,98 @@
using SurrealDb.Net.Models.Response;
using ZB.MOM.WW.CBDDC.Persistence.Surreal;
namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests;
public class SurrealInfrastructureReadinessTests
{
/// <summary>
/// Verifies schema initialization runs only once for repeated calls.
/// </summary>
[Fact]
public async Task SchemaInitializer_ShouldApplySchemaOnlyOnce()
{
var embeddedClient = Substitute.For<ICBDDCSurrealEmbeddedClient>();
embeddedClient.RawQueryAsync(
Arg.Any<string>(),
Arg.Any<IReadOnlyDictionary<string, object?>>(),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult(default(SurrealDbResponse)!));
var options = new CBDDCSurrealEmbeddedOptions
{
Cdc = new CBDDCSurrealCdcOptions
{
CheckpointTable = "custom_checkpoint",
RetentionDuration = TimeSpan.FromHours(12)
}
};
var initializer = new CBDDCSurrealSchemaInitializer(embeddedClient, options);
await initializer.EnsureInitializedAsync();
await initializer.EnsureInitializedAsync();
await embeddedClient.Received(1).RawQueryAsync(
Arg.Is<string>(sql =>
sql.Contains("DEFINE TABLE OVERWRITE cbddc_oplog_entries", StringComparison.Ordinal) &&
sql.Contains("CHANGEFEED 12h", StringComparison.Ordinal) &&
sql.Contains("DEFINE TABLE OVERWRITE custom_checkpoint", StringComparison.Ordinal)),
Arg.Any<IReadOnlyDictionary<string, object?>>(),
Arg.Any<CancellationToken>());
}
/// <summary>
/// Verifies invalid checkpoint table identifiers are rejected.
/// </summary>
[Fact]
public void SchemaInitializer_WhenCheckpointIdentifierIsInvalid_ThrowsArgumentException()
{
var embeddedClient = Substitute.For<ICBDDCSurrealEmbeddedClient>();
var options = new CBDDCSurrealEmbeddedOptions
{
Cdc = new CBDDCSurrealCdcOptions
{
CheckpointTable = "invalid-checkpoint"
}
};
Should.Throw<ArgumentException>(() => new CBDDCSurrealSchemaInitializer(embeddedClient, options));
}
/// <summary>
/// Verifies readiness returns true when schema init and health checks succeed.
/// </summary>
[Fact]
public async Task ReadinessProbe_WhenSchemaAndHealthSucceed_ReturnsTrue()
{
var embeddedClient = Substitute.For<ICBDDCSurrealEmbeddedClient>();
embeddedClient.HealthAsync(Arg.Any<CancellationToken>()).Returns(true);
var schemaInitializer = Substitute.For<ICBDDCSurrealSchemaInitializer>();
schemaInitializer.EnsureInitializedAsync(Arg.Any<CancellationToken>()).Returns(Task.CompletedTask);
var probe = new CBDDCSurrealReadinessProbe(embeddedClient, schemaInitializer);
bool isReady = await probe.IsReadyAsync();
isReady.ShouldBeTrue();
await schemaInitializer.Received(1).EnsureInitializedAsync(Arg.Any<CancellationToken>());
await embeddedClient.Received(1).HealthAsync(Arg.Any<CancellationToken>());
}
/// <summary>
/// Verifies readiness returns false when schema initialization fails.
/// </summary>
[Fact]
public async Task ReadinessProbe_WhenSchemaInitializationThrows_ReturnsFalse()
{
var embeddedClient = Substitute.For<ICBDDCSurrealEmbeddedClient>();
var schemaInitializer = Substitute.For<ICBDDCSurrealSchemaInitializer>();
schemaInitializer.EnsureInitializedAsync(Arg.Any<CancellationToken>())
.Returns(Task.FromException(new InvalidOperationException("boom")));
var probe = new CBDDCSurrealReadinessProbe(embeddedClient, schemaInitializer);
bool isReady = await probe.IsReadyAsync();
isReady.ShouldBeFalse();
await embeddedClient.DidNotReceive().HealthAsync(Arg.Any<CancellationToken>());
}
}

View File

@@ -11,6 +11,9 @@ namespace ZB.MOM.WW.CBDDC.Sample.Console.Tests;
public class SurrealOplogStoreContractTests
{
/// <summary>
/// Verifies append, range query, merge, drop, and last-hash behavior for the oplog store.
/// </summary>
[Fact]
public async Task OplogStore_AppendQueryMergeDrop_AndLastHash_Works()
{
@@ -71,6 +74,9 @@ public class SurrealOplogStoreContractTests
public class SurrealDocumentMetadataStoreContractTests
{
/// <summary>
/// Verifies upsert, deletion marking, incremental reads, and merge precedence for document metadata.
/// </summary>
[Fact]
public async Task DocumentMetadataStore_UpsertMarkDeletedGetAfterAndMergeNewer_Works()
{
@@ -108,6 +114,9 @@ public class SurrealDocumentMetadataStoreContractTests
public class SurrealPeerConfigurationStoreContractTests
{
/// <summary>
/// Verifies save, read, remove, and merge behavior for remote peer configuration records.
/// </summary>
[Fact]
public async Task PeerConfigurationStore_SaveGetRemoveAndMerge_Works()
{
@@ -163,6 +172,9 @@ public class SurrealPeerConfigurationStoreContractTests
public class SurrealPeerOplogConfirmationStoreContractTests
{
/// <summary>
/// Verifies peer registration, confirmation updates, and peer deactivation semantics.
/// </summary>
[Fact]
public async Task PeerOplogConfirmationStore_EnsureUpdateAndDeactivate_Works()
{
@@ -194,6 +206,9 @@ public class SurrealPeerOplogConfirmationStoreContractTests
afterDeactivate.All(x => x.IsActive == false).ShouldBeTrue();
}
/// <summary>
/// Verifies merge semantics prefer newer confirmations and preserve active-state transitions.
/// </summary>
[Fact]
public async Task PeerOplogConfirmationStore_Merge_UsesNewerAndActiveStateSemantics()
{
@@ -262,6 +277,9 @@ public class SurrealPeerOplogConfirmationStoreContractTests
public class SurrealSnapshotMetadataStoreContractTests
{
/// <summary>
/// Verifies insert, update, merge, and hash lookup behavior for snapshot metadata records.
/// </summary>
[Fact]
public async Task SnapshotMetadataStore_InsertUpdateMergeAndHashLookup_Works()
{
@@ -333,6 +351,9 @@ internal sealed class SurrealTestHarness : IAsyncDisposable
private readonly string _rootPath;
private readonly ICBDDCSurrealSchemaInitializer _schemaInitializer;
/// <summary>
/// Initializes a temporary embedded Surreal environment for contract tests.
/// </summary>
public SurrealTestHarness()
{
string suffix = Guid.NewGuid().ToString("N");
@@ -351,6 +372,9 @@ internal sealed class SurrealTestHarness : IAsyncDisposable
_schemaInitializer = new TestSurrealSchemaInitializer(_client);
}
/// <summary>
/// Creates a document metadata store instance bound to the test harness database.
/// </summary>
public SurrealDocumentMetadataStore CreateDocumentMetadataStore()
{
return new SurrealDocumentMetadataStore(
@@ -359,6 +383,9 @@ internal sealed class SurrealTestHarness : IAsyncDisposable
NullLogger<SurrealDocumentMetadataStore>.Instance);
}
/// <summary>
/// Creates an oplog store instance bound to the test harness database.
/// </summary>
public SurrealOplogStore CreateOplogStore()
{
return new SurrealOplogStore(
@@ -371,6 +398,9 @@ internal sealed class SurrealTestHarness : IAsyncDisposable
NullLogger<SurrealOplogStore>.Instance);
}
/// <summary>
/// Creates a peer configuration store instance bound to the test harness database.
/// </summary>
public SurrealPeerConfigurationStore CreatePeerConfigurationStore()
{
return new SurrealPeerConfigurationStore(
@@ -379,6 +409,9 @@ internal sealed class SurrealTestHarness : IAsyncDisposable
NullLogger<SurrealPeerConfigurationStore>.Instance);
}
/// <summary>
/// Creates a peer oplog confirmation store instance bound to the test harness database.
/// </summary>
public SurrealPeerOplogConfirmationStore CreatePeerOplogConfirmationStore()
{
return new SurrealPeerOplogConfirmationStore(
@@ -387,6 +420,9 @@ internal sealed class SurrealTestHarness : IAsyncDisposable
NullLogger<SurrealPeerOplogConfirmationStore>.Instance);
}
/// <summary>
/// Creates a snapshot metadata store instance bound to the test harness database.
/// </summary>
public SurrealSnapshotMetadataStore CreateSnapshotMetadataStore()
{
return new SurrealSnapshotMetadataStore(
@@ -395,6 +431,7 @@ internal sealed class SurrealTestHarness : IAsyncDisposable
NullLogger<SurrealSnapshotMetadataStore>.Instance);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
await _client.DisposeAsync();
@@ -421,11 +458,16 @@ internal sealed class TestSurrealSchemaInitializer : ICBDDCSurrealSchemaInitiali
private readonly ICBDDCSurrealEmbeddedClient _client;
private int _initialized;
/// <summary>
/// Initializes a new instance of the <see cref="TestSurrealSchemaInitializer" /> class.
/// </summary>
/// <param name="client">The embedded client to initialize.</param>
public TestSurrealSchemaInitializer(ICBDDCSurrealEmbeddedClient client)
{
_client = client;
}
/// <inheritdoc />
public async Task EnsureInitializedAsync(CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref _initialized, 1) == 1) return;