test(reminders): add provider conformance TestKit - #10785
Conversation
e9d54e2 to
3952206
Compare
There was a problem hiding this comment.
Pull request overview
Adds a reusable Reminder TestKit with oracle-backed, model-based, service-level, and cluster-level provider conformance tests.
Changes:
- Adds deterministic reminder-table contracts, capabilities, diagnostics, and test runners.
- Adds oracle, faulty-provider, and integration self-tests.
- Integrates shared conformance coverage across built-in providers.
Unresolved findings:
- Critical: Concurrent upsert guarantees conflict with Azure’s contention behavior.
- Critical: Azure restart conformance fails because
StopAsynccancels initialization permanently. - Critical: ADO.NET signed hash comparisons break ranges crossing
0x7fffffff. - Critical: DynamoDB GSI eventual consistency can cause immediate range checks to miss rows.
- Nit: The README references nonexistent
SupportsConcurrentUpsertsinstead ofSupportsConcurrentOperations.
Show a summary per file
| File | Summary |
|---|---|
test/Orleans.Runtime.Internal.Tests/RemindersTest/ReminderTableTestsBaseTests.cs |
Tests shared range-runner behavior. |
test/Orleans.Runtime.Internal.Tests/RemindersTest/ReminderTableTestsBase.cs |
Integrates shared provider conformance tests. |
test/Orleans.Runtime.Internal.Tests/Orleans.Runtime.Internal.Tests.csproj |
References the Reminder TestKit. |
test/Orleans.Reminders.TestKit.Tests/XunitReminderTableTestAdapter.cs |
Adapts capability skips for xUnit. |
test/Orleans.Reminders.TestKit.Tests/ReminderTestKitGrain.cs |
Provides integration-test grain behavior. |
test/Orleans.Reminders.TestKit.Tests/ReminderTestKitClusterIntegrationTests.cs |
Tests cluster integration. |
test/Orleans.Reminders.TestKit.Tests/ReminderServiceConformanceTests.cs |
Tests service-level conformance. |
test/Orleans.Reminders.TestKit.Tests/Orleans.Reminders.TestKit.Tests.csproj |
Defines TestKit self-tests. |
test/Orleans.Reminders.TestKit.Tests/InMemoryReminderTableConformanceTests.cs |
Runs in-memory conformance tests. |
test/Orleans.Reminders.TestKit.Tests/IdealizedReminderTableTests.cs |
Tests oracle behavior. |
test/Orleans.Reminders.TestKit.Tests/FaultyReminderTableTests.cs |
Verifies faulty implementation detection. |
test/Extensions/Orleans.Reminders.Firestore.Tests/Orleans.Reminders.Firestore.Tests.csproj |
Integrates the TestKit with Firestore tests. |
test/Extensions/Orleans.Redis.Tests/Orleans.Redis.Tests.csproj |
Integrates the TestKit with Redis tests. |
test/Extensions/Orleans.Cosmos.Tests/Orleans.Cosmos.Tests.csproj |
Integrates the TestKit with Cosmos tests. |
test/Extensions/Orleans.Cosmos.Tests/CosmosRemindersTableTests.cs |
Retains Cosmos-specific reminder tests. |
test/Extensions/Orleans.Azure.Tests/Orleans.Azure.Tests.csproj |
Integrates the TestKit with Azure tests. |
test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj |
Integrates the TestKit with AWS tests. |
test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj |
Integrates the TestKit with ADO.NET tests. |
test/Directory.Build.props |
Configures shared test infrastructure. |
src/Orleans.Reminders.TestKit/ReminderTestKitDiagnostics.cs |
Formats conformance diagnostics. |
src/Orleans.Reminders.TestKit/ReminderTestKitClusterBuilderExtensions.cs |
Installs the oracle in test clusters. |
src/Orleans.Reminders.TestKit/ReminderTestData.cs |
Supplies deterministic test data. |
src/Orleans.Reminders.TestKit/ReminderTableTestFixture.cs |
Provides reusable test fixtures. |
src/Orleans.Reminders.TestKit/ReminderTableModelBasedTestRunner.cs |
Implements model-based testing. |
src/Orleans.Reminders.TestKit/ReminderTableIntrospection.cs |
Provides table inspection helpers. |
src/Orleans.Reminders.TestKit/ReminderTableEntrySnapshot.cs |
Represents captured reminder state. |
src/Orleans.Reminders.TestKit/ReminderTableCapabilities.cs |
Defines provider capability manifests. |
src/Orleans.Reminders.TestKit/ReminderServiceTestRunner.cs |
Implements service conformance tests. |
src/Orleans.Reminders.TestKit/README.md |
Documents TestKit usage and capabilities. |
src/Orleans.Reminders.TestKit/Orleans.Reminders.TestKit.csproj |
Defines the TestKit project. |
src/Orleans.Reminders.TestKit/IdealizedReminderTable.cs |
Provides the deterministic reference table. |
src/api/Orleans.Reminders.TestKit/Orleans.Reminders.TestKit.cs |
Updates the generated API surface. |
Orleans.slnx |
Registers the new TestKit projects. |
Review details
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Suppressed comments (7)
src/Orleans.Reminders.TestKit/ReminderTableCapabilities.cs:69
- The capability documentation says that upsert is blind in every built-in provider, but Cosmos and Firestore pass the supplied ETag as an
IfMatchEtag/update precondition and support conditional upserts. This guidance is incorrect and conflicts with the capability the class exposes; document the portable default separately from the providers which can opt in.
/// Default is <see langword="false"/>. Upsert is a blind write in every built-in Orleans reminder provider;
/// <see cref="IReminderTable.RemoveRow"/> is the only conditional operation in the contract. Providers which
/// implement conditional upsert may opt in, which enables the stale-ETag rejection guarantee.
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1415
- If an upsert succeeds for one or two entries and a later upsert throws, this method exits before returning a
RangeFixture, so none of the already-created rows can reach the callers'finally/CleanupAsyncblocks. That leaves durable test data behind in external reminder stores, especially when failures are injected; clean up the entries created so far before rethrowing.
var entry = NewEntry(grainId, name, BaseTime.AddMinutes(items.Count), TimeSpan.FromMinutes(items.Count + 1));
var etag = await UpsertAsync(entry, guarantee);
items.Add(new RangeItem(entry, hash, etag));
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1098
- The isolated table is started here but is never stopped or cleared, including when the subsequent upsert/read assertions throw. Any provider enabling cross-table isolation can therefore leak a remote table's rows or lifecycle resources across tests. Wrap the isolated-table exercise in cleanup which stops/clears it without masking the original failure.
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
await isolated!.StartAsync(cancellation.Token);
var grainId = NewGrainId("isolation");
var entry = NewEntry(grainId, "isolation", BaseTime.AddMinutes(13), TimeSpan.FromMinutes(9));
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1540
- The parameter list, opening brace, and body of
CompleteUpsertBatchAsyncare indented one level deeper than the other methods in this class (for example,AssertExactEntriesimmediately above), while the declaration and closing brace remain at class-member indentation. Please align the continuation lines and body with the surrounding method style.
List<(ReminderEntry Entry, Task<string?> Upsert)> pending,
List<(ReminderEntry Entry, string ETag)> created,
string guarantee)
{
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:645
- This guarantee claims to validate movement between loading windows and a grain-scoped read, but
ReadRows(0, 0)is only a full-table hash-range enumeration; it has no loading-window or grain parameter. Consequently this test cannot detect a provider/service bug in due-window membership or grain-scoped enumeration, while its name and documentation imply that it does. Either rename/narrow the guarantee to schedule round-tripping or exercise the service's actual loading-window and grain-scoped paths.
var rows = RequireRows(Guarantee, "ReadRows(0, 0)", await ReminderTable.ReadRows(0, 0));
AssertExactEntries(
Guarantee,
"ReadRows(0, 0)",
[ReminderTableEntrySnapshot.Create(outside, outsideETag, Capabilities.SupportsSubSecondPrecision)],
rows.Reminders);
test/Orleans.Runtime.Internal.Tests/RemindersTest/ReminderTableTestsBase.cs:88
- These wrapper tests now invoke conformance methods which are also public
[Fact]methods inherited by every provider test class below. For example, the six methods called here are each discovered and run again, doubling external-provider reads/writes and increasing the chance of costly or flaky runs. Remove the duplicate provider wrappers or stop exposing the same scenarios as inherited facts while retaining the desired test categorization.
test/Orleans.Runtime.Internal.Tests/RemindersTest/ReminderTableTestsBase.cs:65 - This integration always supplies
ReminderTableCapabilities.Portable, which disables conditional-upsert coverage for every provider. The shared suite therefore never exercises stale-ETag rejection for providers such as Cosmos and Firestore, even though their implementations support conditional writes and their existing provider tests cover that behavior. Add a provider-specific capability hook and opt those providers in so the common suite actually runs every guarantee each provider supports.
- Files reviewed: 34/34 changed files
- Comments generated: 5
- Review effort level: Lite
There was a problem hiding this comment.
Review details
Suppressed comments (12)
src/Orleans.Reminders.TestKit/IdealizedReminderTable.cs:400
FreezeReadspromises that reads stay on the captured snapshot while writes continue to apply, but clearing_frozenReadshere ends the freeze as a side effect of a write. IfTestOnlyClearTableis called while a freeze handle is active, subsequent writes become visible before that handle is disposed. Keep the freeze active and clear its snapshot so cleanup returns an empty stale view.
_frozenReads = null;
src/Orleans.Reminders.TestKit/README.md:91
- This adoption example uses
SupportsConcurrentUpserts, butReminderTableCapabilitiesexposesSupportsConcurrentOperationsinstead. Copying the documented snippet therefore does not compile; update the example to use the actual capability property.
for simultaneous writers sets `SupportsConcurrentUpserts = false` on its
src/Orleans.Reminders.TestKit/README.md:75
- The fixture exposes an overridable
Capabilitiesproperty, but this example constructs a newPortablecapability set instead, so any capability overrides onMyReminderTableFixtureare silently ignored. Pass the fixture's declared capabilities to the runner so the provider's manifest actually controls which guarantees run.
: base(fixture.ReminderTable, ReminderTableCapabilities.Portable("MyProvider"))
src/Orleans.Reminders.TestKit/ReminderTableTestFixture.cs:112
DeployAsync()runsLocalReminderService's lifecycle callback, which already callsIReminderTable.StartAsyncfor every silo. CallingStartAsyncagain here is not harmless: providers such as Redis and Cosmos allocate/replace their client on initialization, so this fixture can leak or replace a connection on every setup. Resolve and assign the table without reinitializing it.
var reminderTable = ResolveReminderTable(cluster.Silos[0].ServiceProvider);
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
await reminderTable.StartAsync(cancellation.Token).ConfigureAwait(false);
_reminderTable = reminderTable;
src/Orleans.Reminders.TestKit/ReminderTableTestFixture.cs:107
_clusteris assigned only afterDeployAsynccompletes, so any deployment, table-resolution, or table-start failure leaves a built cluster unreachable byDisposeAsync. External-provider startup failures can therefore leak the in-process silos. Record the cluster immediately afterBuild()so the disposal path can clean up partial initialization.
var cluster = builder.Build();
await cluster.DeployAsync().ConfigureAwait(false);
_cluster = cluster;
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1541
- If one task in this batch fails,
Task.WhenAllthrows before any successful results are appended tocreated. Those successful upserts remain in the provider, and the outer cleanup only removescreated, so a transient or conformance failure can contaminate subsequent tests which share this table. Track completed successful tasks before propagating the batch failure.
var etags = await Task.WhenAll(pending.Select(item => item.Upsert));
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:941
- If any concurrent upsert faults, this method exits before the read/removal path and has no
finallycleanup, leaving successful rows in the shared provider table. The next conformance fact can then see those rows and report a misleading failure. Track successful operations and remove them in afinallyblock (or isolate/clear this test's data on failure).
var etags = await Task.WhenAll(Enumerable.Range(0, count).Select(index =>
ReminderTable.UpsertRow(NewEntry(grainId, "concurrent-upsert", BaseTime.AddSeconds(index), TimeSpan.FromMinutes(1)))));
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1415
- When setup fails after one or more earlier upserts, this method throws without returning a
RangeFixture, so its caller has no way to run the fixture's cleanup. The successfully created range rows remain in the shared provider table and can affect later exact-range assertions. Wrap fixture construction in failure-safe cleanup foritems.
var etag = await UpsertAsync(entry, guarantee);
items.Add(new RangeItem(entry, hash, etag));
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:998
- The outer
Task.WhenAllcan fail after some grains have completed, and the innerTask.WhenAllcan fail after some reminders for a grain have been written; either path skips the removal loop below. Successful rows are therefore left in the shared table and can pollute later tests. Add failure-safe cleanup for every completed upsert.
var results = await Task.WhenAll(grains.Select(async grainId =>
{
var etags = await Task.WhenAll(Enumerable.Range(0, perGrain).Select(index =>
ReminderTable.UpsertRow(NewEntry(grainId, $"parallel-{index}", BaseTime.AddSeconds(index), TimeSpan.FromMinutes(1)))));
return (GrainId: grainId, ETags: etags);
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1095
- The runner starts a table created by the
CreateIsolatedTableAsyncextension point but never stops or disposes it. A provider which enables cross-table isolation and allocates a connection per table will leak that resource for every conformance invocation. Put the isolated table in cleanup and stop/dispose it according to the provider's lifetime interfaces.
using var cancellation = new CancellationTokenSource(TimeSpan.FromMinutes(1));
await isolated!.StartAsync(cancellation.Token);
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1242
- Provider calls made through this helper can throw before the runner constructs a
ReminderConformanceException, so a failed operation loses the provider, guarantee, identity, expected/observed values, and ETags promised by the runner's failure contract. The same direct-call pattern exists for reads and removals. Catch operation exceptions and convert them to a report at the operation boundary while preserving the original exception as the inner exception.
{
var etag = await ReminderTable.UpsertRow(entry);
if (string.IsNullOrEmpty(etag))
test/Orleans.Runtime.Internal.Tests/RemindersTest/ReminderTableTestsBase.cs:65
PortableleavesSupportsStopAsyncenabled, but the inherited stop/restart guarantee is not valid for the Azure Table and Firestore implementations: bothStopAsyncpermanently cancel their one-shot initialization task, and a subsequentStartAsynccannot complete it again, so theReadRowafter restart observes a canceled task. This makes the newly shared provider suites fail (and their cleanup can fail too). Either make those providers' lifecycle restartable or declare this capability disabled for the affected suites instead of usingPortableunconditionally.
- Files reviewed: 34/34 changed files
- Comments generated: 4
- Review effort level: Lite
There was a problem hiding this comment.
Review details
Suppressed comments (7)
src/Orleans.Reminders.TestKit/IdealizedReminderTable.cs:406
BeforeOperationAsyncrecords failures before the operation-specific arguments are available, but it has nosuppliedETagparameter. Therefore an unavailable or injectedUpsertRow/RemoveRowis logged withSuppliedETag == nulleven when the caller supplied an ETag, which makes the operation trace incomplete and misleading for failure diagnosis. Thread the supplied ETag through this path and into the failure record.
ReminderTableOperationKind kind,
GrainId? grainId = null,
string? reminderName = null,
uint? begin = null,
uint? end = null)
src/Orleans.Reminders.TestKit/IdealizedReminderTable.cs:95
- This XML documentation says
CreateCapabilitiesreturns a strict capability set, butReminderTableProviderProfiles.Oracleexplicitly disablesSupportsConditionalUpsert. Consumers following this documentation will expect the oracle to exercise every optional guarantee, while the returned manifest silently skips conditional-upsert conformance. Please describe the returned oracle capability profile accurately (or returnStrictif conditional upsert is intended to be part of the oracle contract).
/// Gets the capability set which this reference implementation satisfies.
/// </summary>
/// <returns>A strict capability set naming this instance.</returns>
public ReminderTableCapabilities CreateCapabilities()
=> ReminderTableProviderProfiles.Oracle(Name);
src/Orleans.Reminders.TestKit/ReminderTableTestFixture.cs:107
- The cluster is not assigned to
_clusteruntilDeployAsynccompletes. If deployment throws after allocating cluster resources,DisposeAsynchas no cluster to stop or dispose, leaving the in-process cluster leaked. Dispose the localclusterin a catch/finally around deployment (while preserving the deployment exception).
var cluster = builder.Build();
await cluster.DeployAsync().ConfigureAwait(false);
_cluster = cluster;
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1639
- If
CreateRangeFixtureAsyncfails after one or two upserts, it throws before returning aRangeFixture, so each caller'sfinallyblock is never entered and the already-created reminders remain in the provider. A transient provider failure can therefore contaminate subsequent conformance tests. Clean up partialitemsin afinallyinside this method (or return an owning fixture before performing the writes).
var etag = await UpsertAsync(entry, guarantee);
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:1770
CompleteUpsertBatchAsynconly adds entries tocreatedafterTask.WhenAllsucceeds and after each ETag has been validated. If a batch partially succeeds or one task throws/returns an empty ETag, successful writes from the rest of that batch are not tracked, so the enclosingfinallycannot remove them. Track all successful tasks/ETags even when the batch has a failure, or clear the table as a final fallback.
var etags = await Task.WhenAll(pending.Select(item => item.Upsert));
for (var index = 0; index < pending.Count; index++)
{
var entry = pending[index].Entry;
var etag = etags[index];
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:563
- This guarantee is gated only by
SupportsConditionalUpsert, but the setup first writes the row and then requires the second write to produce a different ETag before it can test a stale value. The Firestore profile enables conditional upsert while explicitly disabling ETag rotation, so a valid same-ETag replacement is reported as a conformance failure. Gate this guarantee on ETag rotation as well, or define a separate capability for obtaining a distinguishable stale token.
var currentETag = await ReminderTableConvergence.ReadUntilAsync(
() => UpsertAsync(current, Guarantee),
etag => !string.Equals(etag, staleETag, StringComparison.Ordinal),
Capabilities,
src/Orleans.Reminders.TestKit/ReminderTableTestRunner.cs:753
- This assertion only tests hash-zero exclusion if the fixture happens to contain a row with hash 0;
CreateRangeFixtureAsyncmerely asks for three distinct generated hashes, soexcludedis normally empty and the(0, uint.MaxValue]boundary is never exercised. Make the fixture deterministically include a hash-zero row (or otherwise inject one) before claiming this boundary guarantee.
var expected = fixtureState.All.Where(item => item.Hash != 0).ToList();
var excluded = fixtureState.All.Where(item => item.Hash == 0).ToList();
- Files reviewed: 44/44 changed files
- Comments generated: 3
- Review effort level: Lite
|
The SQL Server net8 failure in run 32590040411 was caused by the TestKit's no-retry parallel distinct-row guarantee: SQL Server selected one concurrent reminder upsert as a deadlock victim. The ADO.NET provider profile now explicitly disables that optional guarantee, so both inherited and legacy wrapper paths report a genuine capability skip. Full-range/cardinality coverage remains enabled, with serialized setup/cleanup. The same update aligns the service runner and model runner with provider capabilities for ETag rotation, conditional upserts, and unsigned range boundaries. |
Code coverage54.82% line coverage - 55,403 / 101,056 lines Coverage details
|
3d442f5 to
60699f8
Compare
|
The uniform-contract run has a PR-caused failure on both Google Cloud net8.0 and net10.0: The shared contract needs to define an outcome Firestore can guarantee—either ensure replacement ETags are unique at the provider boundary or make the model validate replacement state without requiring ETag rotation. This is deterministic across both TFMs, so retrying the unchanged head will not resolve it. |
8d8b1cb to
33eb91f
Compare
Problem
Reminder providers repeat contract assertions across backend-specific suites, which makes behavior drift difficult to detect and leaves no executable reference model for provider authors.
Solution
Add a reusable, framework-neutral Reminder TestKit with direct, cluster-level, and deterministic model-based runners. Every built-in provider executes the same lifecycle, identity, schedule, ETag, concurrency, range, removal, and cleanup contract; providers supply only construction and external-service configuration.
The TestKit includes a strongly consistent oracle, one hard-bounded mutation retry and read-convergence policy, complete failure diagnostics, and self-tests which prove contract violations are detected. Azure Table and Firestore lifecycle handling is updated so stop/start behavior satisfies the shared contract, and the existing provider suites inherit the TestKit facts directly.
Rationale
One executable contract prevents provider-specific opt-outs from hiding behavioral drift. Bounded retries handle transient contention and eventual visibility while successful semantic violations, such as reused ETags or corrupt range results, fail immediately. Deterministic operation traces and an idealized table make failures reproducible and give provider authors concrete adoption guidance.
Fixes #10723