diff --git a/Directory.Packages.props b/Directory.Packages.props
index ee944f78869..03018722bc8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,6 +7,7 @@
+
@@ -133,12 +134,21 @@
+
+
+
+
+
+
+
+
+
diff --git a/docs/site/src/content/docs/grains/grain-persistence/azure-cosmos-db.md b/docs/site/src/content/docs/grains/grain-persistence/azure-cosmos-db.md
index 66126a383c4..b623b6fd3d9 100644
--- a/docs/site/src/content/docs/grains/grain-persistence/azure-cosmos-db.md
+++ b/docs/site/src/content/docs/grains/grain-persistence/azure-cosmos-db.md
@@ -1,7 +1,7 @@
---
title: Azure Cosmos DB grain persistence
description: Configure Azure Cosmos DB for NoSQL as an Orleans grain storage provider.
-ms.date: 08/02/2026
+ms.date: 08/23/2026
ms.topic: how-to
---
@@ -18,6 +18,18 @@ Configure a named provider with defaults to `Orleans`, defaults to `OrleansStorage`, and defaults to `/PartitionKey`. Provision the database and container before startup when is `false`. Enabling resource creation is convenient for development but production provisioning is usually managed separately.
+## Use with Aspire
+
+Reference the Azure Cosmos DB account resource from Orleans so Aspire emits the `AzureCosmosDB` provider type and a keyed Cosmos client. Declare the database and containers as child resources to provision the provider topology:
+
+:::code language="csharp" source="../../host/snippets/aspire/AppHost/AppHostExamples.cs" id="cosmos_providers_aspire":::
+
+Register the keyed client in the silo before Orleans reads the generated clustering, storage, and reminder configuration:
+
+:::code language="csharp" source="../../host/snippets/aspire/Silo/SiloProgram.cs" id="cosmos_providers_silo":::
+
+The clustering container uses `/ClusterId`; grain storage and reminders use `/PartitionKey`. Keep the AppHost container names aligned with the Orleans configuration supplied to each provider.
+
## Partitioning and indexing
The derives the document ID and partition key for each grain record. To customize either value, implement and register it using the generic overload.
diff --git a/docs/site/src/content/docs/grains/grain-persistence/azure-storage.md b/docs/site/src/content/docs/grains/grain-persistence/azure-storage.md
index 6c099cbc0f8..a23f2fd23e7 100644
--- a/docs/site/src/content/docs/grains/grain-persistence/azure-storage.md
+++ b/docs/site/src/content/docs/grains/grain-persistence/azure-storage.md
@@ -1,7 +1,7 @@
---
title: Azure Storage grain persistence
description: Configure Azure Table Storage or Azure Blob Storage for Orleans grain state.
-ms.date: 08/02/2026
+ms.date: 08/23/2026
ms.topic: how-to
---
@@ -37,6 +37,18 @@ Connection strings are useful for local emulators and constrained environments,
Use a secret store when a connection string is unavoidable.
+## Use with Aspire
+
+Aspire can provision the storage account and emit Orleans configuration for Azure Table clustering, grain storage, reminders, grain directories, Azure Blob grain storage, and Azure Queue streaming:
+
+:::code language="csharp" source="../../host/snippets/aspire/AppHost/AppHostExamples.cs" id="azure_storage_providers_aspire":::
+
+Register keyed Azure SDK clients with the resource names used by the AppHost. Orleans provider builders consume those clients through the emitted `ServiceKey` values:
+
+:::code language="csharp" source="../../host/snippets/aspire/Silo/SiloProgram.cs" id="azure_storage_providers_silo":::
+
+Aspire provisions the account and declared blob containers or queues. Orleans creates its configured tables and grain-state blob container during provider initialization when the selected provider enables resource creation.
+
## Operational guidance
- Use separate provider names when records belong in different accounts, containers, or tables.
diff --git a/docs/site/src/content/docs/grains/grain-persistence/dynamodb-storage.md b/docs/site/src/content/docs/grains/grain-persistence/dynamodb-storage.md
index 93efa0b46d0..2be2d848684 100644
--- a/docs/site/src/content/docs/grains/grain-persistence/dynamodb-storage.md
+++ b/docs/site/src/content/docs/grains/grain-persistence/dynamodb-storage.md
@@ -20,6 +20,8 @@ The [AWS SDK for .NET credential and profile resolution chain](https://docs.aws.
must remain stable across deployments that share the same logical application state. The provider uses optimistic concurrency and rejects stale writes.
+For Aspire AppHost configuration, DynamoDB Local, and structured AWS CDK or CloudFormation outputs, see [Use Amazon DynamoDB with Aspire](../../host/dynamodb-aspire).
+
## Serialization
Set to customize the stored representation. Changing serializers doesn't rewrite existing items, so the replacement must read the previous representation or be accompanied by a migration.
diff --git a/docs/site/src/content/docs/grains/grain-persistence/redis-storage.md b/docs/site/src/content/docs/grains/grain-persistence/redis-storage.md
index 8907760b257..52f457efcc4 100644
--- a/docs/site/src/content/docs/grains/grain-persistence/redis-storage.md
+++ b/docs/site/src/content/docs/grains/grain-persistence/redis-storage.md
@@ -1,7 +1,7 @@
---
title: Redis grain persistence
description: Configure Redis as an Orleans grain storage provider.
-ms.date: 08/07/2026
+ms.date: 08/23/2026
ms.topic: how-to
---
@@ -17,6 +17,18 @@ Configure a provider with when grains should use Redis without specifying a storage name:
diff --git a/docs/site/src/content/docs/grains/journaling/azure-storage.md b/docs/site/src/content/docs/grains/journaling/azure-storage.md
index b9c267526a0..3de062cd8fe 100644
--- a/docs/site/src/content/docs/grains/journaling/azure-storage.md
+++ b/docs/site/src/content/docs/grains/journaling/azure-storage.md
@@ -1,7 +1,7 @@
---
title: Azure Storage providers for Journaling
description: Configure Azure Blob Storage or Azure Table Storage for experimental Orleans Journaling.
-ms.date: 08/21/2026
+ms.date: 08/23/2026
ms.topic: how-to
---
@@ -28,6 +28,14 @@ Granting only read access prevents successful draining. See
[Migrate Durable Jobs storage](durable-jobs-migration.md) for the staged rollout,
full inventory, and retirement criteria.
+## Use with Aspire
+
+Aspire injects the Azure Storage connection and keyed SDK client. Configure the journaling provider section explicitly in the silo resource:
+
+:::code language="csharp" source="../../host/snippets/aspire/AppHost/AppHostExamples.cs" id="azure_table_journaling_aspire":::
+
+The example activates Azure Table journal storage. Use `AzureBlobStorage` as `ProviderType`, reference the blob resource service key, and register the corresponding keyed to activate Azure Blob journal storage.
+
## Azure Blob Storage
Configure with an authenticated :
diff --git a/docs/site/src/content/docs/grains/journaling/redis-journal-storage.md b/docs/site/src/content/docs/grains/journaling/redis-journal-storage.md
index eaa44acb9f3..2cbd5018187 100644
--- a/docs/site/src/content/docs/grains/journaling/redis-journal-storage.md
+++ b/docs/site/src/content/docs/grains/journaling/redis-journal-storage.md
@@ -1,7 +1,7 @@
---
title: Redis journal storage
description: Configure Redis as a storage provider for experimental Orleans Journaling durable state.
-ms.date: 08/21/2026
+ms.date: 08/23/2026
ms.topic: how-to
---
@@ -37,6 +37,14 @@ inventory is an observation rather than a transactionally frozen cluster view.
Follow [Migrate Durable Jobs storage](durable-jobs-migration.md), including
repeated complete inventories after all scheduling silos have cut over.
+## Use with Aspire
+
+Aspire injects the Redis connection and keyed client. Configure the journaling provider section explicitly in the silo resource:
+
+:::code language="csharp" source="../../host/snippets/aspire/AppHost/AppHostExamples.cs" id="redis_journaling_aspire":::
+
+The silo registers the `orleans-redis` keyed client before `UseOrleans`, as shown in the [Redis provider silo example](../grain-persistence/redis-storage.md#use-with-aspire). Orleans then activates the default Redis journal storage binding from `Orleans:Journaling:Default`.
+
## Storage behavior
The provider stores journal data in Redis strings and journal metadata in Redis hashes. Per-journal reads and mutations use atomic Lua scripts. Catalog operations discover journals by scanning metadata keys on each connected primary Redis server.
diff --git a/docs/site/src/content/docs/grains/reminders/dynamodb.md b/docs/site/src/content/docs/grains/reminders/dynamodb.md
index 06e77825b15..ed2baf2fc2c 100644
--- a/docs/site/src/content/docs/grains/reminders/dynamodb.md
+++ b/docs/site/src/content/docs/grains/reminders/dynamodb.md
@@ -26,3 +26,5 @@ When and to `true` and configure and for provisioned capacity.
and allow the provider to create the reminder table and update its provisioned capacity. Infrastructure-managed provisioning keeps table lifecycle and capacity changes in the deployment workflow.
+
+For Aspire AppHost configuration, DynamoDB Local, and structured AWS CDK or CloudFormation outputs, see [Use Amazon DynamoDB with Aspire](../../host/dynamodb-aspire).
diff --git a/docs/site/src/content/docs/host/aspire-integration.md b/docs/site/src/content/docs/host/aspire-integration.md
index 0535f033961..621010bb646 100644
--- a/docs/site/src/content/docs/host/aspire-integration.md
+++ b/docs/site/src/content/docs/host/aspire-integration.md
@@ -13,7 +13,7 @@ ms.topic: how-to
The `Aspire.Hosting.Orleans` package models an Orleans cluster and its backing services in an Aspire AppHost. [Aspire](https://aspire.dev/get-started/what-is-aspire/) supplies cluster identity, endpoints, provider configuration, service discovery, dependency ordering, and observability context to silo and client projects.
-Use Aspire when you want a repeatable local distributed environment or already use an AppHost to describe deployment resources. Aspire orchestrates Orleans; it doesn't replace Orleans clustering, storage, reminder, or stream providers. See [Install the Aspire CLI](https://aspire.dev/get-started/install-cli/) for the supported toolchain.
+Use Aspire when you want a repeatable local distributed environment or already use an AppHost to describe deployment resources. Aspire orchestrates the application resources and supplies configuration to the Orleans providers that implement clustering, storage, reminders, streaming, directories, and journaling. See [Install the Aspire CLI](https://aspire.dev/get-started/install-cli/) for the supported toolchain.
## Configure the AppHost
@@ -45,16 +45,16 @@ The named grain storage resources correspond to named Orleans providers such as
-Register the keyed Aspire client for every backing resource consumed by Orleans, then call parameterless :
+Register the keyed Aspire clients required by the selected providers, then call parameterless :
:::code language="csharp" source="snippets/aspire/Silo/SiloProgram.cs" id="silo_basic_config":::
-The AppHost injects the `Orleans` configuration hierarchy. Orleans binds cluster identity, endpoints, clustering, reminders, streaming, grain storage, and grain directory configuration from it.
+The AppHost injects the `Orleans` configuration hierarchy. Orleans binds cluster identity, endpoints, clustering, reminders, streaming, grain storage, grain directory, and grain journaling configuration from it.
> [!IMPORTANT]
-> Resource references inject configuration, but the application project must register the matching keyed service client. For example, use `AddKeyedRedisClient`, `AddKeyedAzureTableServiceClient`, or the matching Aspire integration method for the resource type and name.
+> For providers configured with a keyed service client, register that client in the application project using the resource name. For example, use `AddKeyedRedisClient` or `AddKeyedAzureTableServiceClient`. ADO.NET providers resolve injected connection strings directly, and AWS provider mappings supply endpoints and structured resource outputs as described in the [provider wiring reference](#provider-wiring-reference).
-Use the delegate only for configuration that the AppHost doesn't model, such as application-specific options or custom services.
+Use the delegate for application-specific options and custom services.
## Configure the Orleans client project
@@ -70,7 +70,7 @@ In the client project, register the keyed resource client and call parameterless
:::code language="csharp" source="snippets/aspire/Client/ClientProgram.cs" id="client_basic_config":::
-The client receives the same cluster identity and clustering provider settings as the silos, but doesn't receive silo hosting capabilities.
+The client receives the cluster identity, clustering provider settings, and streaming configuration it uses to communicate with silos and stream backends.
## Azure Storage with Aspire
@@ -92,36 +92,64 @@ The same principle applies to Redis and databases: the AppHost resource can laun
## Provider wiring reference
-For automatically configured external resources, the AppHost needs the corresponding Aspire hosting integration. The silo or client needs both the Orleans provider package and the Aspire client integration, and it must register the client using the Aspire resource name.
+For automatically configured external resources, the AppHost needs the corresponding Aspire hosting integration. The silo or client needs the Orleans provider package and registers any keyed service client named by the generated `ServiceKey`. ADO.NET providers consume injected connection strings directly. AWS resources expose structured outputs, so their examples use `IProviderConfiguration` to map those outputs into Orleans configuration.
+
+| Backend | Aspire-to-Orleans mapping | Orleans capabilities | Provider guidance |
+|---|---|---|---|
+| Azure Tables | Resource inference and keyed `TableServiceClient` | Clustering, grain storage, reminders, grain directory; explicit grain journaling | [Azure Storage](../grains/grain-persistence/azure-storage.md) |
+| Azure Blobs | Resource inference and keyed `BlobServiceClient` | Grain storage; explicit grain journaling | [Azure Storage](../grains/grain-persistence/azure-storage.md) |
+| Azure Queues | Resource inference and keyed `QueueServiceClient` | Streaming | [Azure Queue streams](../implementation/streams-implementation/azure-queue-streams.md) |
+| Azure Cosmos DB | Resource inference and keyed `CosmosClient` | Clustering, grain storage, reminders | [Azure Cosmos DB storage](../grains/grain-persistence/azure-cosmos-db.md) |
+| Redis and Azure Managed Redis | Resource inference and keyed `IConnectionMultiplexer` | Clustering, grain storage, reminders, grain directory, streaming; explicit grain journaling | [Redis storage](../grains/grain-persistence/redis-storage.md) |
+| SQL Server and Azure SQL | Database-resource inference and injected connection string | Clustering, grain storage, reminders, grain directory, streaming | [ADO.NET configuration](configuration-guide/configuring-ado-dot-net-providers.md) |
+| PostgreSQL and Azure Database for PostgreSQL | Database-resource inference and injected connection string | Clustering, grain storage, reminders, grain directory, streaming | [ADO.NET configuration](configuration-guide/configuring-ado-dot-net-providers.md) |
+| MySQL and MariaDB | Database-resource inference and injected connection string | Clustering, grain storage, reminders, grain directory, streaming | [ADO.NET configuration](configuration-guide/configuring-ado-dot-net-providers.md) |
+| Oracle Database | Database-resource inference and injected connection string | Clustering, grain storage, reminders | [ADO.NET configuration](configuration-guide/configuring-ado-dot-net-providers.md) |
+| Azure Event Hubs | Resource inference, injected Event Hubs connection metadata, and a keyed Azure Tables checkpoint client | Streaming | [Stream providers](../streaming/stream-providers.md) |
+| NATS JetStream | Resource inference and keyed `INatsConnection` | Streaming | [Stream providers](../streaming/stream-providers.md) |
+| Amazon DynamoDB | Explicit `IProviderConfiguration` mapping for AWS endpoints and structured outputs | Clustering, grain storage, reminders | [DynamoDB with Aspire](dynamodb-aspire.md) |
+| Amazon SQS | Explicit partition-topology configuration from the AppHost | Streaming | [SQS streaming](../streaming/sqs-streaming.md) and prerequisite [#10797](https://github.com/dotnet/orleans/pull/10797) |
+| Amazon Kinesis | Explicit `IProviderConfiguration` mapping for stream and checkpoint outputs | Streaming with grain or DynamoDB checkpoints | [Kinesis streaming](../streaming/kinesis-streaming.md) |
+| In-memory | Orleans AppHost operations | Development clustering, grain storage, reminders, and streaming | This page |
-| Resource | Application registration | Supported automatic Orleans configuration |
-|---|---|---|
-| Redis | `AddKeyedRedisClient` | Clustering, grain storage, reminders, and grain directories |
-| Azure Tables | `AddKeyedAzureTableServiceClient` | Clustering, grain storage, reminders, and grain directories |
-| Azure Blobs | `AddKeyedAzureBlobServiceClient` | Grain storage |
-| ADO.NET database | Configure the Orleans provider from the injected connection string | Clustering, grain storage, and reminders require manual configuration |
-| In-memory | None | Development clustering, grain storage, reminders, and streaming |
+### ADO.NET providers
-The resulting provider support matrix is:
+Orleans registers aliases for the Aspire SQL Server, Azure SQL, PostgreSQL, Azure PostgreSQL, MySQL, MariaDB, and Oracle database resource types. The provider builder selects the matching ADO.NET invariant and resolves the injected connection string through `ServiceKey`:
-| Capability | Redis | Azure Tables | Azure Blobs | ADO.NET | In-memory |
-|---|---|---|---|---|---|
-| Clustering | Automatic | Automatic | No | Manual | Development only |
-| Grain storage | Automatic | Automatic | Automatic | Manual | Development only |
-| Reminders | Automatic | Automatic | No | Manual | Development only |
-| Grain directory | Automatic | Automatic | No | No | No |
+:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_apphost":::
-### ADO.NET providers
+The silo calls parameterless . Register an Aspire database client separately when application code also consumes that client.
-ADO.NET resource types don't infer the `AdoNet` provider name expected by Orleans, and `Aspire.Hosting.Orleans` doesn't expose an API to override the inferred name. Reference the database resource directly from the silo project so that Aspire injects its connection string:
+Generic connection-string resources can use an explicit `IProviderConfiguration` to select `AdoNet`, set the database invariant, and reference the connection:
-:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_apphost":::
+:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_explicit_provider":::
+
+The `WithOrleansProviderType(...)` resource annotation is implemented upstream in [microsoft/aspire#13630](https://github.com/microsoft/aspire/pull/13630) and can replace the provider-name portion of this explicit mapping after it ships in the Aspire package used by the application.
+
+### Grain journaling
+
+Aspire provisions and starts the backing resource, then the silo configures the Orleans journal provider from the injected resource reference. The [Journaling with Azure Blob JSON sample](../grains/journaling/samples.md) demonstrates programmatic provider registration with Azurite and verifies state recovery after grain reactivation.
+
+The equivalent configuration-driven path uses named entries in the `Orleans:Journaling` section. The `Default` entry configures the default journal storage binding:
+
+```json
+{
+ "Orleans": {
+ "Journaling": {
+ "Default": {
+ "ProviderType": "AzureBlobStorage",
+ "ConnectionName": "blobs"
+ }
+ }
+ }
+}
+```
-Configure the Orleans ADO.NET providers from that connection string:
+Register the keyed Aspire client or inject the named connection used by the selected provider before calling . Orleans activates the provider during silo startup and validates its connection and provider-specific options.
-:::code language="csharp" source="snippets/aspire/Silo/SiloProgram.cs" id="adonet_silo":::
+Additional entries such as `Orleans:Journaling:archive` configure independent named storage bindings. Each entry selects its provider type, client reference, and backend storage options.
-Register an Aspire database client separately only when application code also consumes that database client. Orleans ADO.NET providers use their configured connection string directly.
+Aspire resource-model support for emitting this section is tracked by [microsoft/aspire#19609](https://github.com/microsoft/aspire/issues/19609).
### Grain directories
@@ -169,7 +197,7 @@ Set stable service and cluster identifiers for environments that must interopera
:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="explicit_cluster_ids":::
-- Treat the AppHost as a resource model, not as a substitute for durable services.
+- Model durable backing services and their startup dependencies in the AppHost.
- Use managed identities or workload identities instead of embedding secrets.
- Keep stable and isolate environments with .
- Run multiple silo replicas across failure domains.
diff --git a/docs/site/src/content/docs/host/configuration-guide/configuring-ado-dot-net-providers.md b/docs/site/src/content/docs/host/configuration-guide/configuring-ado-dot-net-providers.md
index c5cfde97844..6f311f20199 100644
--- a/docs/site/src/content/docs/host/configuration-guide/configuring-ado-dot-net-providers.md
+++ b/docs/site/src/content/docs/host/configuration-guide/configuring-ado-dot-net-providers.md
@@ -1,7 +1,7 @@
---
title: Configure ADO.NET providers
description: Configure Orleans clustering, reminders, storage, and grain directories with ADO.NET.
-ms.date: 08/10/2026
+ms.date: 08/23/2026
ms.topic: how-to
---
@@ -40,6 +40,47 @@ deployment and keep the resulting table names, columns, parameters, and query
result shapes compatible with Orleans. Apply and test those changes outside the
application startup path.
+## Configure with Aspire
+
+`Aspire.Hosting.Orleans` emits the database resource name as `ServiceKey`. The Orleans ADO.NET provider resolves the corresponding connection string and selects the invariant from the Aspire database resource type:
+
+| Aspire resource | Orleans invariant | Supported capabilities |
+|---|---|---|
+| `SqlServerDatabaseResource`, `AzureSqlDatabaseResource` | `Microsoft.Data.SqlClient` | Clustering, grain storage, reminders, grain directory, streaming |
+| `PostgresDatabaseResource`, `AzurePostgresFlexibleServerDatabaseResource` | `Npgsql` | Clustering, grain storage, reminders, grain directory, streaming |
+| `MySqlDatabaseResource` | `MySql.Data.MySqlClient` | Clustering, grain storage, reminders, grain directory, streaming |
+| `OracleDatabaseResource` | `Oracle.DataAccess.Client` | Clustering, grain storage, reminders |
+
+The SQL Server AppHost can create the database and apply the Orleans scripts through a creation script:
+
+:::code language="csharp" source="../snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_apphost":::
+
+The `schema/sqlserver.sql` file creates `orleans-db`, selects it, and then contains the SQL Server main script followed by the scripts for each configured capability. Aspire runs this script when the SQL Server resource becomes ready.
+
+PostgreSQL and MySQL/MariaDB container initialization directories can provision the database and schema before the Orleans project starts:
+
+:::code language="csharp" source="../snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_postgresql_apphost":::
+
+:::code language="csharp" source="../snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_mysql_apphost":::
+
+Place ordered initialization files in the referenced directory. Start with a file that creates `orleans-db`, then apply the main script and each configured capability script. MariaDB uses the MySQL scripts and provider mapping.
+
+Oracle supports clustering, grain storage, and reminders:
+
+:::code language="csharp" source="../snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_oracle_apphost":::
+
+The Oracle initialization directory creates the database user or service and applies the Oracle main, clustering, persistence, and reminder scripts. The Oracle container image terms and startup requirements apply to local orchestration.
+
+### Configure a custom database resource
+
+An `IProviderConfiguration` can emit the `AdoNet` provider type, an explicit invariant, and the connection-string resource name. Custom resources use this configuration to select their database driver:
+
+:::code language="csharp" source="../snippets/aspire/AppHost/AppHostExamples.cs" id="adonet_explicit_provider":::
+
+Explicit `Invariant` and `ConnectionString` values take precedence over inferred values. `ServiceKey` resolves an Aspire-injected connection string, and `ConnectionName` resolves a conventional .NET connection string.
+
+When both `ServiceKey` and `ConnectionName` are configured, they must identify the same connection-string entry. Orleans compares the names case-insensitively and reports conflicting references with their provider configuration section.
+
## Configure SQL Server
Use `Microsoft.Data.SqlClient` for SQL Server:
diff --git a/docs/site/src/content/docs/host/dynamodb-aspire.md b/docs/site/src/content/docs/host/dynamodb-aspire.md
new file mode 100644
index 00000000000..481872f9f87
--- /dev/null
+++ b/docs/site/src/content/docs/host/dynamodb-aspire.md
@@ -0,0 +1,74 @@
+---
+title: Use Amazon DynamoDB with Aspire
+description: Configure Orleans clustering, grain storage, and reminders with AWS Aspire and DynamoDB.
+ms.date: 08/25/2026
+ms.topic: how-to
+---
+
+# Use Amazon DynamoDB with Aspire
+
+Amazon DynamoDB can provide Orleans cluster membership, grain storage, and reminder storage in an Aspire application. Install `Aspire.Hosting.AWS` in the AppHost and the corresponding Orleans DynamoDB provider packages in the silo project:
+
+- `Microsoft.Orleans.Clustering.DynamoDB`
+- `Microsoft.Orleans.Persistence.DynamoDB`
+- `Microsoft.Orleans.Reminders.DynamoDB`
+
+## Provision the AWS topology with CDK
+
+The AWS-supported `Aspire.Hosting.AWS` integration provisions DynamoDB tables through AWS CDK and supplies AWS SDK region and profile metadata to application resources. Define the Orleans resource identities and physical table schemas once, then use the same contract for CDK provisioning and Orleans provider configuration:
+
+:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="dynamodb_cdk_aspire":::
+
+The shared topology declares the stable table names, partition keys, sort keys, and reminder indexes required by the Orleans providers:
+
+:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="dynamodb_cdk_topology":::
+
+The provider configuration references the AWS SDK configuration and the table resource output. It emits the provider region independently so published workloads retain the required service location, and it gives grain storage the stable service ID. It sets `UseProvisionedThroughput`, `CreateIfNotExists`, and `UpdateIfExists` to `false` because CloudFormation owns table provisioning and updates:
+
+:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="dynamodb_provider_configuration":::
+
+| Capability | Table key | Additional indexes |
+|---|---|---|
+| Cluster membership | `DeploymentId` + `SiloIdentity` | None |
+| Grain storage | `GrainReference` + `GrainType` | None |
+| Reminders | `ReminderId` + `GrainHash` | `ServiceIdIndex` and `ServiceIdGrainReferenceIndex` |
+| Transactional state | `PartitionKey` + `RowKey` | None |
+| Kinesis checkpoints | `CheckpointNamespace` + `Partition` | None |
+
+The silo references all five tables so Aspire emits their stable names under `AWS:Resources:{resource-name}:TableName`. Clustering, grain storage, and reminders consume those outputs through `ServiceKey`. Transactional storage and Kinesis checkpoint configuration consume the corresponding outputs when those providers are enabled. Clients reference only the membership table. Silo and client resources wait for the CDK stack, and the client also waits for a silo.
+
+Keep `ClusterId`, `ServiceId`, provider names, table names, and key schemas stable across deployments. Changing an identity or table name creates a separate data plane. Plan cutovers by provisioning the replacement topology, migrating or draining state where required, and then switching every silo and client to the same contract.
+
+The AppHost credentials deploy and update a CloudFormation stack. Grant the provisioning identity the required CloudFormation, DynamoDB, and IAM permissions. Bootstrap the target account and region when the CDK application uses assets. Runtime workloads use the AWS SDK credential chain through task roles, pod identity, instance roles, or configured profiles; the generated provider environment contains region, profile, provider metadata, and table names rather than access keys or session tokens.
+
+## Run with DynamoDB Local
+
+Register a connection-string resource whose value is `Service=http://localhost:8000` (or another DynamoDB-compatible endpoint). The Orleans providers resolve it through `ServiceKey` and create their membership, grain-state, and reminder tables by default.
+
+:::code language="csharp" source="snippets/aspire/AppHost/AppHostExamples.cs" id="dynamodb_local_aspire":::
+
+The explicit `IProviderConfiguration` maps the connection-string resource to the Orleans provider name. Orleans creates missing local tables so development and live provider tests exercise runtime recovery creation. The AppHost can start DynamoDB Local as a container separately and supply its endpoint through the connection string.
+
+Use stable cluster and service identifiers whenever local data persists across AppHost runs. The three providers use separate default tables:
+
+| Capability | Default table |
+|---|---|
+| Cluster membership | `OrleansSilos` |
+| Grain storage | `OrleansGrainState` |
+| Reminders | `OrleansReminders` |
+
+## Use AWS profiles and workload credentials
+
+`AddAWSSDKConfig().WithProfile(...).WithRegion(...)` supplies `AWS:Profile` and `AWS:Region` configuration together with the standard AWS environment variables. Orleans binds the profile and region while the AWS SDK resolves credentials through its normal credential chain. In deployed environments, workload credentials such as ECS task roles, EKS pod identity, and EC2 instance roles flow through that chain.
+
+Direct provider configuration can set `AccessKey`, `SecretKey`, and `Token` for environments that supply explicit session credentials. `AccessKey`, `SecretKey`, and `Token` are redacted from formatted options. Configure `AccessKey` and `SecretKey` together, configure `Token` only with that pair, and choose either explicit credentials or `ProfileName`.
+
+## Consume CloudFormation outputs
+
+AWS Aspire CDK table references emit the table name under `AWS:Resources:{resource-name}:TableName`. A CloudFormation reference can instead target the provider section directly, such as `Orleans:Clustering`, `Orleans:GrainStorage:Default`, or `Orleans:Reminders`, when the stack exposes an output named `TableName`.
+
+The providers also accept `ConnectionName`, `ConnectionString`, and nested `ConnectionProperties` or `Resource` values. Direct provider values take precedence over referenced resource outputs, including when an alias such as `Region`, `Endpoint`, `SessionToken`, or `Profile` is used. A connection string can contain `Service`, `Region`, `ServiceURL`, `Endpoint`, `TableName`, `AccessKey`, `SecretKey`, `Token` or `SessionToken`, and `ProfileName` or `Profile`.
+
+A provider-local `ConnectionString` takes precedence over referenced resource outputs. An explicit `ConnectionName` resolves a required `ConnectionStrings:` entry when no direct connection string is supplied. `ServiceKey` supports structured resource outputs, with a named connection string as a fallback for fields those outputs omit.
+
+For infrastructure-managed tables, set `CreateIfNotExists` and `UpdateIfExists` to `false`. The compiled CDK example uses on-demand billing, so it also sets `UseProvisionedThroughput` to `false`.
diff --git a/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHost.csproj b/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHost.csproj
index 6f74f623a39..c36bf56f4e9 100644
--- a/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHost.csproj
+++ b/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHost.csproj
@@ -10,19 +10,30 @@
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
diff --git a/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHostExamples.cs b/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHostExamples.cs
index fe82b979ee1..c22b09863cc 100644
--- a/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHostExamples.cs
+++ b/docs/site/src/content/docs/host/snippets/aspire/AppHost/AppHostExamples.cs
@@ -1,5 +1,14 @@
+using Amazon;
+using Amazon.CDK.AWS.DynamoDB;
+using Amazon.CDK.AWS.Kinesis;
using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.AWS;
using Aspire.Hosting.Azure;
+using Aspire.Hosting.Orleans;
+using CdkDuration = Amazon.CDK.Duration;
+using CdkRemovalPolicy = Amazon.CDK.RemovalPolicy;
+using DynamoDBAttribute = Amazon.CDK.AWS.DynamoDB.Attribute;
namespace Orleans.Docs.Snippets.Aspire;
@@ -103,6 +112,373 @@ public static void AzureStorageWithAspire(string[] args)
}
//
+ //
+ public static void AzureStorageProvidersWithAspire(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var storage = builder.AddAzureStorage("orleans-storage")
+ .RunAsEmulator();
+ var tables = storage.AddTables("orleans-tables");
+ var blobs = storage.AddBlobs("orleans-blobs");
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(tables)
+ .WithGrainStorage("table-state", tables)
+ .WithGrainStorage("blob-state", blobs)
+ .WithReminders(tables)
+ .WithGrainDirectory("directory", tables);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WaitFor(storage);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void CosmosProvidersWithAspire(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var cosmos = builder.AddAzureCosmosDB("orleans-cosmos")
+ .RunAsEmulator();
+ var database = cosmos.AddCosmosDatabase("orleans-db", "Orleans");
+ database.AddContainer("membership", "/ClusterId", "OrleansCluster");
+ database.AddContainer("state", "/PartitionKey", "OrleansStorage");
+ database.AddContainer("reminders", "/PartitionKey", "OrleansReminders");
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(cosmos)
+ .WithGrainStorage("Default", cosmos)
+ .WithReminders(cosmos);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WithEnvironment("Orleans__Clustering__DatabaseName", "Orleans")
+ .WithEnvironment("Orleans__Clustering__ContainerName", "OrleansCluster")
+ .WithEnvironment("Orleans__GrainStorage__Default__DatabaseName", "Orleans")
+ .WithEnvironment("Orleans__GrainStorage__Default__ContainerName", "OrleansStorage")
+ .WithEnvironment("Orleans__Reminders__DatabaseName", "Orleans")
+ .WithEnvironment("Orleans__Reminders__ContainerName", "OrleansReminders")
+ .WaitFor(cosmos);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void RedisProvidersWithAspire(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var redis = builder.AddAzureManagedRedis("orleans-redis")
+ .RunAsContainer();
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(redis)
+ .WithGrainStorage("Default", redis)
+ .WithReminders(redis)
+ .WithGrainDirectory("directory", redis);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WaitFor(redis);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void RedisJournalingWithAspire(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var redis = builder.AddRedis("orleans-redis");
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(redis);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WithEnvironment("Orleans__Journaling__Default__ProviderType", "Redis")
+ .WithEnvironment("Orleans__Journaling__Default__ServiceKey", "orleans-redis")
+ .WaitFor(redis);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void AzureTableJournalingWithAspire(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var storage = builder.AddAzureStorage("orleans-storage")
+ .RunAsEmulator();
+ var tables = storage.AddTables("orleans-tables");
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(tables);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WithEnvironment("Orleans__Journaling__Default__ProviderType", "AzureTableStorage")
+ .WithEnvironment("Orleans__Journaling__Default__ServiceKey", "orleans-tables")
+ .WaitFor(storage);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void EventHubsWithAspire(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var eventHubs = builder.AddAzureEventHubs("event-hubs");
+ var consumerGroup = eventHubs
+ .AddHub("orders-hub", "orders")
+ .AddConsumerGroup("orders-consumer", "orleans");
+ var storage = builder.AddAzureStorage("checkpoints");
+ var tables = storage.AddTables("checkpoint-tables");
+ var orleans = builder.AddOrleans("cluster")
+ .WithDevelopmentClustering()
+ .WithStreaming("orders", consumerGroup);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WithReference(tables)
+ .WithEnvironment("Orleans__Streaming__orders__CheckpointerServiceKey", "checkpoint-tables")
+ .WaitFor(eventHubs)
+ .WaitFor(storage);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void DynamoDBLocalWithAspire(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var dynamodb = builder.AddConnectionString("dynamodb");
+ var provider = new DynamoDBProviderConfiguration(null, dynamodb.Resource.Name);
+ var orleans = builder.AddOrleans("cluster")
+ .WithClusterId("orders")
+ .WithServiceId("orders")
+ .WithClustering(provider)
+ .WithGrainStorage("Default", provider)
+ .WithReminders(provider);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WithReference(dynamodb);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void DynamoDBWithAwsCdk(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var aws = builder.AddAWSSDKConfig()
+ .WithRegion(RegionEndpoint.USEast1);
+ var stack = builder.AddAWSCDKStack("orleans-dynamodb")
+ .WithReference(aws);
+ var membership = stack.AddDynamoDBTable(
+ DynamoDBTopology.MembershipResourceName,
+ DynamoDBTopology.CreateMembershipTable());
+ var grainState = stack.AddDynamoDBTable(
+ DynamoDBTopology.GrainStateResourceName,
+ DynamoDBTopology.CreateGrainStateTable());
+ var reminders = stack.AddDynamoDBTable(
+ DynamoDBTopology.RemindersResourceName,
+ DynamoDBTopology.CreateRemindersTable())
+ .AddGlobalSecondaryIndex(DynamoDBTopology.CreateServiceIdIndex())
+ .AddGlobalSecondaryIndex(DynamoDBTopology.CreateServiceIdGrainReferenceIndex());
+ var transactions = stack.AddDynamoDBTable(
+ DynamoDBTopology.TransactionsResourceName,
+ DynamoDBTopology.CreateTransactionsTable());
+ var checkpoints = stack.AddDynamoDBTable(
+ DynamoDBTopology.CheckpointsResourceName,
+ DynamoDBTopology.CreateCheckpointsTable());
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClusterId(DynamoDBTopology.ClusterId)
+ .WithServiceId(DynamoDBTopology.ServiceId)
+ .WithClustering(
+ new DynamoDBProviderConfiguration(
+ aws,
+ DynamoDBTopology.MembershipResourceName,
+ infrastructureOwnsTable: true))
+ .WithGrainStorage(
+ "Default",
+ new DynamoDBProviderConfiguration(
+ aws,
+ DynamoDBTopology.GrainStateResourceName,
+ infrastructureOwnsTable: true,
+ serviceId: DynamoDBTopology.ServiceId))
+ .WithReminders(
+ new DynamoDBProviderConfiguration(
+ aws,
+ DynamoDBTopology.RemindersResourceName,
+ infrastructureOwnsTable: true));
+
+ var silo = builder.AddProject("silo")
+ .WithReference(orleans)
+ .WithReference(membership)
+ .WithReference(grainState)
+ .WithReference(reminders)
+ .WithReference(transactions)
+ .WithReference(checkpoints)
+ .WaitFor(stack)
+ .WithReplicas(3);
+
+ builder.AddProject("client")
+ .WithReference(orleans.AsClient())
+ .WithReference(membership)
+ .WaitFor(stack)
+ .WaitFor(silo);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ private sealed class DynamoDBProviderConfiguration(
+ IAWSSDKConfig? aws,
+ string serviceKey,
+ bool infrastructureOwnsTable = false,
+ string? serviceId = null) : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resourceBuilder,
+ string configSectionPath)
+ where T : IResourceWithEnvironment
+ {
+ var prefix = $"Orleans__{configSectionPath.Replace(":", "__", StringComparison.Ordinal)}";
+ if (aws is not null)
+ {
+ var region = aws.Region?.SystemName
+ ?? throw new InvalidOperationException("DynamoDB providers require an AWS region.");
+ resourceBuilder
+ .WithReference(aws)
+ .WithEnvironment($"{prefix}__Region", region);
+ }
+
+ resourceBuilder
+ .WithEnvironment($"{prefix}__ProviderType", "DynamoDB")
+ .WithEnvironment($"{prefix}__ServiceKey", serviceKey);
+ if (infrastructureOwnsTable)
+ {
+ resourceBuilder
+ .WithEnvironment($"{prefix}__UseProvisionedThroughput", "false")
+ .WithEnvironment($"{prefix}__CreateIfNotExists", "false")
+ .WithEnvironment($"{prefix}__UpdateIfExists", "false");
+ }
+
+ if (serviceId is not null)
+ {
+ resourceBuilder.WithEnvironment($"{prefix}__ServiceId", serviceId);
+ }
+ }
+ }
+ //
+
+ //
+ private static class DynamoDBTopology
+ {
+ public const string ClusterId = "orders-production";
+ public const string ServiceId = "orders-service";
+ public const string MembershipResourceName = "orleans-membership";
+ public const string GrainStateResourceName = "orleans-grain-state";
+ public const string RemindersResourceName = "orleans-reminders";
+ public const string TransactionsResourceName = "orleans-transactions";
+ public const string CheckpointsResourceName = "orleans-checkpoints";
+
+ private const string MembershipTableName = "orders-orleans-membership";
+ private const string GrainStateTableName = "orders-orleans-grain-state";
+ private const string RemindersTableName = "orders-orleans-reminders";
+ private const string TransactionsTableName = "orders-orleans-transactions";
+ private const string CheckpointsTableName = "orders-orleans-checkpoints";
+
+ public static TableProps CreateMembershipTable()
+ => CreateTable(
+ MembershipTableName,
+ partitionKey: ("DeploymentId", AttributeType.STRING),
+ sortKey: ("SiloIdentity", AttributeType.STRING));
+
+ public static TableProps CreateGrainStateTable()
+ => CreateTable(
+ GrainStateTableName,
+ partitionKey: ("GrainReference", AttributeType.STRING),
+ sortKey: ("GrainType", AttributeType.STRING));
+
+ public static TableProps CreateRemindersTable()
+ => CreateTable(
+ RemindersTableName,
+ partitionKey: ("ReminderId", AttributeType.STRING),
+ sortKey: ("GrainHash", AttributeType.NUMBER));
+
+ public static GlobalSecondaryIndexProps CreateServiceIdIndex()
+ => CreateIndex(
+ "ServiceIdIndex",
+ partitionKey: ("ServiceId", AttributeType.STRING),
+ sortKey: ("GrainHash", AttributeType.NUMBER));
+
+ public static GlobalSecondaryIndexProps CreateServiceIdGrainReferenceIndex()
+ => CreateIndex(
+ "ServiceIdGrainReferenceIndex",
+ partitionKey: ("ServiceId", AttributeType.STRING),
+ sortKey: ("GrainReference", AttributeType.STRING));
+
+ public static TableProps CreateTransactionsTable()
+ => CreateTable(
+ TransactionsTableName,
+ partitionKey: ("PartitionKey", AttributeType.STRING),
+ sortKey: ("RowKey", AttributeType.STRING));
+
+ public static TableProps CreateCheckpointsTable()
+ => CreateTable(
+ CheckpointsTableName,
+ partitionKey: ("CheckpointNamespace", AttributeType.STRING),
+ sortKey: ("Partition", AttributeType.STRING));
+
+ private static TableProps CreateTable(
+ string tableName,
+ (string Name, AttributeType Type) partitionKey,
+ (string Name, AttributeType Type) sortKey)
+ => new()
+ {
+ TableName = tableName,
+ BillingMode = BillingMode.PAY_PER_REQUEST,
+ PartitionKey = CreateAttribute(partitionKey),
+ SortKey = CreateAttribute(sortKey),
+ };
+
+ private static GlobalSecondaryIndexProps CreateIndex(
+ string indexName,
+ (string Name, AttributeType Type) partitionKey,
+ (string Name, AttributeType Type) sortKey)
+ => new()
+ {
+ IndexName = indexName,
+ PartitionKey = CreateAttribute(partitionKey),
+ SortKey = CreateAttribute(sortKey),
+ ProjectionType = ProjectionType.ALL,
+ };
+
+ private static DynamoDBAttribute CreateAttribute((string Name, AttributeType Type) attribute)
+ => new()
+ {
+ Name = attribute.Name,
+ Type = attribute.Type,
+ };
+ }
+ //
+
//
public static void LocalDevelopment(string[] args)
{
@@ -193,33 +569,213 @@ public static void RemindersInMemoryAppHost(string[] args)
}
//
+ //
+ public static void KinesisStreaming(string[] args)
+ {
+ var topology = KinesisTopology.Orders;
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var aws = builder.AddAWSSDKConfig()
+ .WithRegion(topology.Region);
+ var stack = builder.AddAWSCDKStack(topology.StackName)
+ .WithReference(aws);
+ var stream = stack.AddKinesisStream(
+ topology.StreamResourceName,
+ new StreamProps
+ {
+ StreamName = topology.StreamName,
+ ShardCount = topology.ShardCount,
+ StreamMode = StreamMode.PROVISIONED,
+ RetentionPeriod = CdkDuration.Hours(topology.RetentionHours),
+ RemovalPolicy = CdkRemovalPolicy.RETAIN,
+ });
+ var pubSubStore = stack.AddDynamoDBTable(
+ topology.PubSubResourceName,
+ new TableProps
+ {
+ TableName = topology.PubSubTableName,
+ BillingMode = BillingMode.PAY_PER_REQUEST,
+ RemovalPolicy = CdkRemovalPolicy.RETAIN,
+ PartitionKey = new Amazon.CDK.AWS.DynamoDB.Attribute
+ {
+ Name = "GrainReference",
+ Type = AttributeType.STRING,
+ },
+ SortKey = new Amazon.CDK.AWS.DynamoDB.Attribute
+ {
+ Name = "GrainType",
+ Type = AttributeType.STRING,
+ },
+ });
+ var checkpoints = stack.AddDynamoDBTable(
+ topology.CheckpointResourceName,
+ new TableProps
+ {
+ TableName = topology.CheckpointTableName,
+ BillingMode = BillingMode.PAY_PER_REQUEST,
+ RemovalPolicy = CdkRemovalPolicy.RETAIN,
+ PartitionKey = new Amazon.CDK.AWS.DynamoDB.Attribute
+ {
+ Name = "CheckpointNamespace",
+ Type = AttributeType.STRING,
+ },
+ SortKey = new Amazon.CDK.AWS.DynamoDB.Attribute
+ {
+ Name = "Partition",
+ Type = AttributeType.STRING,
+ },
+ });
+ var orleans = builder.AddOrleans("cluster")
+ .WithClusterId(topology.ClusterId)
+ .WithServiceId(topology.ServiceId)
+ .WithDevelopmentClustering()
+ .WithGrainStorage(
+ "PubSubStore",
+ new DynamoDBProviderConfiguration(
+ aws,
+ pubSubStore.Resource.Name,
+ infrastructureOwnsTable: true,
+ serviceId: topology.ServiceId))
+ .WithStreaming(
+ topology.ProviderName,
+ new KinesisProviderConfiguration(
+ aws,
+ topology,
+ stream.Resource.Name,
+ checkpoints.Resource.Name));
+
+ var silo = builder.AddProject("silo")
+ .WithReference(orleans)
+ .WithReference(stream)
+ .WithReference(pubSubStore)
+ .WithReference(checkpoints)
+ .WaitFor(stack)
+ .WithReplicas(3);
+
+ builder.AddProject("client")
+ .WithReference(orleans.AsClient())
+ .WithReference(stream)
+ .WaitFor(stack)
+ .WaitFor(silo);
+
+ builder.Build().Run();
+ }
+ //
+
//
public static void AdoNetAppHost(string[] args)
{
var builder = DistributedApplication.CreateBuilder(args);
- // Add a SQL Server instance and database.
- // Note: Aspire infers the Orleans provider type from the resource class name
- // (SqlServerDatabaseResource → "SqlServerDatabase"), which does not match
- // the Orleans provider name "AdoNet".
- //
- // There is no public API to override this inference in the current version
- // of Aspire.Hosting.Orleans. As a workaround, configure the Orleans providers
- // manually in the silo using UseOrleans(siloBuilder => {...}) and read the
- // connection string from IConfiguration.
- var sql = builder.AddSqlServer("sql");
- var db = sql.AddDatabase("orleans-db");
-
- // Pass the database resource so Aspire injects ConnectionStrings__orleans-db.
- // Then configure Orleans manually in the silo (see silo example).
+ var database = builder.AddSqlServer("sql")
+ .AddDatabase("orleans-db")
+ .WithCreationScript(File.ReadAllText("schema/sqlserver.sql"));
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(database)
+ .WithGrainStorage("Default", database)
+ .WithReminders(database)
+ .WithStreaming("streams", database)
+ .WithGrainDirectory("directory", database);
+
builder.AddProject("silo")
- .WithReference(db)
- .WaitFor(sql);
+ .WithReference(orleans)
+ .WaitFor(database);
builder.Build().Run();
}
//
+ //
+ public static void AdoNetPostgreSqlAppHost(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var postgres = builder.AddPostgres("postgres")
+ .WithInitFiles("schema/postgresql");
+ var database = postgres.AddDatabase("orleans-db");
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(database)
+ .WithGrainStorage("Default", database)
+ .WithReminders(database)
+ .WithStreaming("streams", database)
+ .WithGrainDirectory("directory", database);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WaitFor(database);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void AdoNetMySqlAppHost(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var mysql = builder.AddMySql("mysql")
+ .WithInitFiles("schema/mysql");
+ var database = mysql.AddDatabase("orleans-db");
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(database)
+ .WithGrainStorage("Default", database)
+ .WithReminders(database)
+ .WithStreaming("streams", database)
+ .WithGrainDirectory("directory", database);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WaitFor(database);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void AdoNetOracleAppHost(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var oracle = builder.AddOracle("oracle")
+ .WithInitFiles("schema/oracle");
+ var database = oracle.AddDatabase("orleans-db");
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(database)
+ .WithGrainStorage("Default", database)
+ .WithReminders(database);
+
+ builder.AddProject("silo")
+ .WithReference(orleans)
+ .WaitFor(database);
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void ExplicitAdoNetProviderAppHost(string[] args)
+ {
+ var builder = DistributedApplication.CreateBuilder(args);
+
+ var database = builder.AddConnectionString("orders-db");
+ var provider = new ExplicitAdoNetProviderConfiguration(database, "Npgsql");
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClustering(provider)
+ .WithGrainStorage("Default", provider)
+ .WithReminders(provider);
+
+ builder.AddProject("silo")
+ .WithReference(orleans);
+
+ builder.Build().Run();
+ }
+ //
+
//
public static void GrainDirectoryAppHost(string[] args)
{
@@ -263,4 +819,85 @@ public static void ExplicitClusterIds(string[] args)
builder.Build().Run();
}
//
+
+ private sealed class ExplicitAdoNetProviderConfiguration(
+ IResourceBuilder database,
+ string invariant) : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resourceBuilder,
+ string configurationSectionPath)
+ where T : IResourceWithEnvironment
+ {
+ var prefix = configurationSectionPath.Replace(":", "__", StringComparison.Ordinal);
+ resourceBuilder
+ .WithEnvironment($"Orleans__{prefix}__ProviderType", "AdoNet")
+ .WithEnvironment($"Orleans__{prefix}__Invariant", invariant)
+ .WithEnvironment($"Orleans__{prefix}__ServiceKey", database.Resource.Name)
+ .WithReference(database);
+ }
+ }
+
+ //
+ private sealed class KinesisProviderConfiguration(
+ IAWSSDKConfig aws,
+ KinesisTopology topology,
+ string streamServiceKey,
+ string checkpointServiceKey) : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resourceBuilder,
+ string configurationSectionPath)
+ where T : IResourceWithEnvironment
+ {
+ var prefix = $"Orleans__{configurationSectionPath.Replace(":", "__", StringComparison.Ordinal)}";
+ var region = aws.Region?.SystemName
+ ?? throw new InvalidOperationException("Kinesis streaming requires an AWS region.");
+ resourceBuilder
+ .WithReference(aws)
+ .WithEnvironment($"{prefix}__ProviderType", "Kinesis")
+ .WithEnvironment($"{prefix}__ServiceKey", streamServiceKey)
+ .WithEnvironment($"{prefix}__StreamName", topology.StreamName)
+ .WithEnvironment($"{prefix}__Region", region)
+ .WithEnvironment($"{prefix}__Checkpoint__Type", "DynamoDB")
+ .WithEnvironment($"{prefix}__Checkpoint__ServiceKey", checkpointServiceKey)
+ .WithEnvironment($"{prefix}__Checkpoint__Region", region)
+ .WithEnvironment($"{prefix}__Checkpoint__CreateIfNotExists", "false")
+ .WithEnvironment($"{prefix}__Checkpoint__UseProvisionedThroughput", "false");
+ }
+ }
+ //
+
+ //
+ private sealed record KinesisTopology(
+ string StackName,
+ string ClusterId,
+ string ServiceId,
+ string ProviderName,
+ RegionEndpoint Region,
+ string StreamResourceName,
+ string StreamName,
+ int ShardCount,
+ int RetentionHours,
+ string PubSubResourceName,
+ string PubSubTableName,
+ string CheckpointResourceName,
+ string CheckpointTableName)
+ {
+ public static KinesisTopology Orders { get; } = new(
+ StackName: "orders-kinesis",
+ ClusterId: "orders-v1",
+ ServiceId: "orders-service",
+ ProviderName: "Orders",
+ Region: RegionEndpoint.USWest2,
+ StreamResourceName: "orders-stream",
+ StreamName: "orleans-orders",
+ ShardCount: 4,
+ RetentionHours: 24,
+ PubSubResourceName: "orders-pubsub",
+ PubSubTableName: "orleans-orders-pubsub",
+ CheckpointResourceName: "orders-checkpoints",
+ CheckpointTableName: "orleans-orders-checkpoints");
+ }
+ //
}
diff --git a/docs/site/src/content/docs/host/snippets/aspire/AppHost/Projects.cs b/docs/site/src/content/docs/host/snippets/aspire/AppHost/Projects.cs
deleted file mode 100644
index 38943e8d432..00000000000
--- a/docs/site/src/content/docs/host/snippets/aspire/AppHost/Projects.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-// Stub project metadata classes for documentation examples.
-// In a real Aspire application, these are auto-generated by the Aspire SDK
-// from ProjectReference items. Since our sample projects are libraries
-// (not runnable apps), we define stubs to enable compilation.
-
-using Aspire.Hosting;
-
-namespace Projects;
-
-// Stub for the Silo project
-public class Silo : IProjectMetadata
-{
- public string ProjectPath => "../Silo/Silo.csproj";
-}
-
-// Stub for the Client project
-public class Client : IProjectMetadata
-{
- public string ProjectPath => "../Client/Client.csproj";
-}
diff --git a/docs/site/src/content/docs/host/snippets/aspire/Client/Client.csproj b/docs/site/src/content/docs/host/snippets/aspire/Client/Client.csproj
index 2f0f625952c..6e57eb65054 100644
--- a/docs/site/src/content/docs/host/snippets/aspire/Client/Client.csproj
+++ b/docs/site/src/content/docs/host/snippets/aspire/Client/Client.csproj
@@ -2,12 +2,14 @@
net10.0
- Library
+ Exe
+ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\..\..\..\..\..\..\..\'))
+
@@ -19,6 +21,8 @@
+
+
diff --git a/docs/site/src/content/docs/host/snippets/aspire/Client/ClientProgram.cs b/docs/site/src/content/docs/host/snippets/aspire/Client/ClientProgram.cs
index 2a3e10756bf..ba212724bd8 100644
--- a/docs/site/src/content/docs/host/snippets/aspire/Client/ClientProgram.cs
+++ b/docs/site/src/content/docs/host/snippets/aspire/Client/ClientProgram.cs
@@ -5,6 +5,18 @@ namespace Orleans.Docs.Snippets.Aspire.Client;
// This class contains example code for Orleans client configuration with Aspire
public static class ClientProgram
{
+ //
+ public static void KinesisStreaming(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ builder.AddServiceDefaults();
+ builder.UseOrleansClient();
+
+ builder.Build().Run();
+ }
+ //
+
//
public static void BasicClientConfiguration(string[] args)
{
diff --git a/docs/site/src/content/docs/host/snippets/aspire/Client/Program.cs b/docs/site/src/content/docs/host/snippets/aspire/Client/Program.cs
new file mode 100644
index 00000000000..b7e66fc99af
--- /dev/null
+++ b/docs/site/src/content/docs/host/snippets/aspire/Client/Program.cs
@@ -0,0 +1 @@
+Orleans.Docs.Snippets.Aspire.Client.ClientProgram.BasicClientConfiguration(args);
diff --git a/docs/site/src/content/docs/host/snippets/aspire/Silo/Program.cs b/docs/site/src/content/docs/host/snippets/aspire/Silo/Program.cs
new file mode 100644
index 00000000000..6c9db5e5c42
--- /dev/null
+++ b/docs/site/src/content/docs/host/snippets/aspire/Silo/Program.cs
@@ -0,0 +1 @@
+Orleans.Docs.Snippets.Aspire.Silo.SiloProgram.BasicSiloConfiguration(args);
diff --git a/docs/site/src/content/docs/host/snippets/aspire/Silo/Silo.csproj b/docs/site/src/content/docs/host/snippets/aspire/Silo/Silo.csproj
index 8b5ee5962a0..f50bf6ce3d1 100644
--- a/docs/site/src/content/docs/host/snippets/aspire/Silo/Silo.csproj
+++ b/docs/site/src/content/docs/host/snippets/aspire/Silo/Silo.csproj
@@ -2,7 +2,8 @@
net10.0
- Library
+ Exe
+ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\..\..\..\..\..\..\..\'))
@@ -10,18 +11,27 @@
+
+
+
+
+
+
+
+
+
@@ -30,6 +40,9 @@
+
+
+
diff --git a/docs/site/src/content/docs/host/snippets/aspire/Silo/SiloProgram.cs b/docs/site/src/content/docs/host/snippets/aspire/Silo/SiloProgram.cs
index 3bfc07fba7f..9da9fef39b0 100644
--- a/docs/site/src/content/docs/host/snippets/aspire/Silo/SiloProgram.cs
+++ b/docs/site/src/content/docs/host/snippets/aspire/Silo/SiloProgram.cs
@@ -2,6 +2,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
+using Orleans.Hosting;
using StackExchange.Redis;
namespace Orleans.Docs.Snippets.Aspire.Silo;
@@ -9,6 +10,18 @@ namespace Orleans.Docs.Snippets.Aspire.Silo;
// This class contains example code for Orleans silo configuration with Aspire
public static class SiloProgram
{
+ //
+ public static void KinesisStreaming(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ builder.AddServiceDefaults();
+ builder.UseOrleans();
+
+ builder.Build().Run();
+ }
+ //
+
//
public static void BasicSiloConfiguration(string[] args)
{
@@ -69,6 +82,59 @@ public static void AzureStorageConfiguration(string[] args)
}
//
+ //
+ public static void AzureStorageProvidersSilo(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ builder.AddServiceDefaults();
+ builder.AddKeyedAzureTableServiceClient("orleans-tables");
+ builder.AddKeyedAzureBlobServiceClient("orleans-blobs");
+ builder.UseOrleans();
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void CosmosProvidersSilo(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ builder.AddServiceDefaults();
+ builder.AddKeyedAzureCosmosClient("orleans-cosmos");
+ builder.UseOrleans();
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void RedisProvidersSilo(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ builder.AddServiceDefaults();
+ builder.AddKeyedRedisClient("orleans-redis");
+ builder.UseOrleans();
+
+ builder.Build().Run();
+ }
+ //
+
+ //
+ public static void EventHubsSilo(string[] args)
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+
+ builder.AddServiceDefaults();
+ builder.AddKeyedAzureTableServiceClient("checkpoint-tables");
+ builder.UseOrleans();
+
+ builder.Build().Run();
+ }
+ //
+
//
public static void ConfigureHealthChecks(IHostApplicationBuilder builder)
{
diff --git a/docs/site/src/content/docs/snippets/compiled/Streaming/KinesisSnippets.cs b/docs/site/src/content/docs/snippets/compiled/Streaming/KinesisSnippets.cs
index 81b9e157191..39fb7cc2362 100644
--- a/docs/site/src/content/docs/snippets/compiled/Streaming/KinesisSnippets.cs
+++ b/docs/site/src/content/docs/snippets/compiled/Streaming/KinesisSnippets.cs
@@ -1,4 +1,5 @@
//
+using Microsoft.Extensions.Hosting;
using Orleans.Hosting;
//
@@ -12,6 +13,20 @@ namespace Documentation.Streaming;
internal static class KinesisSnippets
{
+ internal static void ConfigureAspireSilo(HostApplicationBuilder builder)
+ {
+ //
+builder.UseOrleans();
+ //
+ }
+
+ internal static void ConfigureAspireClient(HostApplicationBuilder builder)
+ {
+ //
+builder.UseOrleansClient();
+ //
+ }
+
internal static void ConfigureSilo(ISiloBuilder siloBuilder)
{
//
diff --git a/docs/site/src/content/docs/streaming/kinesis-streaming.md b/docs/site/src/content/docs/streaming/kinesis-streaming.md
index ff14c2ed211..6ad7b6724cb 100644
--- a/docs/site/src/content/docs/streaming/kinesis-streaming.md
+++ b/docs/site/src/content/docs/streaming/kinesis-streaming.md
@@ -1,7 +1,7 @@
---
title: Stream with Amazon Kinesis
description: Configure Amazon Kinesis Data Streams for Orleans, including durable DynamoDB checkpoints.
-ms.date: 08/07/2026
+ms.date: 08/25/2026
ms.topic: how-to
---
@@ -9,7 +9,7 @@ ms.topic: how-to
The [`Microsoft.Orleans.Streaming.Kinesis`](https://www.nuget.org/packages/Microsoft.Orleans.Streaming.Kinesis) package connects Orleans persistent streams to [Amazon Kinesis Data Streams](https://docs.aws.amazon.com/streams/latest/dev/introduction.html). Each Kinesis shard is an Orleans queue, so the number of open shards bounds physical read parallelism. Kinesis retention determines how far a consumer can replay.
-Create the Kinesis data stream before starting Orleans. The provider discovers its shards but doesn't create, delete, split, or merge the stream.
+Provision the Kinesis data stream before starting Orleans. The provider maps every non-expired shard returned by Kinesis to an Orleans queue. Infrastructure automation owns stream creation, retention, encryption, and shard-count changes.
## Configure the silo
@@ -24,7 +24,41 @@ Configure every Orleans client which publishes through the provider with the sam
:::code language="csharp" source="../snippets/compiled/Streaming/KinesisSnippets.cs" id="configure_kinesis_client":::
-When explicit credentials aren't configured, the provider uses the [AWS SDK for .NET credential resolution chain](https://docs.aws.amazon.com/sdk-for-net/v4/developer-guide/creds-assign.html). In production, prefer workload credentials such as an IAM role. Set when using a custom Kinesis-compatible endpoint.
+For AWS regions and HTTPS endpoints, the provider uses the [AWS SDK for .NET credential resolution chain](https://docs.aws.amazon.com/sdk-for-net/v4/developer-guide/creds-assign.html) when explicit credentials are omitted. In production, prefer workload credentials such as an IAM role. Set when using a custom Kinesis-compatible endpoint. HTTP emulator endpoints use the development credentials `dummy` and `dummy` unless explicit keys are configured.
+
+## Configure Kinesis with Aspire
+
+The Aspire application model uses the AWS-supported [`Aspire.Hosting.AWS`](https://www.nuget.org/packages/Aspire.Hosting.AWS) integration to configure AWS SDK for .NET v4 and provision the complete durable topology through AWS CDK. The CDK stack owns the Kinesis stream, DynamoDB `PubSubStore`, and DynamoDB checkpoint table.
+
+Define stable physical names, stream capacity, retention, table schemas, and deployment identity once:
+
+:::code language="csharp" source="../host/snippets/aspire/AppHost/AppHostExamples.cs" id="kinesis_topology":::
+
+Create the AWS SDK configuration and CDK stack, then feed the same topology into the constructs and Orleans:
+
+:::code language="csharp" source="../host/snippets/aspire/AppHost/AppHostExamples.cs" id="kinesis_streaming_apphost":::
+
+The provider configuration references the AWS SDK metadata and emits the Kinesis resource identity plus DynamoDB checkpoint selection:
+
+:::code language="csharp" source="../host/snippets/aspire/AppHost/AppHostExamples.cs" id="kinesis_provider_configuration":::
+
+`WithReference(stream)` maps the CDK `StreamArn` output to `AWS:Resources:orders-stream:StreamArn`. The provider validates the ARN, while the shared topology supplies the effective stream name and region. The DynamoDB references map each `TableName` output through its service key. Provider-local configuration carries the checkpoint region and `PubSubStore` service ID in every deployment mode. Run-mode environment contains AWS profile and region metadata when the AWS SDK configuration selects them, and the AWS SDK credential chain supplies workload credentials.
+
+The `PubSubStore` table uses the `GrainReference` partition key and `GrainType` sort key required by DynamoDB grain storage. The checkpoint table uses `CheckpointNamespace` and `Partition`. CDK configures both tables for on-demand billing, and Orleans receives `CreateIfNotExists=false`, `UpdateIfExists=false`, and `UseProvisionedThroughput=false` for the infrastructure-owned grain storage. The direct checkpoint provider receives `CreateIfNotExists=false` and `UseProvisionedThroughput=false`.
+
+The silo waits for the CDK stack and activates generated configuration:
+
+:::code language="csharp" source="../host/snippets/aspire/Silo/SiloProgram.cs" id="kinesis_streaming_silo":::
+
+The publishing client waits for both the stack and silo, then activates the same stream provider name and physical stream:
+
+:::code language="csharp" source="../host/snippets/aspire/Client/ClientProgram.cs" id="kinesis_streaming_client":::
+
+The CDK stack deploys through CloudFormation in the account and region selected by the AWS SDK configuration. Grant the AppHost identity permission to create and update the stack. Bootstrap the environment when constructs use CDK assets. See [Provisioning application resources with AWS CDK](https://github.com/aws/integrations-on-dotnet-aspire-for-aws/blob/main/src/Aspire.Hosting.AWS/README.md#provisioning-application-resources-with-aws-cdk) for the integration contract.
+
+Keep `ClusterId`, `ServiceId`, provider name, stream name, and table names stable across rolling deployments. `PubSubStore` preserves explicit subscriptions, and the checkpoint table preserves the last delivered sequence number for every shard. A deliberate cutover can introduce new names, run both topologies during migration, drain consumers, and then retire the previous resources.
+
+For local Kinesis-compatible and DynamoDB services, configure `Service`, `Checkpoint:Service`, or the AWS SDK service-specific `AWS_ENDPOINT_URL_KINESIS` and `AWS_ENDPOINT_URL_DYNAMODB` variables. Keep the same stream and table identities so local activation exercises the deployed configuration contract.
## Choose checkpoint storage
@@ -56,6 +90,6 @@ Grant the application only the Kinesis data-plane and DynamoDB table permissions
Monitor Kinesis iterator age, read throttling, provisioned throughput, and retention together with the [Orleans streaming metrics](streaming-operations.md#observe-health). defaults to the fastest interval allowed by Kinesis for each shard.
-Live resharding isn't supported. If the shard topology changes while the provider is running, receivers stop rather than risk incorrect queue ownership. Restart the Orleans stream provider after splitting or merging shards.
+When the shard topology changes, receivers stop after detecting the new topology so ownership remains unambiguous. Restart the Orleans stream provider after the CDK or operational shard update completes. Increasing shard count changes read parallelism while the stable stream name, subscriptions, and checkpoints continue to identify the same durable stream.
For a complete configuration which uses DynamoDB for clustering, grain state, reminders, and Kinesis checkpoints, see the [AWS Kinesis and DynamoDB sample](https://github.com/dotnet/orleans/tree/main/samples/AWS/KinesisDynamoDB).
diff --git a/docs/site/src/content/docs/streaming/stream-providers.md b/docs/site/src/content/docs/streaming/stream-providers.md
index b74a1624f04..7daa2b917cd 100644
--- a/docs/site/src/content/docs/streaming/stream-providers.md
+++ b/docs/site/src/content/docs/streaming/stream-providers.md
@@ -58,6 +58,14 @@ Register [Azure Event Hubs](https://learn.microsoft.com/azure/event-hubs/event-h
New subscriptions can [begin with messages retained in the pulling agent's local cache](subscription-start-positions.md). This cache-local replay keeps the Event Hubs partition receiver and checkpoint at their current positions.
+Aspire can provision the Event Hubs namespace, hub, consumer group, and Azure Table checkpoint resource while emitting the Orleans stream-provider configuration:
+
+:::code language="csharp" source="../host/snippets/aspire/AppHost/AppHostExamples.cs" id="event_hubs_aspire":::
+
+Register the keyed checkpoint client before Orleans activates the stream provider:
+
+:::code language="csharp" source="../host/snippets/aspire/Silo/SiloProgram.cs" id="event_hubs_silo":::
+
The Event Hubs provider supports a custom data adapter for provider-specific wire formats. See [Integrate external stream producers and consumers](external-streams.md) when a non-Orleans application must publish to or consume from the same Event Hub.
## Amazon Kinesis
@@ -74,7 +82,9 @@ Register [ADO.NET](https://learn.microsoft.com/dotnet/framework/data/adonet/ado-
## NATS JetStream streaming (alpha)
-Register [NATS JetStream](https://docs.nats.io/nats-concepts/jetstream) with `AddNatsStreams`. The provider creates or uses a JetStream stream and deterministic subject partitions. File-backed storage is the default; memory-backed JetStream storage is optional and not durable across server loss. Changes to `NatsOptions.PartitionCount` require corresponding server-side stream updates. The provider isn't rewindable.
+Register [NATS JetStream](https://docs.nats.io/nats-concepts/jetstream) with `AddNatsStreams`. The provider creates or uses a JetStream stream and deterministic subject partitions. File-backed storage is the default; memory-backed storage serves transient workloads. Changes to `NatsOptions.PartitionCount` require corresponding server-side stream updates. The provider delivers events through durable JetStream consumers.
+
+Aspire applications can model the server with `Aspire.Hosting.Nats`, enable JetStream with `WithJetStream`, and connect it to Orleans with `WithStreaming`. Silo and client projects register the matching keyed `INatsConnection` with `AddKeyedNatsClient` before calling `UseOrleans` or `UseOrleansClient`. The package [README](https://www.nuget.org/packages/Microsoft.Orleans.Streaming.NATS) contains the complete AppHost and application configuration.
## Redis Streams streaming (alpha)
diff --git a/docs/site/src/content/docs/toc.yml b/docs/site/src/content/docs/toc.yml
index 02dd32bf151..e48d743d8fe 100644
--- a/docs/site/src/content/docs/toc.yml
+++ b/docs/site/src/content/docs/toc.yml
@@ -214,6 +214,8 @@ items:
href: host/client.md
- name: Aspire integration
href: host/aspire-integration.md
+ - name: Amazon DynamoDB with Aspire
+ href: host/dynamodb-aspire.md
- name: Silo lifecycle
href: host/silo-lifecycle.md
- name: Heterogeneous silos
diff --git a/samples/JournalingAzureBlobJson/README.md b/samples/JournalingAzureBlobJson/README.md
index ad5c1ec71a9..87ea46ada37 100644
--- a/samples/JournalingAzureBlobJson/README.md
+++ b/samples/JournalingAzureBlobJson/README.md
@@ -16,3 +16,8 @@ aspire run --project JournalingAzureBlobJson.AppHost
The application writes a scenario, verifies the recovered state, and prints the raw
JSON Lines journal stored by the Azure Storage emulator.
+
+The AppHost starts Azurite and injects the `blobs` connection into the Orleans
+application. The application creates the configured blob container, and the Orleans
+Azure Blob journal provider owns the write-ahead log and checkpoint blobs used to
+recover grain state.
diff --git a/src/AWS/Orleans.Clustering.DynamoDB/AWSUtilsHostingExtensions.cs b/src/AWS/Orleans.Clustering.DynamoDB/AWSUtilsHostingExtensions.cs
index 42bc1455952..f84549f92db 100644
--- a/src/AWS/Orleans.Clustering.DynamoDB/AWSUtilsHostingExtensions.cs
+++ b/src/AWS/Orleans.Clustering.DynamoDB/AWSUtilsHostingExtensions.cs
@@ -1,9 +1,10 @@
+using System;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Orleans.Clustering.DynamoDB;
using Orleans.Configuration;
using Orleans.Messaging;
-using System;
-using Microsoft.Extensions.Options;
+using Orleans.Runtime;
namespace Orleans.Hosting
{
@@ -36,6 +37,10 @@ public static ISiloBuilder UseDynamoDBClustering(
services.Configure(configureOptions);
}
+ services.AddTransient(sp =>
+ new DynamoDBClusteringOptionsValidator(
+ sp.GetRequiredService>().Value));
+ services.ConfigureFormatter();
services.AddSingleton();
});
}
@@ -60,6 +65,10 @@ public static ISiloBuilder UseDynamoDBClustering(
services =>
{
configureOptions?.Invoke(services.AddOptions());
+ services.AddTransient(sp =>
+ new DynamoDBClusteringOptionsValidator(
+ sp.GetRequiredService>().Value));
+ services.ConfigureFormatter();
services.AddSingleton();
});
}
@@ -88,6 +97,10 @@ public static IClientBuilder UseDynamoDBClustering(
services.Configure(configureOptions);
}
+ services.AddTransient(sp =>
+ new DynamoDBGatewayOptionsValidator(
+ sp.GetRequiredService>().Value));
+ services.ConfigureFormatter();
services.AddSingleton();
});
}
@@ -112,6 +125,10 @@ public static IClientBuilder UseDynamoDBClustering(
services =>
{
configureOptions?.Invoke(services.AddOptions());
+ services.AddTransient(sp =>
+ new DynamoDBGatewayOptionsValidator(
+ sp.GetRequiredService>().Value));
+ services.ConfigureFormatter();
services.AddSingleton();
});
}
diff --git a/src/AWS/Orleans.Clustering.DynamoDB/DynamoDBClusteringProviderBuilder.cs b/src/AWS/Orleans.Clustering.DynamoDB/DynamoDBClusteringProviderBuilder.cs
index cc506066d08..581d891ec89 100644
--- a/src/AWS/Orleans.Clustering.DynamoDB/DynamoDBClusteringProviderBuilder.cs
+++ b/src/AWS/Orleans.Clustering.DynamoDB/DynamoDBClusteringProviderBuilder.cs
@@ -1,6 +1,10 @@
using System;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Orleans;
+using Orleans.Clustering.DynamoDB;
+using Orleans.Configuration;
using Orleans.Hosting;
using Orleans.Providers;
@@ -13,135 +17,83 @@ internal sealed class DynamoDBClusteringProviderBuilder : IProviderBuilder
+ builder.UseDynamoDBClustering((OptionsBuilder optionsBuilder) =>
+ optionsBuilder.Configure((options, configuration) =>
{
- var accessKey = configurationSection[nameof(options.AccessKey)];
- if (!string.IsNullOrEmpty(accessKey))
- {
- options.AccessKey = accessKey;
- }
-
- var secretKey = configurationSection[nameof(options.SecretKey)];
- if (!string.IsNullOrEmpty(secretKey))
- {
- options.SecretKey = secretKey;
- }
-
- var region = configurationSection[nameof(options.Service)] ?? configurationSection["Region"];
- if (!string.IsNullOrEmpty(region))
- {
- options.Service = region;
- }
-
- var token = configurationSection[nameof(options.Token)];
- if (!string.IsNullOrEmpty(token))
- {
- options.Token = token;
- }
-
- var profileName = configurationSection[nameof(options.ProfileName)];
- if (!string.IsNullOrEmpty(profileName))
- {
- options.ProfileName = profileName;
- }
+ var providerConfiguration = DynamoDBProviderConfiguration.Create(configurationSection, configuration);
+ providerConfiguration.ConfigureClientOptions(options);
- var tableName = configurationSection[nameof(options.TableName)];
- if (!string.IsNullOrEmpty(tableName))
+ var tableName = providerConfiguration.GetValue(nameof(options.TableName));
+ if (tableName is not null)
{
options.TableName = tableName;
}
- if (int.TryParse(configurationSection[nameof(options.ReadCapacityUnits)], out var rcu))
+ if (providerConfiguration.GetInt32(nameof(options.ReadCapacityUnits)) is { } rcu)
{
options.ReadCapacityUnits = rcu;
}
- if (int.TryParse(configurationSection[nameof(options.WriteCapacityUnits)], out var wcu))
+ if (providerConfiguration.GetInt32(nameof(options.WriteCapacityUnits)) is { } wcu)
{
options.WriteCapacityUnits = wcu;
}
- if (bool.TryParse(configurationSection[nameof(options.UseProvisionedThroughput)], out var upt))
+ if (providerConfiguration.GetBoolean(nameof(options.UseProvisionedThroughput)) is { } upt)
{
options.UseProvisionedThroughput = upt;
}
- if (bool.TryParse(configurationSection[nameof(options.CreateIfNotExists)], out var cine))
+ if (providerConfiguration.GetBoolean(nameof(options.CreateIfNotExists)) is { } cine)
{
options.CreateIfNotExists = cine;
}
- if (bool.TryParse(configurationSection[nameof(options.UpdateIfExists)], out var uie))
+ if (providerConfiguration.GetBoolean(nameof(options.UpdateIfExists)) is { } uie)
{
options.UpdateIfExists = uie;
}
- });
+ }));
}
public void Configure(IClientBuilder builder, string? name, IConfigurationSection configurationSection)
{
- builder.UseDynamoDBClustering(options =>
+ builder.UseDynamoDBClustering((OptionsBuilder optionsBuilder) =>
+ optionsBuilder.Configure((options, configuration) =>
{
- var accessKey = configurationSection[nameof(options.AccessKey)];
- if (!string.IsNullOrEmpty(accessKey))
- {
- options.AccessKey = accessKey;
- }
-
- var secretKey = configurationSection[nameof(options.SecretKey)];
- if (!string.IsNullOrEmpty(secretKey))
- {
- options.SecretKey = secretKey;
- }
-
- var region = configurationSection[nameof(options.Service)] ?? configurationSection["Region"];
- if (!string.IsNullOrEmpty(region))
- {
- options.Service = region;
- }
-
- var token = configurationSection[nameof(options.Token)];
- if (!string.IsNullOrEmpty(token))
- {
- options.Token = token;
- }
-
- var profileName = configurationSection[nameof(options.ProfileName)];
- if (!string.IsNullOrEmpty(profileName))
- {
- options.ProfileName = profileName;
- }
+ var providerConfiguration = DynamoDBProviderConfiguration.Create(configurationSection, configuration);
+ providerConfiguration.ConfigureClientOptions(options);
- var tableName = configurationSection[nameof(options.TableName)];
- if (!string.IsNullOrEmpty(tableName))
+ var tableName = providerConfiguration.GetValue(nameof(options.TableName));
+ if (tableName is not null)
{
options.TableName = tableName;
}
- if (int.TryParse(configurationSection[nameof(options.ReadCapacityUnits)], out var rcu))
+ if (providerConfiguration.GetInt32(nameof(options.ReadCapacityUnits)) is { } rcu)
{
options.ReadCapacityUnits = rcu;
}
- if (int.TryParse(configurationSection[nameof(options.WriteCapacityUnits)], out var wcu))
+ if (providerConfiguration.GetInt32(nameof(options.WriteCapacityUnits)) is { } wcu)
{
options.WriteCapacityUnits = wcu;
}
- if (bool.TryParse(configurationSection[nameof(options.UseProvisionedThroughput)], out var upt))
+ if (providerConfiguration.GetBoolean(nameof(options.UseProvisionedThroughput)) is { } upt)
{
options.UseProvisionedThroughput = upt;
}
- if (bool.TryParse(configurationSection[nameof(options.CreateIfNotExists)], out var cine))
+ if (providerConfiguration.GetBoolean(nameof(options.CreateIfNotExists)) is { } cine)
{
options.CreateIfNotExists = cine;
}
- if (bool.TryParse(configurationSection[nameof(options.UpdateIfExists)], out var uie))
+ if (providerConfiguration.GetBoolean(nameof(options.UpdateIfExists)) is { } uie)
{
options.UpdateIfExists = uie;
}
- });
+ }));
}
}
diff --git a/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBClusteringOptions.cs b/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBClusteringOptions.cs
index d996ffd25e4..c7764dccf37 100644
--- a/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBClusteringOptions.cs
+++ b/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBClusteringOptions.cs
@@ -1,4 +1,5 @@
using Orleans.Clustering.DynamoDB;
+using Orleans.Runtime;
namespace Orleans.Configuration
{
@@ -38,4 +39,16 @@ public class DynamoDBClusteringOptions : DynamoDBClientOptions
///
public string TableName { get; set; } = "OrleansSilos";
}
+
+ internal sealed class DynamoDBClusteringOptionsValidator(DynamoDBClusteringOptions options) : IConfigurationValidator
+ {
+ public void ValidateConfiguration()
+ => DynamoDBProviderConfiguration.ValidateTableOptions(
+ options,
+ options.TableName,
+ options.UseProvisionedThroughput,
+ options.ReadCapacityUnits,
+ options.WriteCapacityUnits,
+ "DynamoDB clustering");
+ }
}
diff --git a/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBGatewayOptions.cs b/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBGatewayOptions.cs
index 16da5c2d5ae..0efd772a340 100644
--- a/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBGatewayOptions.cs
+++ b/src/AWS/Orleans.Clustering.DynamoDB/Options/DynamoDBGatewayOptions.cs
@@ -1,4 +1,5 @@
using Orleans.Clustering.DynamoDB;
+using Orleans.Runtime;
namespace Orleans.Configuration
{
@@ -38,4 +39,16 @@ public class DynamoDBGatewayOptions : DynamoDBClientOptions
///
public string TableName { get; set; } = "OrleansSilos";
}
+
+ internal sealed class DynamoDBGatewayOptionsValidator(DynamoDBGatewayOptions options) : IConfigurationValidator
+ {
+ public void ValidateConfiguration()
+ => DynamoDBProviderConfiguration.ValidateTableOptions(
+ options,
+ options.TableName,
+ options.UseProvisionedThroughput,
+ options.ReadCapacityUnits,
+ options.WriteCapacityUnits,
+ "DynamoDB gateway discovery");
+ }
}
diff --git a/src/AWS/Orleans.Clustering.DynamoDB/Orleans.Clustering.DynamoDB.csproj b/src/AWS/Orleans.Clustering.DynamoDB/Orleans.Clustering.DynamoDB.csproj
index c79e153aa7d..4a33726aac1 100644
--- a/src/AWS/Orleans.Clustering.DynamoDB/Orleans.Clustering.DynamoDB.csproj
+++ b/src/AWS/Orleans.Clustering.DynamoDB/Orleans.Clustering.DynamoDB.csproj
@@ -18,6 +18,7 @@
+
@@ -35,4 +36,3 @@
-
diff --git a/src/AWS/Orleans.Clustering.DynamoDB/README.md b/src/AWS/Orleans.Clustering.DynamoDB/README.md
index 8a66fa8eef5..25f0ddd5c76 100644
--- a/src/AWS/Orleans.Clustering.DynamoDB/README.md
+++ b/src/AWS/Orleans.Clustering.DynamoDB/README.md
@@ -69,6 +69,10 @@ await host.WaitForShutdownAsync();
The AWS SDK credential chain supplies credentials for regions and HTTPS endpoints when explicit keys and a profile are omitted. HTTP endpoints use the local-development credentials `dummy` and `dummyKey` in that case. Configure explicit credentials when a DynamoDB Local instance uses an access key to select its database.
+## Aspire
+
+AWS Aspire can run DynamoDB Local and provide CDK or CloudFormation table outputs to Orleans. See [Use Amazon DynamoDB with Aspire](https://dotnet.github.io/orleans/docs/host/dynamodb-aspire/) for clustering, grain storage, reminders, identity, and table lifecycle guidance.
+
## Documentation
For more comprehensive documentation, please refer to:
- [Microsoft Orleans Documentation](https://dotnet.github.io/orleans/docs/)
diff --git a/src/AWS/Orleans.Persistence.DynamoDB/Hosting/DynamoDBGrainStorageProviderBuilder.cs b/src/AWS/Orleans.Persistence.DynamoDB/Hosting/DynamoDBGrainStorageProviderBuilder.cs
index 59eb923a631..d779fbdc22c 100644
--- a/src/AWS/Orleans.Persistence.DynamoDB/Hosting/DynamoDBGrainStorageProviderBuilder.cs
+++ b/src/AWS/Orleans.Persistence.DynamoDB/Hosting/DynamoDBGrainStorageProviderBuilder.cs
@@ -5,6 +5,7 @@
using Orleans;
using Orleans.Configuration;
using Orleans.Hosting;
+using Orleans.Persistence.DynamoDB;
using Orleans.Providers;
using Orleans.Storage;
@@ -16,83 +17,57 @@ internal sealed class DynamoDBGrainStorageProviderBuilder : IProviderBuilder optionsBuilder) => optionsBuilder.Configure((options, services) =>
{
- var accessKey = configurationSection[nameof(options.AccessKey)];
- if (!string.IsNullOrEmpty(accessKey))
- {
- options.AccessKey = accessKey;
- }
-
- var secretKey = configurationSection[nameof(options.SecretKey)];
- if (!string.IsNullOrEmpty(secretKey))
- {
- options.SecretKey = secretKey;
- }
-
- var region = configurationSection[nameof(options.Service)] ?? configurationSection["Region"];
- if (!string.IsNullOrEmpty(region))
- {
- options.Service = region;
- }
-
- var token = configurationSection[nameof(options.Token)];
- if (!string.IsNullOrEmpty(token))
- {
- options.Token = token;
- }
-
- var profileName = configurationSection[nameof(options.ProfileName)];
- if (!string.IsNullOrEmpty(profileName))
- {
- options.ProfileName = profileName;
- }
+ var providerConfiguration = DynamoDBProviderConfiguration.Create(configurationSection, configuration);
+ providerConfiguration.ConfigureClientOptions(options);
- var serviceId = configurationSection[nameof(options.ServiceId)];
- if (!string.IsNullOrEmpty(serviceId))
+ var serviceId = providerConfiguration.GetValue(nameof(options.ServiceId));
+ if (serviceId is not null)
{
options.ServiceId = serviceId;
}
- var tableName = configurationSection[nameof(options.TableName)];
- if (!string.IsNullOrEmpty(tableName))
+ var tableName = providerConfiguration.GetValue(nameof(options.TableName));
+ if (tableName is not null)
{
options.TableName = tableName;
}
- if (int.TryParse(configurationSection[nameof(options.ReadCapacityUnits)], out var rcu))
+ if (providerConfiguration.GetInt32(nameof(options.ReadCapacityUnits)) is { } rcu)
{
options.ReadCapacityUnits = rcu;
}
- if (int.TryParse(configurationSection[nameof(options.WriteCapacityUnits)], out var wcu))
+ if (providerConfiguration.GetInt32(nameof(options.WriteCapacityUnits)) is { } wcu)
{
options.WriteCapacityUnits = wcu;
}
- if (bool.TryParse(configurationSection[nameof(options.UseProvisionedThroughput)], out var upt))
+ if (providerConfiguration.GetBoolean(nameof(options.UseProvisionedThroughput)) is { } upt)
{
options.UseProvisionedThroughput = upt;
}
- if (bool.TryParse(configurationSection[nameof(options.CreateIfNotExists)], out var cine))
+ if (providerConfiguration.GetBoolean(nameof(options.CreateIfNotExists)) is { } cine)
{
options.CreateIfNotExists = cine;
}
- if (bool.TryParse(configurationSection[nameof(options.UpdateIfExists)], out var uie))
+ if (providerConfiguration.GetBoolean(nameof(options.UpdateIfExists)) is { } uie)
{
options.UpdateIfExists = uie;
}
- if (bool.TryParse(configurationSection[nameof(options.DeleteStateOnClear)], out var dsoc))
+ if (providerConfiguration.GetBoolean(nameof(options.DeleteStateOnClear)) is { } dsoc)
{
options.DeleteStateOnClear = dsoc;
}
- if (TimeSpan.TryParse(configurationSection[nameof(options.TimeToLive)], out var ttl))
+ if (providerConfiguration.GetTimeSpan(nameof(options.TimeToLive)) is { } ttl)
{
options.TimeToLive = ttl;
}
diff --git a/src/AWS/Orleans.Persistence.DynamoDB/Options/DynamoDBStorageOptions.cs b/src/AWS/Orleans.Persistence.DynamoDB/Options/DynamoDBStorageOptions.cs
index b85ae7ce454..7eac317e29f 100644
--- a/src/AWS/Orleans.Persistence.DynamoDB/Options/DynamoDBStorageOptions.cs
+++ b/src/AWS/Orleans.Persistence.DynamoDB/Options/DynamoDBStorageOptions.cs
@@ -93,20 +93,14 @@ public DynamoDBGrainStorageOptionsValidator(DynamoDBStorageOptions options, stri
///
public void ValidateConfiguration()
{
- if (string.IsNullOrWhiteSpace(this.options.TableName))
- throw new OrleansConfigurationException(
- $"Configuration for DynamoDBGrainStorage {this.name} is invalid. {nameof(this.options.TableName)} is not valid.");
-
- if (this.options.UseProvisionedThroughput)
- {
- if (this.options.ReadCapacityUnits == 0)
- throw new OrleansConfigurationException(
- $"Configuration for DynamoDBGrainStorage {this.name} is invalid. {nameof(this.options.ReadCapacityUnits)} is not valid.");
-
- if (this.options.WriteCapacityUnits == 0)
- throw new OrleansConfigurationException(
- $"Configuration for DynamoDBGrainStorage {this.name} is invalid. {nameof(this.options.WriteCapacityUnits)} is not valid.");
- }
+ DynamoDBProviderConfiguration.ValidateTableOptions(
+ options,
+ options.TableName,
+ options.UseProvisionedThroughput,
+ options.ReadCapacityUnits,
+ options.WriteCapacityUnits,
+ $"DynamoDB grain storage provider '{name}'",
+ reportSpecificCapacity: true);
}
}
}
diff --git a/src/AWS/Orleans.Persistence.DynamoDB/Orleans.Persistence.DynamoDB.csproj b/src/AWS/Orleans.Persistence.DynamoDB/Orleans.Persistence.DynamoDB.csproj
index 72b46b952f9..afc74820481 100644
--- a/src/AWS/Orleans.Persistence.DynamoDB/Orleans.Persistence.DynamoDB.csproj
+++ b/src/AWS/Orleans.Persistence.DynamoDB/Orleans.Persistence.DynamoDB.csproj
@@ -19,6 +19,7 @@
+
@@ -34,4 +35,3 @@
-
diff --git a/src/AWS/Orleans.Persistence.DynamoDB/README.md b/src/AWS/Orleans.Persistence.DynamoDB/README.md
index e67c597b1b5..f9cd9c0410e 100644
--- a/src/AWS/Orleans.Persistence.DynamoDB/README.md
+++ b/src/AWS/Orleans.Persistence.DynamoDB/README.md
@@ -42,6 +42,10 @@ await builder.RunAsync();
The AWS SDK credential chain supplies credentials for regions and HTTPS endpoints when explicit keys and a profile are omitted. HTTP endpoints use the local-development credentials `dummy` and `dummyKey` in that case. Configure explicit credentials when a DynamoDB Local instance uses an access key to select its database.
+## Aspire
+
+AWS Aspire can run DynamoDB Local and provide CDK or CloudFormation table outputs to Orleans. See [Use Amazon DynamoDB with Aspire](https://dotnet.github.io/orleans/docs/host/dynamodb-aspire/) for clustering, grain storage, reminders, identity, and table lifecycle guidance.
+
## Example - Using Grain Storage in a Grain
```csharp
// Define grain state class
diff --git a/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBRemindersProviderBuilder.cs b/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBRemindersProviderBuilder.cs
index 4cc3142b9a8..d87d19c072f 100644
--- a/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBRemindersProviderBuilder.cs
+++ b/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBRemindersProviderBuilder.cs
@@ -1,7 +1,11 @@
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Orleans;
+using Orleans.Configuration;
using Orleans.Hosting;
using Orleans.Providers;
+using Orleans.Reminders.DynamoDB;
[assembly: RegisterProvider("DynamoDB", "Reminders", "Silo", typeof(DynamoDBRemindersProviderBuilder))]
@@ -11,68 +15,46 @@ internal sealed class DynamoDBRemindersProviderBuilder : IProviderBuilder
+ builder.ConfigureServices(services =>
+ {
+ services.UseDynamoDBReminderService(_ => { });
+ services.AddOptions()
+ .Configure((options, configuration) =>
{
- var accessKey = configurationSection[nameof(options.AccessKey)];
- if (!string.IsNullOrEmpty(accessKey))
- {
- options.AccessKey = accessKey;
- }
-
- var secretKey = configurationSection[nameof(options.SecretKey)];
- if (!string.IsNullOrEmpty(secretKey))
- {
- options.SecretKey = secretKey;
- }
-
- var region = configurationSection[nameof(options.Service)] ?? configurationSection["Region"];
- if (!string.IsNullOrEmpty(region))
- {
- options.Service = region;
- }
-
- var token = configurationSection[nameof(options.Token)];
- if (!string.IsNullOrEmpty(token))
- {
- options.Token = token;
- }
-
- var profileName = configurationSection[nameof(options.ProfileName)];
- if (!string.IsNullOrEmpty(profileName))
- {
- options.ProfileName = profileName;
- }
+ var providerConfiguration = DynamoDBProviderConfiguration.Create(configurationSection, configuration);
+ providerConfiguration.ConfigureClientOptions(options);
- var tableName = configurationSection[nameof(options.TableName)];
- if (!string.IsNullOrEmpty(tableName))
+ var tableName = providerConfiguration.GetValue(nameof(options.TableName));
+ if (tableName is not null)
{
options.TableName = tableName;
}
- if (int.TryParse(configurationSection[nameof(options.ReadCapacityUnits)], out var rcu))
+ if (providerConfiguration.GetInt32(nameof(options.ReadCapacityUnits)) is { } rcu)
{
options.ReadCapacityUnits = rcu;
}
- if (int.TryParse(configurationSection[nameof(options.WriteCapacityUnits)], out var wcu))
+ if (providerConfiguration.GetInt32(nameof(options.WriteCapacityUnits)) is { } wcu)
{
options.WriteCapacityUnits = wcu;
}
- if (bool.TryParse(configurationSection[nameof(options.UseProvisionedThroughput)], out var upt))
+ if (providerConfiguration.GetBoolean(nameof(options.UseProvisionedThroughput)) is { } upt)
{
options.UseProvisionedThroughput = upt;
}
- if (bool.TryParse(configurationSection[nameof(options.CreateIfNotExists)], out var cine))
+ if (providerConfiguration.GetBoolean(nameof(options.CreateIfNotExists)) is { } cine)
{
options.CreateIfNotExists = cine;
}
- if (bool.TryParse(configurationSection[nameof(options.UpdateIfExists)], out var uie))
+ if (providerConfiguration.GetBoolean(nameof(options.UpdateIfExists)) is { } uie)
{
options.UpdateIfExists = uie;
}
});
+ });
}
}
diff --git a/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBServiceCollectionReminderExtensions.cs b/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBServiceCollectionReminderExtensions.cs
index 7d99d3649f7..a8a43dbccb1 100644
--- a/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBServiceCollectionReminderExtensions.cs
+++ b/src/AWS/Orleans.Reminders.DynamoDB/DynamoDBServiceCollectionReminderExtensions.cs
@@ -1,7 +1,9 @@
+using System;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
using Orleans.Configuration;
using Orleans.Reminders.DynamoDB;
-using System;
+using Orleans.Runtime;
namespace Orleans.Hosting
{
@@ -27,6 +29,9 @@ public static IServiceCollection UseDynamoDBReminderService(this IServiceCollect
services.AddReminders();
services.AddSingleton();
services.Configure(configure);
+ services.AddTransient(sp =>
+ new DynamoDBReminderStorageOptionsValidator(
+ sp.GetRequiredService>().Value));
services.ConfigureFormatter();
return services;
}
diff --git a/src/AWS/Orleans.Reminders.DynamoDB/Orleans.Reminders.DynamoDB.csproj b/src/AWS/Orleans.Reminders.DynamoDB/Orleans.Reminders.DynamoDB.csproj
index 43b79ca5ac3..70b49e269a8 100644
--- a/src/AWS/Orleans.Reminders.DynamoDB/Orleans.Reminders.DynamoDB.csproj
+++ b/src/AWS/Orleans.Reminders.DynamoDB/Orleans.Reminders.DynamoDB.csproj
@@ -18,6 +18,7 @@
+
@@ -37,4 +38,3 @@
-
diff --git a/src/AWS/Orleans.Reminders.DynamoDB/README.md b/src/AWS/Orleans.Reminders.DynamoDB/README.md
index e7fdacde94a..f436a284ce2 100644
--- a/src/AWS/Orleans.Reminders.DynamoDB/README.md
+++ b/src/AWS/Orleans.Reminders.DynamoDB/README.md
@@ -51,6 +51,10 @@ await host.WaitForShutdownAsync();
`UseDynamoDBReminderService` configures reminder storage independently from the cluster membership provider. The AWS SDK credential and profile resolution chain supplies credentials when the reminder options omit explicit keys. This example uses on-demand capacity and an infrastructure-managed table.
+## Aspire
+
+AWS Aspire can run DynamoDB Local and provide CDK or CloudFormation table outputs to Orleans. See [Use Amazon DynamoDB with Aspire](https://dotnet.github.io/orleans/docs/host/dynamodb-aspire/) for clustering, grain storage, reminders, identity, and table lifecycle guidance.
+
## Example - Using Reminders in a Grain
```csharp
using System;
diff --git a/src/AWS/Orleans.Reminders.DynamoDB/Reminders/DynamoDbReminderStorageOptions.cs b/src/AWS/Orleans.Reminders.DynamoDB/Reminders/DynamoDbReminderStorageOptions.cs
index 848724abd61..d6d5c907003 100644
--- a/src/AWS/Orleans.Reminders.DynamoDB/Reminders/DynamoDbReminderStorageOptions.cs
+++ b/src/AWS/Orleans.Reminders.DynamoDB/Reminders/DynamoDbReminderStorageOptions.cs
@@ -1,4 +1,5 @@
using Orleans.Reminders.DynamoDB;
+using Orleans.Runtime;
namespace Orleans.Configuration
{
@@ -38,4 +39,15 @@ public class DynamoDBReminderStorageOptions : DynamoDBClientOptions
///
public string TableName { get; set; } = "OrleansReminders";
}
+ internal sealed class DynamoDBReminderStorageOptionsValidator(DynamoDBReminderStorageOptions options) : IConfigurationValidator
+ {
+ public void ValidateConfiguration()
+ => DynamoDBProviderConfiguration.ValidateTableOptions(
+ options,
+ options.TableName,
+ options.UseProvisionedThroughput,
+ options.ReadCapacityUnits,
+ options.WriteCapacityUnits,
+ "DynamoDB reminder storage");
+ }
}
diff --git a/src/AWS/Orleans.Streaming.Kinesis/Hosting/KinesisStreamProviderBuilder.cs b/src/AWS/Orleans.Streaming.Kinesis/Hosting/KinesisStreamProviderBuilder.cs
new file mode 100644
index 00000000000..c61f0c4113f
--- /dev/null
+++ b/src/AWS/Orleans.Streaming.Kinesis/Hosting/KinesisStreamProviderBuilder.cs
@@ -0,0 +1,319 @@
+using System;
+using System.Globalization;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Orleans;
+using Orleans.Configuration;
+using Orleans.Hosting;
+using Orleans.Providers;
+using Orleans.Streaming.Kinesis;
+using Orleans.Streams;
+
+[assembly: RegisterProvider("Kinesis", "Streaming", "Silo", typeof(KinesisStreamProviderBuilder))]
+[assembly: RegisterProvider("Kinesis", "Streaming", "Client", typeof(KinesisStreamProviderBuilder))]
+[assembly: RegisterProvider("AmazonKinesis", "Streaming", "Silo", typeof(KinesisStreamProviderBuilder))]
+[assembly: RegisterProvider("AmazonKinesis", "Streaming", "Client", typeof(KinesisStreamProviderBuilder))]
+[assembly: RegisterProvider("KinesisStream", "Streaming", "Silo", typeof(KinesisStreamProviderBuilder))]
+[assembly: RegisterProvider("KinesisStream", "Streaming", "Client", typeof(KinesisStreamProviderBuilder))]
+
+namespace Orleans.Hosting;
+
+internal sealed class KinesisStreamProviderBuilder : IProviderBuilder, IProviderBuilder
+{
+ private const string AwsResourcesConfigurationSection = "AWS:Resources";
+ private const string GrainCheckpointStorage = "Grain";
+ private const string DynamoDBCheckpointStorage = "DynamoDB";
+
+ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection configurationSection)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ builder.AddKinesisStreams(name, configurator =>
+ {
+ configurator.ConfigureKinesis(GetKinesisOptionsBuilder(name, configurationSection));
+ ConfigureCheckpointer(configurator, name, configurationSection);
+ });
+ }
+
+ public void Configure(IClientBuilder builder, string? name, IConfigurationSection configurationSection)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(name);
+
+ builder.AddKinesisStreams(name, configurator =>
+ configurator.ConfigureKinesis(GetKinesisOptionsBuilder(name, configurationSection)));
+ }
+
+ private static Action> GetKinesisOptionsBuilder(
+ string name,
+ IConfigurationSection configurationSection)
+ {
+ return optionsBuilder => optionsBuilder.Configure((options, configuration) =>
+ {
+ var streamArn = configurationSection["StreamArn"]
+ ?? GetAwsResourceProperty(configuration, configurationSection, "StreamArn");
+ var streamName = configurationSection[nameof(options.StreamName)];
+ var region = configurationSection[nameof(options.Region)];
+
+ if (!string.IsNullOrWhiteSpace(streamArn))
+ {
+ var resource = ParseKinesisStreamArn(streamArn, name);
+ streamName ??= resource.StreamName;
+ region ??= resource.Region;
+ }
+
+ var connectionString = GetConnectionString(configuration, configurationSection, name);
+ if (!string.IsNullOrWhiteSpace(connectionString))
+ {
+ options.ConnectionString = connectionString;
+ }
+
+ if (string.IsNullOrWhiteSpace(streamName))
+ {
+ throw new OrleansConfigurationException(
+ $"Kinesis stream provider '{name}' requires StreamName or a referenced AWS Aspire StreamArn.");
+ }
+
+ options.StreamName = streamName;
+ options.Region = region
+ ?? options.Region
+ ?? configuration["AWS:Region"]
+ ?? configuration["AWS_REGION"]
+ ?? configuration["AWS_DEFAULT_REGION"];
+ options.Service = configurationSection[nameof(options.Service)]
+ ?? options.Service
+ ?? configuration["AWS_ENDPOINT_URL_KINESIS"];
+ options.AccessKey = configurationSection[nameof(options.AccessKey)] ?? options.AccessKey;
+ options.SecretKey = configurationSection[nameof(options.SecretKey)] ?? options.SecretKey;
+
+ if (GetTimeSpan(configurationSection, nameof(options.GetRecordsInterval), name) is { } getRecordsInterval)
+ {
+ options.GetRecordsInterval = getRecordsInterval;
+ }
+
+ if (GetTimeSpan(configurationSection, nameof(options.TopologyCheckInterval), name) is { } topologyCheckInterval)
+ {
+ options.TopologyCheckInterval = topologyCheckInterval;
+ }
+ });
+ }
+
+ private static void ConfigureCheckpointer(
+ SiloKinesisStreamConfigurator configurator,
+ string name,
+ IConfigurationSection configurationSection)
+ {
+ var checkpointSection = configurationSection.GetSection("Checkpoint");
+ var checkpointStorage = checkpointSection["Type"] ?? GrainCheckpointStorage;
+ if (string.Equals(checkpointStorage, GrainCheckpointStorage, StringComparison.OrdinalIgnoreCase))
+ {
+ configurator.UseGrainCheckpointer(optionsBuilder =>
+ optionsBuilder.Configure(options =>
+ {
+ options.CheckpointComparer = StreamCheckpointComparers.Numeric;
+
+ var storageProviderName = checkpointSection[nameof(options.StorageProviderName)];
+ if (!string.IsNullOrWhiteSpace(storageProviderName))
+ {
+ options.StorageProviderName = storageProviderName;
+ }
+
+ if (GetTimeSpan(checkpointSection, nameof(options.PersistInterval), name) is { } persistInterval)
+ {
+ options.PersistInterval = persistInterval;
+ }
+ }));
+ return;
+ }
+
+ if (string.Equals(checkpointStorage, DynamoDBCheckpointStorage, StringComparison.OrdinalIgnoreCase))
+ {
+ configurator.UseDynamoDBCheckpointer(optionsBuilder =>
+ optionsBuilder.Configure((options, configuration) =>
+ ConfigureDynamoDBCheckpointer(options, configuration, checkpointSection, name)));
+ return;
+ }
+
+ throw new OrleansConfigurationException(
+ $"Kinesis stream provider '{name}' has unsupported checkpoint type '{checkpointStorage}'. " +
+ $"Use '{GrainCheckpointStorage}' or '{DynamoDBCheckpointStorage}'.");
+ }
+
+ private static void ConfigureDynamoDBCheckpointer(
+ DynamoDBStreamQueueCheckpointerOptions options,
+ IConfiguration configuration,
+ IConfigurationSection checkpointSection,
+ string name)
+ {
+ var serviceKey = checkpointSection["ServiceKey"];
+ var resourceConfigSection = checkpointSection["ResourceConfigSection"];
+ var tableName = checkpointSection[nameof(options.TableName)]
+ ?? GetAwsResourceProperty(configuration, checkpointSection, "TableName");
+ if ((!string.IsNullOrWhiteSpace(serviceKey) || !string.IsNullOrWhiteSpace(resourceConfigSection))
+ && string.IsNullOrWhiteSpace(tableName))
+ {
+ var resourceReference = serviceKey ?? resourceConfigSection;
+ throw new OrleansConfigurationException(
+ $"Kinesis stream provider '{name}' references DynamoDB checkpoint resource '{resourceReference}', " +
+ "but its AWS Aspire TableName output is missing.");
+ }
+
+ options.TableName = tableName ?? options.TableName;
+ options.Service = checkpointSection[nameof(options.Service)]
+ ?? checkpointSection["Region"]
+ ?? configuration["AWS_ENDPOINT_URL_DYNAMODB"]
+ ?? configuration["AWS:Region"]
+ ?? configuration["AWS_REGION"]
+ ?? configuration["AWS_DEFAULT_REGION"]
+ ?? options.Service;
+ options.AccessKey = checkpointSection[nameof(options.AccessKey)] ?? options.AccessKey;
+ options.SecretKey = checkpointSection[nameof(options.SecretKey)] ?? options.SecretKey;
+ options.Token = checkpointSection[nameof(options.Token)] ?? options.Token;
+ options.ProfileName = checkpointSection[nameof(options.ProfileName)] ?? options.ProfileName;
+
+ if (GetBoolean(checkpointSection, nameof(options.CreateIfNotExists), name) is { } createIfNotExists)
+ {
+ options.CreateIfNotExists = createIfNotExists;
+ }
+
+ if (GetBoolean(checkpointSection, nameof(options.UseProvisionedThroughput), name) is { } useProvisionedThroughput)
+ {
+ options.UseProvisionedThroughput = useProvisionedThroughput;
+ }
+
+ if (GetInt32(checkpointSection, nameof(options.ReadCapacityUnits), name) is { } readCapacityUnits)
+ {
+ options.ReadCapacityUnits = readCapacityUnits;
+ }
+
+ if (GetInt32(checkpointSection, nameof(options.WriteCapacityUnits), name) is { } writeCapacityUnits)
+ {
+ options.WriteCapacityUnits = writeCapacityUnits;
+ }
+
+ if (GetTimeSpan(checkpointSection, nameof(options.PersistInterval), name) is { } persistInterval)
+ {
+ options.PersistInterval = persistInterval;
+ }
+
+ if (GetTimeSpan(checkpointSection, nameof(options.InitializationTimeout), name) is { } initializationTimeout)
+ {
+ options.InitializationTimeout = initializationTimeout;
+ }
+ }
+
+ private static int? GetInt32(IConfigurationSection section, string key, string providerName)
+ {
+ var value = section[key];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
+ {
+ throw CreateInvalidValueException(section, key, value, providerName, "an integer");
+ }
+
+ return result;
+ }
+
+ private static bool? GetBoolean(IConfigurationSection section, string key, string providerName)
+ {
+ var value = section[key];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ if (!bool.TryParse(value, out var result))
+ {
+ throw CreateInvalidValueException(section, key, value, providerName, "a Boolean");
+ }
+
+ return result;
+ }
+
+ private static TimeSpan? GetTimeSpan(IConfigurationSection section, string key, string providerName)
+ {
+ var value = section[key];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ if (!TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var result))
+ {
+ throw CreateInvalidValueException(section, key, value, providerName, "a time span");
+ }
+
+ return result;
+ }
+
+ private static OrleansConfigurationException CreateInvalidValueException(
+ IConfigurationSection section,
+ string key,
+ string value,
+ string providerName,
+ string expectedType)
+ => new(
+ $"Kinesis stream provider '{providerName}' configuration section '{section.Path}' setting '{key}' " +
+ $"must be {expectedType}, but the configured value is '{value}'.");
+
+ private static string? GetConnectionString(
+ IConfiguration configuration,
+ IConfigurationSection configurationSection,
+ string name)
+ {
+ var connectionString = configurationSection["ConnectionString"];
+ var connectionName = configurationSection["ConnectionName"];
+ if (string.IsNullOrWhiteSpace(connectionString) && !string.IsNullOrWhiteSpace(connectionName))
+ {
+ connectionString = configuration.GetConnectionString(connectionName);
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new OrleansConfigurationException(
+ $"Kinesis stream provider '{name}' references connection string '{connectionName}', but it is missing.");
+ }
+ }
+
+ return connectionString;
+ }
+
+ private static string? GetAwsResourceProperty(
+ IConfiguration configuration,
+ IConfigurationSection providerSection,
+ string propertyName)
+ {
+ var resourceConfigurationSection = providerSection["ResourceConfigSection"];
+ if (string.IsNullOrWhiteSpace(resourceConfigurationSection))
+ {
+ var serviceKey = providerSection["ServiceKey"];
+ if (string.IsNullOrWhiteSpace(serviceKey))
+ {
+ return null;
+ }
+
+ resourceConfigurationSection = $"{AwsResourcesConfigurationSection}:{serviceKey}";
+ }
+
+ return configuration[$"{resourceConfigurationSection}:{propertyName}"];
+ }
+
+ private static (string Region, string StreamName) ParseKinesisStreamArn(string streamArn, string name)
+ {
+ var parts = streamArn.Split(':', 6);
+ if (parts.Length != 6
+ || !string.Equals(parts[0], "arn", StringComparison.Ordinal)
+ || !string.Equals(parts[2], "kinesis", StringComparison.Ordinal)
+ || string.IsNullOrWhiteSpace(parts[3])
+ || !parts[5].StartsWith("stream/", StringComparison.Ordinal)
+ || parts[5].Length == "stream/".Length)
+ {
+ throw new OrleansConfigurationException(
+ $"Kinesis stream provider '{name}' has invalid StreamArn '{streamArn}'.");
+ }
+
+ return (parts[3], parts[5]["stream/".Length..]);
+ }
+}
diff --git a/src/AWS/Orleans.Streaming.Kinesis/README.md b/src/AWS/Orleans.Streaming.Kinesis/README.md
index 08a31bc2c56..82386175371 100644
--- a/src/AWS/Orleans.Streaming.Kinesis/README.md
+++ b/src/AWS/Orleans.Streaming.Kinesis/README.md
@@ -47,7 +47,24 @@ clientBuilder.AddKinesisStreams(
});
```
-When access and secret keys are not configured, the provider uses the standard AWS SDK credential chain.
+Explicit access and secret keys take precedence for both regions and endpoint URLs. HTTP emulator endpoints use the development credentials `dummy` and `dummy` when those keys are omitted. Regions and HTTPS endpoints use the standard AWS SDK credential chain when explicit keys are omitted.
+
+## Configuration-driven hosting
+
+The package registers `Kinesis`, `AmazonKinesis`, and `KinesisStream` aliases for silo and client streaming configuration. The canonical Aspire configuration uses `Kinesis`:
+
+```text
+Orleans__Streaming__Orders__ProviderType=Kinesis
+Orleans__Streaming__Orders__ServiceKey=orders-stream
+Orleans__Streaming__Orders__Checkpoint__Type=Grain
+AWS__Resources__orders-stream__StreamArn=arn:aws:kinesis:us-west-2:123456789012:stream/orders
+```
+
+`ServiceKey` selects the AWS Aspire resource under `AWS:Resources`. Set `ResourceConfigSection` when the resource reference uses a custom configuration section. Direct `StreamArn`, `StreamName`, `Region`, `Service`, `ConnectionName`, and `ConnectionString` values are also supported.
+
+Silos select `Checkpoint:Type` as `Grain` or `DynamoDB`. Grain checkpoints accept `Checkpoint:StorageProviderName` and `Checkpoint:PersistInterval`. DynamoDB checkpoints accept `Checkpoint:ServiceKey` for an AWS Aspire table output, `Checkpoint:ResourceConfigSection` for a custom output path, and the properties on `DynamoDBStreamQueueCheckpointerOptions`.
+
+DynamoDB checkpoint clients preserve configured key, session, or named-profile credentials. When these settings are omitted, HTTP and loopback endpoints use the development credentials `dummy` and `dummy`; other endpoints and regions use the AWS SDK credential chain.
## Checkpoint persistence
diff --git a/src/AWS/Orleans.Streaming.Kinesis/Streams/DynamoDBStreamQueueCheckpointerFactory.cs b/src/AWS/Orleans.Streaming.Kinesis/Streams/DynamoDBStreamQueueCheckpointerFactory.cs
index ff76c59de58..0429b6a52e3 100644
--- a/src/AWS/Orleans.Streaming.Kinesis/Streams/DynamoDBStreamQueueCheckpointerFactory.cs
+++ b/src/AWS/Orleans.Streaming.Kinesis/Streams/DynamoDBStreamQueueCheckpointerFactory.cs
@@ -121,7 +121,9 @@ internal static IAmazonDynamoDB CreateClient(DynamoDBStreamQueueCheckpointerOpti
AuthenticationRegion = GetAuthenticationRegion(options.Service),
ServiceURL = options.Service,
};
- var endpointCredentials = CreateCredentials(options, useDummyCredentials: serviceUri.IsLoopback);
+ var endpointCredentials = CreateCredentials(
+ options,
+ useDummyCredentials: serviceUri.IsLoopback || serviceUri.Scheme == Uri.UriSchemeHttp);
return endpointCredentials is null
? new AmazonDynamoDBClient(config)
: new AmazonDynamoDBClient(endpointCredentials, config);
diff --git a/src/AWS/Orleans.Streaming.Kinesis/Streams/KinesisAdapterFactory.cs b/src/AWS/Orleans.Streaming.Kinesis/Streams/KinesisAdapterFactory.cs
index 8fbd667b379..faf005332ca 100644
--- a/src/AWS/Orleans.Streaming.Kinesis/Streams/KinesisAdapterFactory.cs
+++ b/src/AWS/Orleans.Streaming.Kinesis/Streams/KinesisAdapterFactory.cs
@@ -170,8 +170,13 @@ internal static IAmazonKinesis CreateClient(KinesisStreamOptions options)
AuthenticationRegion = GetRegionName(options),
};
- return !string.IsNullOrEmpty(options.AccessKey) && !string.IsNullOrEmpty(options.SecretKey)
- ? new AmazonKinesisClient(new BasicAWSCredentials(options.AccessKey, options.SecretKey), config)
+ if (!string.IsNullOrEmpty(options.AccessKey) && !string.IsNullOrEmpty(options.SecretKey))
+ {
+ return new AmazonKinesisClient(new BasicAWSCredentials(options.AccessKey, options.SecretKey), config);
+ }
+
+ return options.Service.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
+ ? new AmazonKinesisClient(new BasicAWSCredentials("dummy", "dummy"), config)
: new AmazonKinesisClient(config);
}
diff --git a/src/AWS/Shared/Storage/DynamoDBProviderConfiguration.cs b/src/AWS/Shared/Storage/DynamoDBProviderConfiguration.cs
new file mode 100644
index 00000000000..86fb8a8af10
--- /dev/null
+++ b/src/AWS/Shared/Storage/DynamoDBProviderConfiguration.cs
@@ -0,0 +1,365 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+using Microsoft.Extensions.Configuration;
+using Orleans.Runtime;
+
+#if CLUSTERING_DYNAMODB
+namespace Orleans.Clustering.DynamoDB
+#elif PERSISTENCE_DYNAMODB
+namespace Orleans.Persistence.DynamoDB
+#elif REMINDERS_DYNAMODB
+namespace Orleans.Reminders.DynamoDB
+#else
+// No default namespace intentionally to cause compile errors if something is not defined
+#endif
+{
+ internal sealed class DynamoDBProviderConfiguration
+ {
+ private const string AwsResourcesConfigurationSection = "AWS:Resources";
+ private readonly IConfigurationSection _providerSection;
+ private readonly IConfiguration _configuration;
+ private readonly IReadOnlyDictionary _connectionValues;
+ private readonly bool _hasDirectConnectionString;
+ private readonly string? _referenceName;
+
+ private DynamoDBProviderConfiguration(
+ IConfigurationSection providerSection,
+ IConfiguration configuration,
+ IReadOnlyDictionary connectionValues,
+ bool hasDirectConnectionString,
+ string? referenceName)
+ {
+ _providerSection = providerSection;
+ _configuration = configuration;
+ _connectionValues = connectionValues;
+ _hasDirectConnectionString = hasDirectConnectionString;
+ _referenceName = referenceName;
+ }
+
+ public static DynamoDBProviderConfiguration Create(
+ IConfigurationSection providerSection,
+ IConfiguration configuration)
+ {
+ var serviceKey = GetNonEmpty(providerSection["ServiceKey"]);
+ var connectionName = GetNonEmpty(providerSection["ConnectionName"]);
+ if (serviceKey is not null
+ && connectionName is not null
+ && !string.Equals(serviceKey, connectionName, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new OrleansConfigurationException(
+ $"DynamoDB provider configuration section '{providerSection.Path}' cannot specify different ServiceKey and ConnectionName values.");
+ }
+
+ var referenceName = serviceKey ?? connectionName;
+ var connectionString = GetNonEmpty(providerSection["ConnectionString"]);
+ var hasDirectConnectionString = connectionString is not null;
+ if (connectionString is null && referenceName is not null)
+ {
+ connectionString = configuration.GetConnectionString(referenceName);
+ if (connectionName is not null && string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new OrleansConfigurationException(
+ $"DynamoDB provider configuration section '{providerSection.Path}' references ConnectionName '{connectionName}', " +
+ "but its connection string was not found.");
+ }
+ }
+
+ return new(
+ providerSection,
+ configuration,
+ ParseConnectionString(connectionString),
+ hasDirectConnectionString,
+ referenceName);
+ }
+
+ public void ConfigureClientOptions(DynamoDBClientOptions options)
+ {
+ SetIfPresent(value => options.AccessKey = value, GetValue(nameof(options.AccessKey)));
+ SetIfPresent(value => options.SecretKey = value, GetValue(nameof(options.SecretKey)));
+ SetIfPresent(value => options.Token = value, GetValue(nameof(options.Token), "SessionToken"));
+ var profileName = GetValue(nameof(options.ProfileName), "Profile")
+ ?? GetNonEmpty(_configuration["AWS:Profile"])
+ ?? GetNonEmpty(_configuration["AWS_PROFILE"]);
+ SetIfPresent(value => options.ProfileName = value, profileName);
+
+ var service = GetValue(nameof(options.Service), "ServiceURL", "Endpoint", "Region")
+ ?? GetNonEmpty(_configuration["AWS_ENDPOINT_URL_DYNAMODB"])
+ ?? GetNonEmpty(_configuration["AWS:Region"])
+ ?? GetNonEmpty(_configuration["AWS_REGION"])
+ ?? GetNonEmpty(_configuration["AWS_DEFAULT_REGION"]);
+ SetIfPresent(value => options.Service = value, service);
+ }
+
+ public string? GetValue(params string[] names)
+ {
+ var value = GetSectionValue(_providerSection, names)
+ ?? GetSectionValue(_providerSection.GetSection("ConnectionProperties"), names)
+ ?? GetSectionValue(_providerSection.GetSection("Resource"), names)
+ ?? GetSectionValue(_providerSection.GetSection("AWS"), names);
+ if (value is not null)
+ {
+ return value;
+ }
+
+ if (_hasDirectConnectionString && GetConnectionValue(names) is { } directValue)
+ {
+ return directValue;
+ }
+
+ if (_referenceName is not null)
+ {
+ value = GetSectionValue(_configuration.GetSection($"{AwsResourcesConfigurationSection}:{_referenceName}"), names);
+ if (value is not null)
+ {
+ return value;
+ }
+
+ foreach (var name in names)
+ {
+ value = GetNonEmpty(_configuration[$"{EncodeEnvironmentVariableName(_referenceName)}_{name.ToUpperInvariant()}"]);
+ if (value is not null)
+ {
+ return value;
+ }
+ }
+ }
+
+ return _hasDirectConnectionString ? null : GetConnectionValue(names);
+ }
+
+ private string? GetConnectionValue(string[] names)
+ {
+ foreach (var name in names)
+ {
+ if (_connectionValues.TryGetValue(name, out var value) && GetNonEmpty(value) is { } connectionValue)
+ {
+ return connectionValue;
+ }
+ }
+
+ return null;
+ }
+
+ public int? GetInt32(string name)
+ {
+ var value = GetNonEmpty(_providerSection[name]);
+ if (value is null)
+ {
+ return null;
+ }
+
+ if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result))
+ {
+ throw CreateInvalidValueException(name, value, "an integer");
+ }
+
+ return result;
+ }
+
+ public bool? GetBoolean(string name)
+ {
+ var value = GetNonEmpty(_providerSection[name]);
+ if (value is null)
+ {
+ return null;
+ }
+
+ if (!bool.TryParse(value, out var result))
+ {
+ throw CreateInvalidValueException(name, value, "a Boolean");
+ }
+
+ return result;
+ }
+
+ public TimeSpan? GetTimeSpan(string name)
+ {
+ var value = GetNonEmpty(_providerSection[name]);
+ if (value is null)
+ {
+ return null;
+ }
+
+ if (!TimeSpan.TryParse(value, CultureInfo.InvariantCulture, out var result))
+ {
+ throw CreateInvalidValueException(name, value, "a time span");
+ }
+
+ return result;
+ }
+
+ public static void ValidateClientOptions(DynamoDBClientOptions options, string providerName)
+ {
+ if (string.IsNullOrWhiteSpace(options.Service))
+ {
+ throw new OrleansConfigurationException(
+ $"The {nameof(DynamoDBClientOptions.Service)} is required for {providerName}.");
+ }
+
+ ValidateCredentials(options, providerName);
+ }
+
+ public static void ValidateTableOptions(
+ DynamoDBClientOptions options,
+ string? tableName,
+ bool useProvisionedThroughput,
+ int readCapacityUnits,
+ int writeCapacityUnits,
+ string providerName,
+ bool reportSpecificCapacity = false)
+ {
+ ValidateClientOptions(options, providerName);
+
+ if (string.IsNullOrWhiteSpace(tableName))
+ {
+ throw new OrleansConfigurationException($"The TableName is required for {providerName}.");
+ }
+
+ if (useProvisionedThroughput
+ && !reportSpecificCapacity
+ && (readCapacityUnits <= 0 || writeCapacityUnits <= 0))
+ {
+ throw new OrleansConfigurationException(
+ $"ReadCapacityUnits and WriteCapacityUnits must be greater than zero when provisioned throughput is enabled for {providerName}.");
+ }
+
+ if (useProvisionedThroughput && readCapacityUnits <= 0)
+ {
+ throw new OrleansConfigurationException(
+ $"ReadCapacityUnits must be greater than zero when provisioned throughput is enabled for {providerName}.");
+ }
+
+ if (useProvisionedThroughput && writeCapacityUnits <= 0)
+ {
+ throw new OrleansConfigurationException(
+ $"WriteCapacityUnits must be greater than zero when provisioned throughput is enabled for {providerName}.");
+ }
+ }
+
+ private static void ValidateCredentials(DynamoDBClientOptions options, string providerName)
+ {
+ var hasAccessKey = !string.IsNullOrEmpty(options.AccessKey);
+ var hasSecretKey = !string.IsNullOrEmpty(options.SecretKey);
+ if (hasAccessKey != hasSecretKey)
+ {
+ throw new OrleansConfigurationException(
+ $"The {nameof(DynamoDBClientOptions.AccessKey)} and {nameof(DynamoDBClientOptions.SecretKey)} " +
+ $"must either both be configured or both be omitted for {providerName}.");
+ }
+
+ if (!string.IsNullOrEmpty(options.Token) && !hasAccessKey)
+ {
+ throw new OrleansConfigurationException(
+ $"The {nameof(DynamoDBClientOptions.Token)} requires explicit credentials for {providerName}.");
+ }
+
+ if (hasAccessKey && !string.IsNullOrEmpty(options.ProfileName))
+ {
+ throw new OrleansConfigurationException(
+ $"Explicit credentials and {nameof(DynamoDBClientOptions.ProfileName)} cannot both be configured for {providerName}.");
+ }
+ }
+
+ private static string? GetSectionValue(IConfiguration section, string[] names)
+ {
+ foreach (var name in names)
+ {
+ var value = GetNonEmpty(section[name]);
+ if (value is not null)
+ {
+ return value;
+ }
+ }
+
+ return null;
+ }
+
+ private static IReadOnlyDictionary ParseConnectionString(string? connectionString)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ return result;
+ }
+
+ if (!connectionString.Contains(';', StringComparison.Ordinal)
+ && Uri.TryCreate(connectionString, UriKind.Absolute, out var serviceUri))
+ {
+ result[nameof(DynamoDBClientOptions.Service)] = serviceUri.AbsoluteUri;
+ return result;
+ }
+
+ foreach (var parameter in connectionString.Split(';', StringSplitOptions.RemoveEmptyEntries))
+ {
+ var separator = parameter.IndexOf('=');
+ if (separator <= 0 || separator == parameter.Length - 1)
+ {
+ continue;
+ }
+
+ var key = parameter[..separator].Trim();
+ var value = parameter[(separator + 1)..].Trim();
+ if (key.Length == 0 || value.Length == 0)
+ {
+ continue;
+ }
+
+ if (key.Equals("ServiceURL", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("Endpoint", StringComparison.OrdinalIgnoreCase)
+ || key.Equals("Region", StringComparison.OrdinalIgnoreCase))
+ {
+ key = nameof(DynamoDBClientOptions.Service);
+ }
+ else if (key.Equals("SessionToken", StringComparison.OrdinalIgnoreCase))
+ {
+ key = nameof(DynamoDBClientOptions.Token);
+ }
+ else if (key.Equals("Profile", StringComparison.OrdinalIgnoreCase))
+ {
+ key = nameof(DynamoDBClientOptions.ProfileName);
+ }
+
+ result[key] = value;
+ }
+
+ return result;
+ }
+
+ private static string EncodeEnvironmentVariableName(string name)
+ {
+ var builder = new StringBuilder(name.Length + 1);
+ if (char.IsAsciiDigit(name[0]))
+ {
+ builder.Append('_');
+ }
+
+ foreach (var character in name)
+ {
+ builder.Append(char.IsAsciiLetterOrDigit(character) ? char.ToUpperInvariant(character) : '_');
+ }
+
+ return builder.ToString();
+ }
+
+ private static string? GetNonEmpty(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value;
+
+ private OrleansConfigurationException CreateInvalidValueException(
+ string name,
+ string value,
+ string expectedType)
+ => new(
+ $"DynamoDB provider configuration section '{_providerSection.Path}' setting '{name}' " +
+ $"must be {expectedType}, but the configured value is '{value}'.");
+
+ private static void SetIfPresent(Action setter, string? value)
+ {
+ if (value is not null)
+ {
+ setter(value);
+ }
+ }
+ }
+}
diff --git a/src/AdoNet/Orleans.Clustering.AdoNet/AdoNetClusteringProviderBuilder.cs b/src/AdoNet/Orleans.Clustering.AdoNet/AdoNetClusteringProviderBuilder.cs
index 8b3f9635fdd..e77e654cf42 100644
--- a/src/AdoNet/Orleans.Clustering.AdoNet/AdoNetClusteringProviderBuilder.cs
+++ b/src/AdoNet/Orleans.Clustering.AdoNet/AdoNetClusteringProviderBuilder.cs
@@ -4,11 +4,24 @@
using Microsoft.Extensions.Options;
using Orleans;
using Orleans.Configuration;
+using Orleans.Clustering.AdoNet.Storage;
using Orleans.Hosting;
using Orleans.Providers;
[assembly: RegisterProvider("AdoNet", "Clustering", "Silo", typeof(AdoNetClusteringProviderBuilder))]
[assembly: RegisterProvider("AdoNet", "Clustering", "Client", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("SqlServerDatabase", "Clustering", "Silo", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("SqlServerDatabase", "Clustering", "Client", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("AzureSqlDatabase", "Clustering", "Silo", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("AzureSqlDatabase", "Clustering", "Client", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("PostgresDatabase", "Clustering", "Silo", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("PostgresDatabase", "Clustering", "Client", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("AzurePostgresFlexibleServerDatabase", "Clustering", "Silo", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("AzurePostgresFlexibleServerDatabase", "Clustering", "Client", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("MySqlDatabase", "Clustering", "Silo", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("MySqlDatabase", "Clustering", "Client", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("OracleDatabase", "Clustering", "Silo", typeof(AdoNetClusteringProviderBuilder))]
+[assembly: RegisterProvider("OracleDatabase", "Clustering", "Client", typeof(AdoNetClusteringProviderBuilder))]
namespace Orleans.Hosting;
@@ -18,20 +31,14 @@ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection
{
builder.UseAdoNetClustering((OptionsBuilder optionsBuilder) => optionsBuilder.Configure((options, services) =>
{
- var invariant = configurationSection[nameof(options.Invariant)];
- if (!string.IsNullOrEmpty(invariant))
+ var invariant = AdoNetProviderConfiguration.GetInvariant(configurationSection);
+ if (!string.IsNullOrWhiteSpace(invariant))
{
options.Invariant = invariant;
}
- var connectionString = configurationSection[nameof(options.ConnectionString)];
- var connectionName = configurationSection["ConnectionName"];
- if (string.IsNullOrEmpty(connectionString) && !string.IsNullOrEmpty(connectionName))
- {
- connectionString = services.GetRequiredService().GetConnectionString(connectionName);
- }
-
- if (!string.IsNullOrEmpty(connectionString))
+ var connectionString = AdoNetProviderConfiguration.GetConnectionString(configurationSection, services);
+ if (!string.IsNullOrWhiteSpace(connectionString))
{
options.ConnectionString = connectionString;
}
@@ -42,20 +49,14 @@ public void Configure(IClientBuilder builder, string? name, IConfigurationSectio
{
builder.UseAdoNetClustering((OptionsBuilder optionsBuilder) => optionsBuilder.Configure((options, services) =>
{
- var invariant = configurationSection[nameof(options.Invariant)];
- if (!string.IsNullOrEmpty(invariant))
+ var invariant = AdoNetProviderConfiguration.GetInvariant(configurationSection);
+ if (!string.IsNullOrWhiteSpace(invariant))
{
options.Invariant = invariant;
}
- var connectionString = configurationSection[nameof(options.ConnectionString)];
- var connectionName = configurationSection["ConnectionName"];
- if (string.IsNullOrEmpty(connectionString) && !string.IsNullOrEmpty(connectionName))
- {
- connectionString = services.GetRequiredService().GetConnectionString(connectionName);
- }
-
- if (!string.IsNullOrEmpty(connectionString))
+ var connectionString = AdoNetProviderConfiguration.GetConnectionString(configurationSection, services);
+ if (!string.IsNullOrWhiteSpace(connectionString))
{
options.ConnectionString = connectionString;
}
diff --git a/src/AdoNet/Orleans.GrainDirectory.AdoNet/Hosting/AdoNetGrainDirectoryProviderBuilder.cs b/src/AdoNet/Orleans.GrainDirectory.AdoNet/Hosting/AdoNetGrainDirectoryProviderBuilder.cs
index 85d232f094e..df21e70b283 100644
--- a/src/AdoNet/Orleans.GrainDirectory.AdoNet/Hosting/AdoNetGrainDirectoryProviderBuilder.cs
+++ b/src/AdoNet/Orleans.GrainDirectory.AdoNet/Hosting/AdoNetGrainDirectoryProviderBuilder.cs
@@ -1,6 +1,12 @@
using static System.String;
+using Orleans.GrainDirectory.AdoNet.Storage;
[assembly: RegisterProvider("AdoNet", "GrainDirectory", "Silo", typeof(AdoNetGrainDirectoryProviderBuilder))]
+[assembly: RegisterProvider("SqlServerDatabase", "GrainDirectory", "Silo", typeof(AdoNetGrainDirectoryProviderBuilder))]
+[assembly: RegisterProvider("AzureSqlDatabase", "GrainDirectory", "Silo", typeof(AdoNetGrainDirectoryProviderBuilder))]
+[assembly: RegisterProvider("PostgresDatabase", "GrainDirectory", "Silo", typeof(AdoNetGrainDirectoryProviderBuilder))]
+[assembly: RegisterProvider("AzurePostgresFlexibleServerDatabase", "GrainDirectory", "Silo", typeof(AdoNetGrainDirectoryProviderBuilder))]
+[assembly: RegisterProvider("MySqlDatabase", "GrainDirectory", "Silo", typeof(AdoNetGrainDirectoryProviderBuilder))]
namespace Orleans.Hosting;
@@ -10,26 +16,17 @@ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection
{
builder.AddAdoNetGrainDirectory(name ?? "Default", optionsBuilder => optionsBuilder.Configure((options, services) =>
{
- var invariant = configurationSection["Invariant"];
+ var invariant = AdoNetProviderConfiguration.GetInvariant(configurationSection);
if (!IsNullOrWhiteSpace(invariant))
{
options.Invariant = invariant;
}
- var connectionString = configurationSection["ConnectionString"];
- var connectionName = configurationSection["ConnectionName"];
+ var connectionString = AdoNetProviderConfiguration.GetConnectionString(configurationSection, services);
if (!IsNullOrWhiteSpace(connectionString))
{
options.ConnectionString = connectionString;
}
- else if (!IsNullOrWhiteSpace(connectionName))
- {
- connectionString = services.GetRequiredService().GetConnectionString(connectionName);
- if (!IsNullOrWhiteSpace(connectionString))
- {
- options.ConnectionString = connectionString;
- }
- }
}));
}
}
diff --git a/src/AdoNet/Orleans.Persistence.AdoNet/AdoNetGrainStorageProviderBuilder.cs b/src/AdoNet/Orleans.Persistence.AdoNet/AdoNetGrainStorageProviderBuilder.cs
index 5806b6db4e8..d077ad26642 100644
--- a/src/AdoNet/Orleans.Persistence.AdoNet/AdoNetGrainStorageProviderBuilder.cs
+++ b/src/AdoNet/Orleans.Persistence.AdoNet/AdoNetGrainStorageProviderBuilder.cs
@@ -5,10 +5,17 @@
using Orleans;
using Orleans.Configuration;
using Orleans.Hosting;
+using Orleans.Persistence.AdoNet.Storage;
using Orleans.Providers;
using Orleans.Storage;
[assembly: RegisterProvider("AdoNet", "GrainStorage", "Silo", typeof(AdoNetGrainStorageProviderBuilder))]
+[assembly: RegisterProvider("SqlServerDatabase", "GrainStorage", "Silo", typeof(AdoNetGrainStorageProviderBuilder))]
+[assembly: RegisterProvider("AzureSqlDatabase", "GrainStorage", "Silo", typeof(AdoNetGrainStorageProviderBuilder))]
+[assembly: RegisterProvider("PostgresDatabase", "GrainStorage", "Silo", typeof(AdoNetGrainStorageProviderBuilder))]
+[assembly: RegisterProvider("AzurePostgresFlexibleServerDatabase", "GrainStorage", "Silo", typeof(AdoNetGrainStorageProviderBuilder))]
+[assembly: RegisterProvider("MySqlDatabase", "GrainStorage", "Silo", typeof(AdoNetGrainStorageProviderBuilder))]
+[assembly: RegisterProvider("OracleDatabase", "GrainStorage", "Silo", typeof(AdoNetGrainStorageProviderBuilder))]
namespace Orleans.Hosting;
@@ -18,20 +25,14 @@ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection
{
builder.AddAdoNetGrainStorage(name!, (OptionsBuilder optionsBuilder) => optionsBuilder.Configure((options, services) =>
{
- var invariant = configurationSection[nameof(options.Invariant)];
- if (!string.IsNullOrEmpty(invariant))
+ var invariant = AdoNetProviderConfiguration.GetInvariant(configurationSection);
+ if (!string.IsNullOrWhiteSpace(invariant))
{
options.Invariant = invariant;
}
- var connectionString = configurationSection[nameof(options.ConnectionString)];
- var connectionName = configurationSection["ConnectionName"];
- if (string.IsNullOrEmpty(connectionString) && !string.IsNullOrEmpty(connectionName))
- {
- connectionString = services.GetRequiredService().GetConnectionString(connectionName);
- }
-
- if (!string.IsNullOrEmpty(connectionString))
+ var connectionString = AdoNetProviderConfiguration.GetConnectionString(configurationSection, services);
+ if (!string.IsNullOrWhiteSpace(connectionString))
{
options.ConnectionString = connectionString;
}
diff --git a/src/AdoNet/Orleans.Reminders.AdoNet/AdoNetRemindersProviderBuilder.cs b/src/AdoNet/Orleans.Reminders.AdoNet/AdoNetRemindersProviderBuilder.cs
index 23e9ea11fda..789606502b5 100644
--- a/src/AdoNet/Orleans.Reminders.AdoNet/AdoNetRemindersProviderBuilder.cs
+++ b/src/AdoNet/Orleans.Reminders.AdoNet/AdoNetRemindersProviderBuilder.cs
@@ -6,8 +6,15 @@
using Orleans.Configuration;
using Orleans.Hosting;
using Orleans.Providers;
+using Orleans.Reminders.AdoNet.Storage;
[assembly: RegisterProvider("AdoNet", "Reminders", "Silo", typeof(AdoNetRemindersProviderBuilder))]
+[assembly: RegisterProvider("SqlServerDatabase", "Reminders", "Silo", typeof(AdoNetRemindersProviderBuilder))]
+[assembly: RegisterProvider("AzureSqlDatabase", "Reminders", "Silo", typeof(AdoNetRemindersProviderBuilder))]
+[assembly: RegisterProvider("PostgresDatabase", "Reminders", "Silo", typeof(AdoNetRemindersProviderBuilder))]
+[assembly: RegisterProvider("AzurePostgresFlexibleServerDatabase", "Reminders", "Silo", typeof(AdoNetRemindersProviderBuilder))]
+[assembly: RegisterProvider("MySqlDatabase", "Reminders", "Silo", typeof(AdoNetRemindersProviderBuilder))]
+[assembly: RegisterProvider("OracleDatabase", "Reminders", "Silo", typeof(AdoNetRemindersProviderBuilder))]
namespace Orleans.Hosting;
@@ -17,20 +24,14 @@ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection
{
builder.UseAdoNetReminderService((OptionsBuilder optionsBuilder) => optionsBuilder.Configure((options, services) =>
{
- var invariant = configurationSection[nameof(options.Invariant)];
- if (!string.IsNullOrEmpty(invariant))
+ var invariant = AdoNetProviderConfiguration.GetInvariant(configurationSection);
+ if (!string.IsNullOrWhiteSpace(invariant))
{
options.Invariant = invariant;
}
- var connectionString = configurationSection[nameof(options.ConnectionString)];
- var connectionName = configurationSection["ConnectionName"];
- if (string.IsNullOrEmpty(connectionString) && !string.IsNullOrEmpty(connectionName))
- {
- connectionString = services.GetRequiredService().GetConnectionString(connectionName);
- }
-
- if (!string.IsNullOrEmpty(connectionString))
+ var connectionString = AdoNetProviderConfiguration.GetConnectionString(configurationSection, services);
+ if (!string.IsNullOrWhiteSpace(connectionString))
{
options.ConnectionString = connectionString;
}
diff --git a/src/AdoNet/Orleans.Streaming.AdoNet/Hosting/AdoNetStreamingProviderBuilder.cs b/src/AdoNet/Orleans.Streaming.AdoNet/Hosting/AdoNetStreamingProviderBuilder.cs
new file mode 100644
index 00000000000..433328fc9e9
--- /dev/null
+++ b/src/AdoNet/Orleans.Streaming.AdoNet/Hosting/AdoNetStreamingProviderBuilder.cs
@@ -0,0 +1,99 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Orleans.Configuration;
+using Orleans.Providers;
+using Orleans.Streaming.AdoNet.Storage;
+
+[assembly: RegisterProvider("AdoNet", "Streaming", "Silo", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("AdoNet", "Streaming", "Client", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("SqlServerDatabase", "Streaming", "Silo", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("SqlServerDatabase", "Streaming", "Client", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("AzureSqlDatabase", "Streaming", "Silo", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("AzureSqlDatabase", "Streaming", "Client", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("PostgresDatabase", "Streaming", "Silo", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("PostgresDatabase", "Streaming", "Client", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("AzurePostgresFlexibleServerDatabase", "Streaming", "Silo", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("AzurePostgresFlexibleServerDatabase", "Streaming", "Client", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("MySqlDatabase", "Streaming", "Silo", typeof(AdoNetStreamingProviderBuilder))]
+[assembly: RegisterProvider("MySqlDatabase", "Streaming", "Client", typeof(AdoNetStreamingProviderBuilder))]
+
+namespace Orleans.Hosting;
+
+internal sealed class AdoNetStreamingProviderBuilder : IProviderBuilder, IProviderBuilder
+{
+ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection configurationSection)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+
+ builder.AddAdoNetStreams(name, configurator =>
+ {
+ configurator.ConfigureAdoNet(GetOptionsBuilder(configurationSection));
+ ConfigurePartitioning(configurator, configurationSection);
+ });
+ }
+
+ public void Configure(IClientBuilder builder, string? name, IConfigurationSection configurationSection)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+
+ builder.AddAdoNetStreams(name, configurator =>
+ {
+ configurator.ConfigureAdoNet(GetOptionsBuilder(configurationSection));
+ ConfigurePartitioning(configurator, configurationSection);
+ });
+ }
+
+ private static Action> GetOptionsBuilder(IConfigurationSection configurationSection)
+ => optionsBuilder =>
+ {
+ optionsBuilder.Bind(configurationSection);
+ optionsBuilder.Configure((options, services) =>
+ {
+ var invariant = AdoNetProviderConfiguration.GetInvariant(configurationSection);
+ if (!string.IsNullOrWhiteSpace(invariant))
+ {
+ options.Invariant = invariant;
+ }
+
+ var connectionString = AdoNetProviderConfiguration.GetConnectionString(configurationSection, services);
+ if (!string.IsNullOrWhiteSpace(connectionString))
+ {
+ options.ConnectionString = connectionString;
+ }
+ });
+ };
+
+ private static void ConfigurePartitioning(SiloAdoNetStreamConfigurator configurator, IConfigurationSection configurationSection)
+ {
+ if (GetPartitionCount(configurationSection) is { } partitionCount)
+ {
+ configurator.ConfigurePartitioning(partitionCount);
+ }
+ }
+
+ private static void ConfigurePartitioning(ClusterClientAdoNetStreamConfigurator configurator, IConfigurationSection configurationSection)
+ {
+ if (GetPartitionCount(configurationSection) is { } partitionCount)
+ {
+ configurator.ConfigurePartitioning(partitionCount);
+ }
+ }
+
+ private static int? GetPartitionCount(IConfigurationSection configurationSection)
+ {
+ var value = configurationSection["PartitionCount"];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ if (!int.TryParse(value, out var partitionCount) || partitionCount <= 0)
+ {
+ throw new OrleansConfigurationException(
+ $"ADO.NET streaming configuration section '{configurationSection.Path}' setting 'PartitionCount' must be a positive integer.");
+ }
+
+ return partitionCount;
+ }
+}
diff --git a/src/AdoNet/Shared/Storage/AdoNetProviderConfiguration.cs b/src/AdoNet/Shared/Storage/AdoNetProviderConfiguration.cs
new file mode 100644
index 00000000000..970ef071427
--- /dev/null
+++ b/src/AdoNet/Shared/Storage/AdoNetProviderConfiguration.cs
@@ -0,0 +1,65 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Orleans.Runtime;
+
+#if CLUSTERING_ADONET
+namespace Orleans.Clustering.AdoNet.Storage;
+#elif PERSISTENCE_ADONET
+namespace Orleans.Persistence.AdoNet.Storage;
+#elif REMINDERS_ADONET
+namespace Orleans.Reminders.AdoNet.Storage;
+#elif STREAMING_ADONET
+namespace Orleans.Streaming.AdoNet.Storage;
+#elif GRAINDIRECTORY_ADONET
+namespace Orleans.GrainDirectory.AdoNet.Storage;
+#elif TESTER_SQLUTILS
+namespace Orleans.Tests.SqlUtils;
+#else
+// No default namespace intentionally to cause compile errors if something is not defined
+#endif
+
+internal static class AdoNetProviderConfiguration
+{
+ public static string? GetInvariant(IConfigurationSection configurationSection)
+ {
+ var invariant = configurationSection["Invariant"];
+ if (!string.IsNullOrWhiteSpace(invariant))
+ {
+ return invariant;
+ }
+
+ return configurationSection["ProviderType"] switch
+ {
+ "SqlServerDatabase" or "AzureSqlDatabase" => AdoNetInvariants.InvariantNameSqlServer,
+ "PostgresDatabase" or "AzurePostgresFlexibleServerDatabase" => AdoNetInvariants.InvariantNamePostgreSql,
+ "MySqlDatabase" => AdoNetInvariants.InvariantNameMySql,
+ "OracleDatabase" => AdoNetInvariants.InvariantNameOracleDatabase,
+ _ => null,
+ };
+ }
+
+ public static string? GetConnectionString(IConfigurationSection configurationSection, IServiceProvider services)
+ {
+ var serviceKey = configurationSection["ServiceKey"];
+ var connectionName = configurationSection["ConnectionName"];
+ if (!string.IsNullOrWhiteSpace(serviceKey)
+ && !string.IsNullOrWhiteSpace(connectionName)
+ && !string.Equals(serviceKey, connectionName, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new OrleansConfigurationException(
+ $"ADO.NET provider configuration section '{configurationSection.Path}' cannot specify different ServiceKey and ConnectionName values.");
+ }
+
+ var connectionString = configurationSection["ConnectionString"];
+ if (!string.IsNullOrWhiteSpace(connectionString))
+ {
+ return connectionString;
+ }
+
+ var referenceName = string.IsNullOrWhiteSpace(serviceKey) ? connectionName : serviceKey;
+
+ return string.IsNullOrWhiteSpace(referenceName)
+ ? null
+ : services.GetRequiredService().GetConnectionString(referenceName);
+ }
+}
diff --git a/src/Azure/Orleans.Reminders.Cosmos/CosmosRemindersProviderBuilder.cs b/src/Azure/Orleans.Reminders.Cosmos/CosmosRemindersProviderBuilder.cs
new file mode 100644
index 00000000000..4f5d16c7b2d
--- /dev/null
+++ b/src/Azure/Orleans.Reminders.Cosmos/CosmosRemindersProviderBuilder.cs
@@ -0,0 +1,53 @@
+using Microsoft.Azure.Cosmos;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Orleans.Configuration;
+using Orleans.Providers;
+using Orleans.Reminders.Cosmos;
+
+[assembly: RegisterProvider("AzureCosmosDB", "Reminders", "Silo", typeof(Orleans.Hosting.CosmosRemindersProviderBuilder))]
+
+namespace Orleans.Hosting;
+
+internal sealed class CosmosRemindersProviderBuilder : IProviderBuilder
+{
+ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection configurationSection)
+ {
+ builder.UseCosmosReminderService((OptionsBuilder optionsBuilder) =>
+ {
+ optionsBuilder.Bind(configurationSection);
+ optionsBuilder.Configure((options, services) =>
+ {
+ var serviceKey = configurationSection["ServiceKey"];
+ if (!string.IsNullOrEmpty(serviceKey))
+ {
+ options.ConfigureCosmosClient(
+ provider => new ValueTask(
+ provider.GetKeyedService(serviceKey)
+ ?? throw new OrleansConfigurationException(
+ $"Cosmos reminder provider configuration '{configurationSection.Path}' requires the keyed CosmosClient '{serviceKey}'. " +
+ "Register the keyed client or configure ConnectionName or ConnectionString.")));
+ return;
+ }
+
+ var connectionName = configurationSection["ConnectionName"];
+ var connectionString = configurationSection["ConnectionString"];
+ if (!string.IsNullOrEmpty(connectionName) && string.IsNullOrEmpty(connectionString))
+ {
+ connectionString = services.GetRequiredService().GetConnectionString(connectionName);
+ if (string.IsNullOrEmpty(connectionString))
+ {
+ throw new OrleansConfigurationException(
+ $"Cosmos reminder provider configuration '{configurationSection.Path}' references connection string '{connectionName}', but it was not found.");
+ }
+ }
+
+ if (!string.IsNullOrEmpty(connectionString))
+ {
+ options.ConfigureCosmosClient(connectionString);
+ }
+ });
+ });
+ }
+}
diff --git a/src/Orleans.Streaming.NATS/Hosting/NatsStreamProviderBuilder.cs b/src/Orleans.Streaming.NATS/Hosting/NatsStreamProviderBuilder.cs
new file mode 100644
index 00000000000..19e475834aa
--- /dev/null
+++ b/src/Orleans.Streaming.NATS/Hosting/NatsStreamProviderBuilder.cs
@@ -0,0 +1,222 @@
+using System.Globalization;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using NATS.Client.Core;
+using NATS.Client.JetStream.Models;
+using Orleans.Configuration;
+using Orleans.Providers;
+using Orleans.Streaming.NATS;
+using Orleans.Streaming.NATS.Hosting;
+
+[assembly: RegisterProvider("NATS", "Streaming", "Silo", typeof(NatsStreamProviderBuilder))]
+[assembly: RegisterProvider("NATS", "Streaming", "Client", typeof(NatsStreamProviderBuilder))]
+[assembly: RegisterProvider("Nats", "Streaming", "Silo", typeof(NatsStreamProviderBuilder))]
+[assembly: RegisterProvider("Nats", "Streaming", "Client", typeof(NatsStreamProviderBuilder))]
+[assembly: RegisterProvider("NatsServer", "Streaming", "Silo", typeof(NatsStreamProviderBuilder))]
+[assembly: RegisterProvider("NatsServer", "Streaming", "Client", typeof(NatsStreamProviderBuilder))]
+
+namespace Orleans.Hosting;
+
+///
+/// Configures NATS JetStream streaming from Orleans provider configuration.
+///
+public sealed class NatsStreamProviderBuilder : IProviderBuilder, IProviderBuilder
+{
+ ///
+ public void Configure(ISiloBuilder builder, string? name, IConfigurationSection configurationSection)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+
+ var partitionCount = GetInt32(
+ configurationSection,
+ nameof(NatsOptions.PartitionCount),
+ defaultValue: 8,
+ minimumValue: 1);
+
+ builder.AddNatsStreams(name, streams =>
+ {
+ streams.ConfigureNats(GetOptionsBuilder(name, configurationSection));
+ streams.ConfigurePartitioning(partitionCount);
+
+ var cacheSize = GetNullableInt32(configurationSection, "CacheSize", minimumValue: 1);
+ if (cacheSize is not null)
+ {
+ streams.ConfigureCache(cacheSize.Value);
+ }
+ });
+ }
+
+ ///
+ public void Configure(IClientBuilder builder, string? name, IConfigurationSection configurationSection)
+ {
+ ArgumentNullException.ThrowIfNull(name);
+
+ var partitionCount = GetInt32(
+ configurationSection,
+ nameof(NatsOptions.PartitionCount),
+ defaultValue: 8,
+ minimumValue: 1);
+
+ builder.AddNatsStreams(name, streams =>
+ {
+ streams.ConfigureNats(GetOptionsBuilder(name, configurationSection));
+ streams.ConfigurePartitioning(partitionCount);
+ });
+ }
+
+ private static Action> GetOptionsBuilder(
+ string providerName,
+ IConfigurationSection configurationSection)
+ => optionsBuilder => optionsBuilder.Configure((options, services) =>
+ {
+ options.StreamName = configurationSection[nameof(NatsOptions.StreamName)] ?? options.StreamName;
+ options.BatchSize = GetInt32(
+ configurationSection,
+ nameof(NatsOptions.BatchSize),
+ options.BatchSize,
+ minimumValue: 1);
+ options.PartitionCount = GetInt32(
+ configurationSection,
+ nameof(NatsOptions.PartitionCount),
+ options.PartitionCount,
+ minimumValue: 1);
+ options.ProducerCount = GetInt32(
+ configurationSection,
+ nameof(NatsOptions.ProducerCount),
+ options.ProducerCount,
+ minimumValue: 1);
+ options.NumReplicas = GetInt32(
+ configurationSection,
+ nameof(NatsOptions.NumReplicas),
+ options.NumReplicas,
+ minimumValue: 1);
+ options.StorageType = GetEnum(
+ configurationSection,
+ nameof(NatsOptions.StorageType),
+ options.StorageType);
+
+ ConfigureConnection(providerName, configurationSection, options, services);
+ });
+
+ private static void ConfigureConnection(
+ string providerName,
+ IConfigurationSection configurationSection,
+ NatsOptions options,
+ IServiceProvider services)
+ {
+ var serviceKey = configurationSection["ServiceKey"];
+ var connectionName = configurationSection["ConnectionName"];
+ var connectionString = configurationSection["ConnectionString"];
+ var url = configurationSection["Url"];
+
+ var configuredSourceCount =
+ (!string.IsNullOrWhiteSpace(serviceKey) ? 1 : 0)
+ + (!string.IsNullOrWhiteSpace(connectionName) ? 1 : 0)
+ + (!string.IsNullOrWhiteSpace(connectionString) ? 1 : 0)
+ + (!string.IsNullOrWhiteSpace(url) ? 1 : 0);
+
+ if (configuredSourceCount == 0)
+ {
+ throw new OrleansConfigurationException(
+ $"NATS stream provider '{providerName}' requires ServiceKey, ConnectionName, ConnectionString, or Url.");
+ }
+
+ if (configuredSourceCount > 1)
+ {
+ throw new OrleansConfigurationException(
+ $"NATS stream provider '{providerName}' has ambiguous connection configuration. Configure exactly one of ServiceKey, ConnectionName, ConnectionString, or Url.");
+ }
+
+ if (!string.IsNullOrWhiteSpace(serviceKey))
+ {
+ options.Connection = services.GetKeyedService(serviceKey)
+ ?? throw new OrleansConfigurationException(
+ $"NATS stream provider '{providerName}' requires the keyed INatsConnection '{serviceKey}'. Register it with AddKeyedNatsClient(\"{serviceKey}\").");
+ options.NatsClientOptions = null;
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(connectionName))
+ {
+ connectionString = services.GetRequiredService().GetConnectionString(connectionName);
+ if (string.IsNullOrWhiteSpace(connectionString))
+ {
+ throw new OrleansConfigurationException(
+ $"NATS stream provider '{providerName}' references connection string '{connectionName}', but it has no configured value.");
+ }
+ }
+
+ var connectionUrl = connectionString ?? url;
+ var connectionUris = connectionUrl?.Split(
+ ',',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (connectionUris is not { Length: > 0 }
+ || connectionUris.Any(static value => !IsSupportedNatsUri(value)))
+ {
+ throw new OrleansConfigurationException(
+ $"NATS stream provider '{providerName}' requires one or more absolute nats, tls, ws, or wss connection URIs.");
+ }
+
+ options.Connection = null;
+ options.NatsClientOptions = (options.NatsClientOptions ?? NatsOpts.Default) with { Url = connectionUrl! };
+ }
+
+ private static bool IsSupportedNatsUri(string value)
+ => Uri.TryCreate(value, UriKind.Absolute, out var uri)
+ && (string.Equals(uri.Scheme, "nats", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(uri.Scheme, "tls", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(uri.Scheme, "ws", StringComparison.OrdinalIgnoreCase)
+ || string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase));
+
+ private static int GetInt32(
+ IConfigurationSection configurationSection,
+ string key,
+ int defaultValue,
+ int minimumValue)
+ => GetNullableInt32(configurationSection, key, minimumValue) ?? defaultValue;
+
+ private static int? GetNullableInt32(
+ IConfigurationSection configurationSection,
+ string key,
+ int minimumValue)
+ {
+ var value = configurationSection[key];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)
+ || result < minimumValue)
+ {
+ throw new OrleansConfigurationException(
+ $"NATS stream provider configuration section '{configurationSection.Path}' setting '{key}' " +
+ $"must be an integer greater than or equal to {minimumValue}.");
+ }
+
+ return result;
+ }
+
+ private static TEnum GetEnum(
+ IConfigurationSection configurationSection,
+ string key,
+ TEnum defaultValue)
+ where TEnum : struct, Enum
+ {
+ var value = configurationSection[key];
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return defaultValue;
+ }
+
+ if (!Enum.TryParse(value, ignoreCase: true, out var result)
+ || !Enum.IsDefined(result))
+ {
+ throw new OrleansConfigurationException(
+ $"NATS stream provider setting '{key}' has invalid value '{value}'.");
+ }
+
+ return result;
+ }
+}
diff --git a/src/Orleans.Streaming.NATS/NatsOptions.cs b/src/Orleans.Streaming.NATS/NatsOptions.cs
index 417798b83c0..49b4db48b0b 100644
--- a/src/Orleans.Streaming.NATS/NatsOptions.cs
+++ b/src/Orleans.Streaming.NATS/NatsOptions.cs
@@ -10,6 +10,8 @@ namespace Orleans.Streaming.NATS;
///
public class NatsOptions
{
+ internal INatsConnection? Connection { get; set; }
+
///
/// Gets or sets the name of the NATS JetStream stream used by the provider.
///
diff --git a/src/Orleans.Streaming.NATS/Providers/NatsConnectionManager.cs b/src/Orleans.Streaming.NATS/Providers/NatsConnectionManager.cs
index fb33b7f3311..54a284b071c 100644
--- a/src/Orleans.Streaming.NATS/Providers/NatsConnectionManager.cs
+++ b/src/Orleans.Streaming.NATS/Providers/NatsConnectionManager.cs
@@ -18,14 +18,15 @@ internal sealed partial class NatsConnectionManager
{
private static readonly byte[] AckPayload = "+ACK"u8.ToArray();
private readonly string _providerName;
- private readonly NatsOpts _natsClientOptions;
- private readonly NatsConnection _natsConnection;
+ private readonly INatsConnection _natsConnection;
private readonly string _natsServer;
+ private readonly bool _usesSharedConnection;
private readonly ILogger _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly NatsOptions _options;
private readonly NatsJSContext[] _producerNatsContexts;
private readonly NatsJSContext _natsContext;
+ private readonly NatsJsonContextOptionsSerializerRegistry _serializerRegistry;
[GeneratedActivatorConstructor]
public NatsConnectionManager(string providerName, ILoggerFactory loggerFactory, NatsOptions options)
@@ -36,39 +37,45 @@ public NatsConnectionManager(string providerName, ILoggerFactory loggerFactory,
this._options = options;
this._options.JsonSerializerOptions ??= new();
this._options.JsonSerializerOptions.TypeInfoResolverChain.Add(NatsSerializerContext.Default);
+ this._serializerRegistry = new NatsJsonContextOptionsSerializerRegistry(this._options.JsonSerializerOptions);
- if (this._options.NatsClientOptions is null)
+ if (this._options.Connection is { } sharedConnection)
{
- this._options.NatsClientOptions = NatsOpts.Default with
- {
- Name = $"Orleans-{this._providerName}",
- SerializerRegistry =
- new NatsJsonContextOptionsSerializerRegistry(this._options.JsonSerializerOptions)
- };
+ this._usesSharedConnection = true;
+ this._natsConnection = sharedConnection;
+ this._natsServer = GetLogSafeServerDescription(sharedConnection.Opts.Url);
}
else
{
- this._options.NatsClientOptions = this._options.NatsClientOptions with
+ var clientOptions = this._options.NatsClientOptions ?? NatsOpts.Default;
+ this._options.NatsClientOptions = clientOptions with
{
- Name = string.IsNullOrWhiteSpace(this._options.NatsClientOptions.Name)
+ Name = string.IsNullOrWhiteSpace(clientOptions.Name)
? $"Orleans-{this._providerName}"
- : this._options.NatsClientOptions.Name,
- SerializerRegistry = new NatsJsonContextOptionsSerializerRegistry(this._options.JsonSerializerOptions)
+ : clientOptions.Name,
+ SerializerRegistry = this._serializerRegistry
};
+ this._natsConnection = new NatsConnection(this._options.NatsClientOptions);
+ this._natsServer = GetLogSafeServerDescription(this._options.NatsClientOptions.Url);
}
- this._natsClientOptions = this._options.NatsClientOptions;
- this._natsServer = GetLogSafeServerDescription(this._natsClientOptions.Url);
- this._natsConnection = new NatsConnection(this._natsClientOptions);
this._natsContext = new NatsJSContext(this._natsConnection);
this._producerNatsContexts = new NatsJSContext[this._options.ProducerCount];
for (var i = 0; i < this._options.ProducerCount; i++)
{
- var producerOptions = this._natsClientOptions with { Name = $"Orleans-{this._providerName}-Producer-{i}" };
- var producerConnection = new NatsConnection(producerOptions);
- this._producerNatsContexts[i] = new NatsJSContext(producerConnection);
+ if (this._usesSharedConnection)
+ {
+ this._producerNatsContexts[i] = new NatsJSContext(this._natsConnection);
+ }
+ else
+ {
+ var clientOptions = this._options.NatsClientOptions
+ ?? throw new InvalidOperationException("NATS client options were not initialized.");
+ var producerOptions = clientOptions with { Name = $"Orleans-{this._providerName}-Producer-{i}" };
+ this._producerNatsContexts[i] = new NatsJSContext(new NatsConnection(producerOptions));
+ }
}
}
@@ -93,15 +100,18 @@ public async Task Initialize(CancellationToken cancellationToken = default)
return;
}
- foreach (var producerContext in this._producerNatsContexts)
+ if (!this._usesSharedConnection)
{
- await producerContext.Connection.ConnectAsync();
-
- if (producerContext.Connection.ConnectionState != NatsConnectionState.Open)
+ foreach (var producerContext in this._producerNatsContexts)
{
- this.LogUnableToConnectToNatsServer(
- GetLogSafeServerDescription(producerContext.Connection.Opts.Url));
- return;
+ await producerContext.Connection.ConnectAsync();
+
+ if (producerContext.Connection.ConnectionState != NatsConnectionState.Open)
+ {
+ this.LogUnableToConnectToNatsServer(
+ GetLogSafeServerDescription(producerContext.Connection.Opts.Url));
+ return;
+ }
}
}
@@ -188,7 +198,7 @@ public async Task EnqueueMessage(NatsStreamMessage message, CancellationToken ca
var ack = await context.TryPublishAsync(
subject,
message,
- this._natsClientOptions.SerializerRegistry.GetSerializer(),
+ this._serializerRegistry.GetSerializer(),
cancellationToken: cancellationToken);
if (ack.Success)
@@ -213,7 +223,7 @@ public NatsStreamConsumer CreateConsumer(uint partition) =>
this._options.StreamName,
partition,
this._options.BatchSize,
- this._natsClientOptions.SerializerRegistry.GetDeserializer());
+ this._serializerRegistry.GetDeserializer());
internal static int GetProducerIndex(int hashCode, int producerCount)
=> (int)((uint)hashCode % (uint)producerCount);
diff --git a/src/Orleans.Streaming.NATS/README.md b/src/Orleans.Streaming.NATS/README.md
index a70af961842..d875554b671 100644
--- a/src/Orleans.Streaming.NATS/README.md
+++ b/src/Orleans.Streaming.NATS/README.md
@@ -63,6 +63,45 @@ var builder = Host.CreateApplicationBuilder(args)
await builder.RunAsync();
```
+## Aspire integration
+
+Add the `Aspire.Hosting.Nats` package to the AppHost and enable JetStream on the NATS resource:
+
+```csharp
+var builder = DistributedApplication.CreateBuilder(args);
+
+var nats = builder.AddNats("nats")
+ .WithJetStream()
+ .WithDataVolume();
+
+var orleans = builder.AddOrleans("cluster")
+ .WithDevelopmentClustering()
+ .WithStreaming("NatsStreamProvider", nats);
+
+builder.AddProject("silo")
+ .WithReference(orleans)
+ .WaitFor(nats);
+
+builder.AddProject("client")
+ .WithReference(orleans.AsClient())
+ .WaitFor(nats);
+
+builder.Build().Run();
+```
+
+Add the `Aspire.NATS.Net` and `Microsoft.Orleans.Streaming.NATS` packages to each silo or client project. Register the keyed NATS connection using the AppHost resource name before Orleans reads the generated provider configuration:
+
+```csharp
+var builder = Host.CreateApplicationBuilder(args);
+
+builder.AddKeyedNatsClient("nats");
+builder.UseOrleans();
+
+await builder.Build().RunAsync();
+```
+
+The Aspire resource supplies the NATS connection and Orleans provider type. Configure `Orleans:Streaming:NatsStreamProvider:StreamName` in the application project or deployment environment. Orleans creates the JetStream stream on startup and uses the keyed Aspire connection for publishing and consumption.
+
## Example - Using NATS Streams in a Grain
```csharp
diff --git a/src/Redis/Orleans.Clustering.Redis/Hosting/RedisClusteringProviderBuilder.cs b/src/Redis/Orleans.Clustering.Redis/Hosting/RedisClusteringProviderBuilder.cs
index 62a0fb44e34..e9bdb9c6a63 100644
--- a/src/Redis/Orleans.Clustering.Redis/Hosting/RedisClusteringProviderBuilder.cs
+++ b/src/Redis/Orleans.Clustering.Redis/Hosting/RedisClusteringProviderBuilder.cs
@@ -11,9 +11,11 @@
[assembly: RegisterProvider("Redis", "Clustering", "Silo", typeof(RedisClusteringProviderBuilder))]
[assembly: RegisterProvider("AzureRedisCache", "Clustering", "Silo", typeof(RedisClusteringProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "Clustering", "Silo", typeof(RedisClusteringProviderBuilder))]
[assembly: RegisterProvider("Redis", "Clustering", "Client", typeof(RedisClusteringProviderBuilder))]
[assembly: RegisterProvider("AzureRedisCache", "Clustering", "Client", typeof(RedisClusteringProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "Clustering", "Client", typeof(RedisClusteringProviderBuilder))]
namespace Orleans.Clustering.Redis.Hosting;
diff --git a/src/Redis/Orleans.GrainDirectory.Redis/Hosting/RedisGrainDirectoryProviderBuilder.cs b/src/Redis/Orleans.GrainDirectory.Redis/Hosting/RedisGrainDirectoryProviderBuilder.cs
index 943816b05bd..59fe8533da6 100644
--- a/src/Redis/Orleans.GrainDirectory.Redis/Hosting/RedisGrainDirectoryProviderBuilder.cs
+++ b/src/Redis/Orleans.GrainDirectory.Redis/Hosting/RedisGrainDirectoryProviderBuilder.cs
@@ -11,6 +11,7 @@
[assembly: RegisterProvider("Redis", "GrainDirectory", "Silo", typeof(RedisGrainDirectoryProviderBuilder))]
[assembly: RegisterProvider("AzureRedisCache", "GrainDirectory", "Silo", typeof(RedisGrainDirectoryProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "GrainDirectory", "Silo", typeof(RedisGrainDirectoryProviderBuilder))]
namespace Orleans.Hosting;
diff --git a/src/Redis/Orleans.Journaling.Redis/RedisJournalingProviderBuilder.cs b/src/Redis/Orleans.Journaling.Redis/RedisJournalingProviderBuilder.cs
index 2c1d6977998..531be385f1c 100644
--- a/src/Redis/Orleans.Journaling.Redis/RedisJournalingProviderBuilder.cs
+++ b/src/Redis/Orleans.Journaling.Redis/RedisJournalingProviderBuilder.cs
@@ -6,6 +6,8 @@
using StackExchange.Redis;
[assembly: RegisterProvider("Redis", "Journaling", "Silo", typeof(Orleans.Hosting.RedisJournalingProviderBuilder))]
+[assembly: RegisterProvider("AzureRedisCache", "Journaling", "Silo", typeof(Orleans.Hosting.RedisJournalingProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "Journaling", "Silo", typeof(Orleans.Hosting.RedisJournalingProviderBuilder))]
namespace Orleans.Hosting;
diff --git a/src/Redis/Orleans.Persistence.Redis/Hosting/RedisGrainStorageProviderBuilder.cs b/src/Redis/Orleans.Persistence.Redis/Hosting/RedisGrainStorageProviderBuilder.cs
index 3eb225487a1..c5da48d138f 100644
--- a/src/Redis/Orleans.Persistence.Redis/Hosting/RedisGrainStorageProviderBuilder.cs
+++ b/src/Redis/Orleans.Persistence.Redis/Hosting/RedisGrainStorageProviderBuilder.cs
@@ -12,6 +12,7 @@
[assembly: RegisterProvider("Redis", "GrainStorage", "Silo", typeof(RedisGrainStorageProviderBuilder))]
[assembly: RegisterProvider("AzureRedisCache", "GrainStorage", "Silo", typeof(RedisGrainStorageProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "GrainStorage", "Silo", typeof(RedisGrainStorageProviderBuilder))]
namespace Orleans.Hosting;
diff --git a/src/Redis/Orleans.Reminders.Redis/Hosting/RedisRemindersProviderBuilder.cs b/src/Redis/Orleans.Reminders.Redis/Hosting/RedisRemindersProviderBuilder.cs
index d536e602ba5..8faeb709088 100644
--- a/src/Redis/Orleans.Reminders.Redis/Hosting/RedisRemindersProviderBuilder.cs
+++ b/src/Redis/Orleans.Reminders.Redis/Hosting/RedisRemindersProviderBuilder.cs
@@ -10,6 +10,7 @@
[assembly: RegisterProvider("Redis", "Reminders", "Silo", typeof(RedisRemindersProviderBuilder))]
[assembly: RegisterProvider("AzureRedisCache", "Reminders", "Silo", typeof(RedisRemindersProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "Reminders", "Silo", typeof(RedisRemindersProviderBuilder))]
namespace Orleans.Hosting;
diff --git a/src/Redis/Orleans.Streaming.Redis/Hosting/RedisStreamingProviderBuilder.cs b/src/Redis/Orleans.Streaming.Redis/Hosting/RedisStreamingProviderBuilder.cs
index 48de8b7d4a5..c65219effdd 100644
--- a/src/Redis/Orleans.Streaming.Redis/Hosting/RedisStreamingProviderBuilder.cs
+++ b/src/Redis/Orleans.Streaming.Redis/Hosting/RedisStreamingProviderBuilder.cs
@@ -9,8 +9,10 @@
[assembly: RegisterProvider("Redis", "Streaming", "Silo", typeof(RedisStreamingProviderBuilder))]
[assembly: RegisterProvider("AzureRedisCache", "Streaming", "Silo", typeof(RedisStreamingProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "Streaming", "Silo", typeof(RedisStreamingProviderBuilder))]
[assembly: RegisterProvider("Redis", "Streaming", "Client", typeof(RedisStreamingProviderBuilder))]
[assembly: RegisterProvider("AzureRedisCache", "Streaming", "Client", typeof(RedisStreamingProviderBuilder))]
+[assembly: RegisterProvider("AzureManagedRedis", "Streaming", "Client", typeof(RedisStreamingProviderBuilder))]
namespace Orleans.Hosting;
diff --git a/src/api/Orleans.Streaming.NATS/Orleans.Streaming.NATS.cs b/src/api/Orleans.Streaming.NATS/Orleans.Streaming.NATS.cs
index 4f50043b68a..d44fcbdecbd 100644
--- a/src/api/Orleans.Streaming.NATS/Orleans.Streaming.NATS.cs
+++ b/src/api/Orleans.Streaming.NATS/Orleans.Streaming.NATS.cs
@@ -6,6 +6,16 @@
// the code is regenerated.
//
//------------------------------------------------------------------------------
+namespace Orleans.Hosting
+{
+ public sealed partial class NatsStreamProviderBuilder : Providers.IProviderBuilder, Providers.IProviderBuilder
+ {
+ public void Configure(IClientBuilder builder, string? name, Microsoft.Extensions.Configuration.IConfigurationSection configurationSection) { }
+
+ public void Configure(ISiloBuilder builder, string? name, Microsoft.Extensions.Configuration.IConfigurationSection configurationSection) { }
+ }
+}
+
namespace Orleans.Streaming.NATS
{
public partial class NatsOptions
diff --git a/test/Extensions/AspireTestInfrastructure/AspireResourceConfiguration.cs b/test/Extensions/AspireTestInfrastructure/AspireResourceConfiguration.cs
new file mode 100644
index 00000000000..cdc2b6c6cf6
--- /dev/null
+++ b/test/Extensions/AspireTestInfrastructure/AspireResourceConfiguration.cs
@@ -0,0 +1,64 @@
+#if NET10_0_OR_GREATER
+
+using Aspire.Hosting.ApplicationModel;
+using Microsoft.Extensions.Configuration;
+
+namespace TestExtensions;
+
+internal sealed class AspireResourceConfiguration : ConfigurationProvider, IConfigurationSource
+{
+ private AspireResourceConfiguration(Dictionary values)
+ {
+ Data = values;
+ }
+
+ public IConfigurationProvider Build(IConfigurationBuilder builder) => this;
+
+ public static async Task CreateAsync(
+ IResource resource,
+ IServiceProvider services,
+ DistributedApplicationOperation operation = DistributedApplicationOperation.Run,
+ Func? include = null)
+ {
+ var executionContext = new DistributedApplicationExecutionContext(
+ new DistributedApplicationExecutionContextOptions(operation)
+ {
+ ServiceProvider = services,
+ });
+ var environment = new Dictionary();
+ var callbackContext = new EnvironmentCallbackContext(executionContext, resource, environment);
+
+ foreach (var annotation in resource.Annotations.OfType())
+ {
+ await annotation.Callback(callbackContext);
+ }
+
+ var valueContext = new ValueProviderContext
+ {
+ Caller = resource,
+ ExecutionContext = executionContext,
+ Network = KnownNetworkIdentifiers.LocalhostNetwork,
+ };
+ var configuration = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var (key, value) in environment)
+ {
+ if (include is not null && !include(key))
+ {
+ continue;
+ }
+
+ configuration[key.Replace("__", ":", StringComparison.Ordinal)] = value switch
+ {
+ null => string.Empty,
+ IValueProvider provider => await provider.GetValueAsync(valueContext),
+ _ => value.ToString(),
+ };
+ }
+
+ return new ConfigurationBuilder()
+ .Add(new AspireResourceConfiguration(configuration))
+ .Build();
+ }
+}
+
+#endif
diff --git a/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireIntegrationTests.cs b/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireIntegrationTests.cs
new file mode 100644
index 00000000000..b820b05bce4
--- /dev/null
+++ b/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireIntegrationTests.cs
@@ -0,0 +1,455 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Orleans;
+using Aspire.Hosting.Testing;
+using Amazon.DynamoDBv2;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Orleans.Clustering.DynamoDB;
+using Orleans.Configuration;
+using Orleans.Hosting;
+using Orleans.Runtime;
+using Orleans.Storage;
+using TestExtensions;
+using Xunit;
+namespace AWSUtils.Tests.Configuration;
+
+[TestSuite("BVT")]
+[TestProvider("DynamoDB")]
+[TestArea("Aspire")]
+[TestCategory("AWS"), TestCategory("DynamoDB"), TestCategory("Aspire"), TestCategory("BVT")]
+public sealed class DynamoDBAspireIntegrationTests
+{
+ [Fact]
+ public async Task DynamoDBLocalContract_RunMode_EmitsAwsDynamoDBEndpoint()
+ {
+ await using var builder = DistributedApplicationTestingBuilder.Create();
+ var dynamodb = AddDynamoDBLocal(builder, "dynamodb");
+ var silo = builder.AddContainer("silo", "unused")
+ .WithEnvironment("AWS_ENDPOINT_URL_DYNAMODB", dynamodb.GetEndpoint("http"));
+ AllocateEndpoint(dynamodb, 8000);
+
+ await using var app = await builder.BuildAsync(TestContext.Current.CancellationToken);
+ var configuration = await AspireResourceConfiguration.CreateAsync(
+ silo.Resource,
+ app.Services,
+ include: static key => key.StartsWith("AWS_", StringComparison.Ordinal));
+
+ Assert.Equal("http://localhost:8000", configuration["AWS_ENDPOINT_URL_DYNAMODB"]);
+ Assert.Contains(
+ dynamodb.Resource.Annotations.OfType(),
+ endpoint => endpoint.Name == "http" && endpoint.TargetPort == 8000);
+ }
+
+ [Fact]
+ public async Task AppHost_WhenInferenceIsUnavailable_UsesExplicitDynamoDBProviderConfiguration()
+ {
+ var generated = await CreateDynamoDBOrleansEnvironmentAsync();
+ var siloConfiguration = generated.Silo;
+ var clientConfiguration = generated.Client;
+
+ AssertProviderConfiguration(siloConfiguration, "Orleans:Clustering");
+ AssertProviderConfiguration(siloConfiguration, "Orleans:GrainStorage:Default");
+ AssertProviderConfiguration(siloConfiguration, "Orleans:Reminders");
+ AssertProviderConfiguration(clientConfiguration, "Orleans:Clustering");
+ Assert.Equal("http://localhost:8000", siloConfiguration["AWS_ENDPOINT_URL_DYNAMODB"]);
+ Assert.Equal("http://localhost:8000", clientConfiguration["AWS_ENDPOINT_URL_DYNAMODB"]);
+ }
+
+ [Fact]
+ public async Task AppHostGeneratedConfiguration_ActivatesAllDynamoDBProviders()
+ {
+ var generated = await CreateDynamoDBOrleansEnvironmentAsync();
+ var siloConfiguration = generated.Silo;
+ var clientConfiguration = generated.Client;
+
+ var siloBuilder = Host.CreateApplicationBuilder();
+ siloBuilder.Configuration.AddConfiguration(siloConfiguration);
+ siloBuilder.UseOrleans();
+ using var siloHost = siloBuilder.Build();
+
+ var membership = siloHost.Services.GetRequiredService();
+ var storage = siloHost.Services.GetRequiredKeyedService("Default");
+ var reminders = siloHost.Services.GetRequiredService();
+ var clusteringOptions = siloHost.Services.GetRequiredService>().Value;
+ var storageOptions = siloHost.Services
+ .GetRequiredService>()
+ .Get("Default");
+ var reminderOptions = siloHost.Services
+ .GetRequiredService>()
+ .Value;
+
+ Assert.Equal("DynamoDBMembershipTable", membership.GetType().Name);
+ Assert.IsType(storage);
+ Assert.Equal("DynamoDBReminderTable", reminders.GetType().Name);
+ AssertDynamoDBOptions(clusteringOptions.Service, clusteringOptions.TableName, "OrleansSilos");
+ AssertDynamoDBOptions(storageOptions.Service, storageOptions.TableName, "OrleansGrainState");
+ AssertDynamoDBOptions(reminderOptions.Service, reminderOptions.TableName, "OrleansReminders");
+ ValidateDynamoDBOptions(
+ siloHost.Services,
+ [
+ "DynamoDBClusteringOptionsValidator",
+ "DynamoDBGrainStorageOptionsValidator",
+ "DynamoDBReminderStorageOptionsValidator",
+ ]);
+
+ var clientBuilder = Host.CreateApplicationBuilder();
+ clientBuilder.Configuration.AddConfiguration(clientConfiguration);
+ clientBuilder.UseOrleansClient();
+ using var clientHost = clientBuilder.Build();
+
+ var gateway = clientHost.Services.GetRequiredService();
+ var gatewayOptions = clientHost.Services.GetRequiredService>().Value;
+
+ Assert.Equal("DynamoDBGatewayListProvider", gateway.GetType().Name);
+ AssertDynamoDBOptions(gatewayOptions.Service, gatewayOptions.TableName, "OrleansSilos");
+ ValidateDynamoDBOptions(clientHost.Services, ["DynamoDBGatewayOptionsValidator"]);
+ }
+
+ [Fact]
+ public async Task CdkAppModel_EmitsExactTopologyAndSecretFreeConfiguration()
+ {
+ await using var app = await DynamoDBAspireTestModel.CreateCdkAsync();
+ var silo = await app.GetSiloConfigurationAsync();
+ var client = await app.GetClientConfigurationAsync();
+ var tables = app.Model.Resources
+ .OfType()
+ .Select(resource => resource.Contract)
+ .OrderBy(table => table.ResourceName, StringComparer.Ordinal)
+ .ToArray();
+
+ AssertTable(
+ tables,
+ "orleans-membership",
+ "orders-orleans-membership",
+ ("DeploymentId", DynamoDBAttributeType.String),
+ ("SiloIdentity", DynamoDBAttributeType.String));
+ AssertTable(
+ tables,
+ "orleans-grain-state",
+ "orders-orleans-grain-state",
+ ("GrainReference", DynamoDBAttributeType.String),
+ ("GrainType", DynamoDBAttributeType.String));
+ AssertTable(
+ tables,
+ "orleans-reminders",
+ "orders-orleans-reminders",
+ ("ReminderId", DynamoDBAttributeType.String),
+ ("GrainHash", DynamoDBAttributeType.Number),
+ ("ServiceIdIndex", "ServiceId", DynamoDBAttributeType.String, "GrainHash", DynamoDBAttributeType.Number),
+ ("ServiceIdGrainReferenceIndex", "ServiceId", DynamoDBAttributeType.String, "GrainReference", DynamoDBAttributeType.String));
+ AssertTable(
+ tables,
+ "orleans-transactions",
+ "orders-orleans-transactions",
+ ("PartitionKey", DynamoDBAttributeType.String),
+ ("RowKey", DynamoDBAttributeType.String));
+ AssertTable(
+ tables,
+ "orleans-checkpoints",
+ "orders-orleans-checkpoints",
+ ("CheckpointNamespace", DynamoDBAttributeType.String),
+ ("Partition", DynamoDBAttributeType.String));
+ Assert.Equal("integration-profile", silo["AWS_PROFILE"]);
+ Assert.Equal(DynamoDBAspireTopology.Region, silo["AWS_REGION"]);
+ Assert.Equal("integration-profile", silo["AWS:Profile"]);
+ Assert.Equal(DynamoDBAspireTopology.Region, silo["AWS:Region"]);
+ Assert.Equal(silo["AWS_PROFILE"], client["AWS_PROFILE"]);
+ Assert.Equal(silo["AWS_REGION"], client["AWS_REGION"]);
+ AssertProviderConfiguration(silo, "Orleans:Clustering", DynamoDBAspireTopology.Membership);
+ AssertProviderConfiguration(silo, "Orleans:GrainStorage:Default", DynamoDBAspireTopology.GrainState);
+ AssertProviderConfiguration(silo, "Orleans:Reminders", DynamoDBAspireTopology.Reminders);
+ AssertProviderConfiguration(client, "Orleans:Clustering", DynamoDBAspireTopology.Membership);
+ AssertSecretFree(silo);
+ AssertSecretFree(client);
+ Assert.Single(
+ app.Model.Resources,
+ resource => resource is DynamoDBAspireTestModel.DynamoDBStackResource);
+ }
+
+ [Fact]
+ public async Task CdkGeneratedConfiguration_ActivatesInfrastructureOwnedProviders()
+ {
+ await using var app = await DynamoDBAspireTestModel.CreateCdkAsync(profile: null);
+ var siloConfiguration = await app.GetSiloConfigurationAsync();
+ var clientConfiguration = await app.GetClientConfigurationAsync();
+
+ var siloBuilder = Host.CreateApplicationBuilder();
+ siloBuilder.Configuration.AddConfiguration(siloConfiguration);
+ siloBuilder.UseOrleans();
+ using var siloHost = siloBuilder.Build();
+ var clustering = siloHost.Services.GetRequiredService>().Value;
+ var storage = siloHost.Services
+ .GetRequiredService>()
+ .Get("Default");
+ var reminders = siloHost.Services
+ .GetRequiredService>()
+ .Value;
+
+ AssertInfrastructureOwnedOptions(
+ clustering.Service,
+ clustering.TableName,
+ clustering.UseProvisionedThroughput,
+ clustering.CreateIfNotExists,
+ clustering.UpdateIfExists,
+ clustering.AccessKey,
+ clustering.SecretKey,
+ clustering.Token,
+ clustering.ProfileName,
+ DynamoDBAspireTopology.Membership.TableName);
+ AssertInfrastructureOwnedOptions(
+ storage.Service,
+ storage.TableName,
+ storage.UseProvisionedThroughput,
+ storage.CreateIfNotExists,
+ storage.UpdateIfExists,
+ storage.AccessKey,
+ storage.SecretKey,
+ storage.Token,
+ storage.ProfileName,
+ DynamoDBAspireTopology.GrainState.TableName);
+ AssertInfrastructureOwnedOptions(
+ reminders.Service,
+ reminders.TableName,
+ reminders.UseProvisionedThroughput,
+ reminders.CreateIfNotExists,
+ reminders.UpdateIfExists,
+ reminders.AccessKey,
+ reminders.SecretKey,
+ reminders.Token,
+ reminders.ProfileName,
+ DynamoDBAspireTopology.Reminders.TableName);
+ Assert.Equal(DynamoDBAspireTopology.ServiceId, storage.ServiceId);
+
+ var clientBuilder = Host.CreateApplicationBuilder();
+ clientBuilder.Configuration.AddConfiguration(clientConfiguration);
+ clientBuilder.UseOrleansClient();
+ using var clientHost = clientBuilder.Build();
+ var gateway = clientHost.Services.GetRequiredService>().Value;
+ AssertInfrastructureOwnedOptions(
+ gateway.Service,
+ gateway.TableName,
+ gateway.UseProvisionedThroughput,
+ gateway.CreateIfNotExists,
+ gateway.UpdateIfExists,
+ gateway.AccessKey,
+ gateway.SecretKey,
+ gateway.Token,
+ gateway.ProfileName,
+ DynamoDBAspireTopology.Membership.TableName);
+ }
+
+ [Fact]
+ public async Task CdkPublishConfiguration_RetainsProviderRegionAndServiceIdentity()
+ {
+ await using var app = await DynamoDBAspireTestModel.CreateCdkAsync();
+ var configuration = await app.GetSiloConfigurationAsync(DistributedApplicationOperation.Publish);
+
+ Assert.Null(configuration["AWS:Region"]);
+ Assert.Null(configuration["AWS:Profile"]);
+ Assert.Equal(
+ DynamoDBAspireTopology.Region,
+ configuration["Orleans:Clustering:Region"]);
+ Assert.Equal(
+ DynamoDBAspireTopology.Region,
+ configuration["Orleans:GrainStorage:Default:Region"]);
+ Assert.Equal(
+ DynamoDBAspireTopology.Region,
+ configuration["Orleans:Reminders:Region"]);
+ Assert.Equal(
+ DynamoDBAspireTopology.ServiceId,
+ configuration["Orleans:GrainStorage:Default:ServiceId"]);
+
+ var hostBuilder = Host.CreateApplicationBuilder();
+ hostBuilder.Configuration.AddConfiguration(configuration);
+ hostBuilder.UseOrleans();
+ using var host = hostBuilder.Build();
+ var storage = host.Services
+ .GetRequiredService>()
+ .Get("Default");
+
+ Assert.Equal(DynamoDBAspireTopology.Region, storage.Service);
+ Assert.Equal(DynamoDBAspireTopology.ServiceId, storage.ServiceId);
+ }
+
+ [Fact]
+ public void DynamoDBProvider_UsesAwsSdkV4()
+ => Assert.Equal(4, typeof(AmazonDynamoDBClient).Assembly.GetName().Version?.Major);
+
+ private static async Task<(
+ IConfigurationRoot Silo,
+ IConfigurationRoot Client)> CreateDynamoDBOrleansEnvironmentAsync()
+ {
+ await using var builder = DistributedApplicationTestingBuilder.Create();
+ var dynamodb = AddDynamoDBLocal(builder, "dynamodb");
+ var provider = new DynamoDBProviderConfiguration("dynamodb");
+ var orleans = builder.AddOrleans("cluster")
+ .WithClusterId("phase3-cluster")
+ .WithServiceId("phase3-service")
+ .WithClustering(provider)
+ .WithGrainStorage("Default", provider)
+ .WithReminders(provider);
+ var silo = builder.AddContainer("silo", "unused")
+ .WithReference(orleans)
+ .WithEnvironment("AWS_ENDPOINT_URL_DYNAMODB", dynamodb.GetEndpoint("http"));
+ var client = builder.AddContainer("client", "unused")
+ .WithReference(orleans.AsClient())
+ .WithEnvironment("AWS_ENDPOINT_URL_DYNAMODB", dynamodb.GetEndpoint("http"));
+ AllocateEndpoint(dynamodb, 8000);
+
+ await using var app = await builder.BuildAsync();
+ var siloConfiguration = await AspireResourceConfiguration.CreateAsync(
+ silo.Resource,
+ app.Services,
+ include: IncludeProviderConfiguration);
+ var clientConfiguration = await AspireResourceConfiguration.CreateAsync(
+ client.Resource,
+ app.Services,
+ include: IncludeProviderConfiguration);
+ return (siloConfiguration, clientConfiguration);
+ }
+
+ private static IResourceBuilder AddDynamoDBLocal(
+ IDistributedApplicationBuilder builder,
+ string name)
+ => builder.AddContainer(name, "amazon/dynamodb-local")
+ .WithHttpEndpoint(targetPort: 8000, name: "http");
+
+ private static void AllocateEndpoint(
+ IResourceBuilder dynamodb,
+ int port)
+ {
+ var endpoint = dynamodb.Resource.Annotations
+ .OfType()
+ .Single(annotation => annotation.Name == "http");
+ endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", port);
+ }
+
+ private static bool IncludeProviderConfiguration(string key)
+ => !key.StartsWith("Orleans__Endpoints__", StringComparison.Ordinal)
+ && (key.StartsWith("Orleans__", StringComparison.Ordinal)
+ || key.StartsWith("AWS_", StringComparison.Ordinal)
+ || key.StartsWith("AWS__", StringComparison.Ordinal));
+
+ private static void AssertProviderConfiguration(
+ IConfiguration configuration,
+ string section)
+ {
+ Assert.Equal("DynamoDB", configuration[$"{section}:ProviderType"]);
+ Assert.Equal("dynamodb", configuration[$"{section}:ServiceKey"]);
+ }
+
+ private static void AssertProviderConfiguration(
+ IConfiguration configuration,
+ string section,
+ DynamoDBTableContract table)
+ {
+ Assert.Equal("DynamoDB", configuration[$"{section}:ProviderType"]);
+ Assert.Equal(table.ResourceName, configuration[$"{section}:ServiceKey"]);
+ Assert.Equal(DynamoDBAspireTopology.Region, configuration[$"{section}:Region"]);
+ Assert.False(bool.Parse(configuration[$"{section}:UseProvisionedThroughput"]!));
+ Assert.False(bool.Parse(configuration[$"{section}:CreateIfNotExists"]!));
+ Assert.False(bool.Parse(configuration[$"{section}:UpdateIfExists"]!));
+ Assert.Equal(table.TableName, configuration[$"AWS:Resources:{table.ResourceName}:TableName"]);
+ }
+
+ private static void AssertDynamoDBOptions(
+ string? service,
+ string? tableName,
+ string expectedTableName)
+ {
+ Assert.Equal("http://localhost:8000", service);
+ Assert.Equal(expectedTableName, tableName);
+ }
+
+ private static void ValidateDynamoDBOptions(
+ IServiceProvider services,
+ string[] expectedValidatorNames)
+ {
+ var validators = services
+ .GetServices()
+ .Where(validator => validator.GetType().Name.Contains("DynamoDB", StringComparison.Ordinal))
+ .ToArray();
+
+ Assert.Equal(
+ expectedValidatorNames.Order(StringComparer.Ordinal),
+ validators.Select(validator => validator.GetType().Name).Order(StringComparer.Ordinal));
+ Assert.All(validators, validator => validator.ValidateConfiguration());
+ }
+
+ private static void AssertInfrastructureOwnedOptions(
+ string? service,
+ string? tableName,
+ bool useProvisionedThroughput,
+ bool createIfNotExists,
+ bool updateIfExists,
+ string? accessKey,
+ string? secretKey,
+ string? token,
+ string? profileName,
+ string expectedTableName)
+ {
+ Assert.Equal(DynamoDBAspireTopology.Region, service);
+ Assert.Equal(expectedTableName, tableName);
+ Assert.False(useProvisionedThroughput);
+ Assert.False(createIfNotExists);
+ Assert.False(updateIfExists);
+ Assert.Null(accessKey);
+ Assert.Null(secretKey);
+ Assert.Null(token);
+ Assert.Null(profileName);
+ }
+
+ private static void AssertSecretFree(IConfiguration configuration)
+ {
+ string[] forbidden = ["AccessKey", "SecretKey", "SessionToken", "Token"];
+ Assert.DoesNotContain(
+ configuration.AsEnumerable(),
+ pair => forbidden.Any(fragment =>
+ pair.Key.Contains(fragment, StringComparison.OrdinalIgnoreCase)
+ || pair.Value?.Contains(fragment, StringComparison.OrdinalIgnoreCase) == true));
+ }
+
+ private static void AssertTable(
+ IReadOnlyList tables,
+ string resourceName,
+ string tableName,
+ (string Name, DynamoDBAttributeType Type) partitionKey,
+ (string Name, DynamoDBAttributeType Type) sortKey,
+ params (string Name, string PartitionName, DynamoDBAttributeType PartitionType, string SortName, DynamoDBAttributeType SortType)[] indexes)
+ {
+ var table = Assert.Single(tables, value => value.ResourceName == resourceName);
+ Assert.Equal(tableName, table.TableName);
+ Assert.Equal(new DynamoDBAttributeContract(partitionKey.Name, partitionKey.Type), table.PartitionKey);
+ Assert.Equal(new DynamoDBAttributeContract(sortKey.Name, sortKey.Type), table.SortKey);
+ Assert.Equal(indexes.Length, table.GlobalSecondaryIndexes.Count);
+ foreach (var index in indexes)
+ {
+ var actual = Assert.Single(table.GlobalSecondaryIndexes, value => value.Name == index.Name);
+ Assert.Equal(
+ new DynamoDBAttributeContract(index.PartitionName, index.PartitionType),
+ actual.PartitionKey);
+ Assert.Equal(
+ new DynamoDBAttributeContract(index.SortName, index.SortType),
+ actual.SortKey);
+ }
+ }
+
+ private sealed class DynamoDBProviderConfiguration(string serviceKey) : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resource,
+ string configurationSection)
+ where T : IResourceWithEnvironment
+ {
+ resource.WithEnvironment(
+ $"Orleans__{configurationSection}__ProviderType",
+ "DynamoDB");
+ resource.WithEnvironment(
+ $"Orleans__{configurationSection}__ServiceKey",
+ serviceKey);
+ }
+ }
+}
diff --git a/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireLiveTests.cs b/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireLiveTests.cs
new file mode 100644
index 00000000000..c78ef848857
--- /dev/null
+++ b/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireLiveTests.cs
@@ -0,0 +1,452 @@
+using System.Net;
+using Amazon.DynamoDBv2.Model;
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Orleans;
+using Aspire.Hosting.Testing;
+using AWSUtils.Tests.StorageTests;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Orleans;
+using Orleans.AWSUtils.Tests;
+using Orleans.Configuration;
+using Orleans.Hosting;
+using Orleans.Messaging;
+using Orleans.Runtime;
+using Orleans.Storage;
+using TestExtensions;
+using Xunit;
+
+namespace AWSUtils.Tests.Configuration;
+
+[TestSuite("Functional")]
+[TestProvider("DynamoDB")]
+[TestArea("Aspire")]
+[TestCategory("AWS"), TestCategory("DynamoDB"), TestCategory("Aspire"), TestCategory("Functional")]
+[Collection(TestEnvironmentFixture.DefaultCollection)]
+public sealed class DynamoDBAspireLiveTests
+{
+ private const string StorageName = "LiveStore";
+ private const string UnavailableSkipReason = "Unable to connect to AWS DynamoDB simulator";
+
+ [Fact]
+ public async Task GeneratedConfiguration_Clustering_ProvidesMembershipAndGateway()
+ {
+ var endpoint = GetDynamoDBLocalEndpointOrSkip();
+ var suffix = Guid.NewGuid().ToString("N");
+ var clusterId = $"phase4-cluster-{suffix}";
+ var serviceId = $"phase4-service-{suffix}";
+ var tableName = $"phase4-membership-{suffix}";
+ IMembershipTable? membership = null;
+
+ try
+ {
+ var generated = await CreateGeneratedConfigurationAsync(
+ ProviderSurface.Clustering,
+ endpoint,
+ clusterId,
+ serviceId,
+ tableName);
+
+ AssertGeneratedEndpoint(generated.Silo, endpoint);
+ Assert.NotNull(generated.Client);
+ AssertGeneratedEndpoint(generated.Client, endpoint);
+
+ using var siloHost = BuildSiloHost(generated.Silo);
+ using var clientHost = BuildClientHost(generated.Client);
+ membership = siloHost.Services.GetRequiredService();
+ var gateway = clientHost.Services.GetRequiredService();
+ var membershipOptions = siloHost.Services.GetRequiredService>().Value;
+ var gatewayOptions = clientHost.Services.GetRequiredService>().Value;
+
+ Assert.Equal("DynamoDBMembershipTable", membership.GetType().Name);
+ Assert.Equal("DynamoDBGatewayListProvider", gateway.GetType().Name);
+ AssertProviderOptions(membershipOptions.Service, membershipOptions.TableName, membershipOptions.UseProvisionedThroughput, endpoint, tableName);
+ AssertProviderOptions(gatewayOptions.Service, gatewayOptions.TableName, gatewayOptions.UseProvisionedThroughput, endpoint, tableName);
+
+ var cancellationToken = TestContext.Current.CancellationToken;
+ await membership.InitializeMembershipTableAsync(tryInitTableVersion: true, cancellationToken);
+ await gateway.InitializeGatewayListProvider();
+
+ var initial = await membership.ReadAllAsync(cancellationToken);
+ Assert.Empty(initial.Members);
+ Assert.Equal(0, initial.Version.Version);
+
+ const int siloPort = 11_111;
+ const int gatewayPort = 30_000;
+ const int generation = 7;
+ var siloAddress = SiloAddress.New(IPAddress.Loopback, siloPort, generation);
+ var startTime = new DateTime(2026, 8, 23, 12, 0, 0, DateTimeKind.Utc);
+ var entry = new MembershipEntry
+ {
+ HostName = "phase4-host",
+ IAmAliveTime = startTime.AddMinutes(1),
+ ProxyPort = gatewayPort,
+ SiloAddress = siloAddress,
+ SiloName = $"phase4-silo-{suffix}",
+ StartTime = startTime,
+ Status = SiloStatus.Active,
+ };
+
+ Assert.True(await membership.InsertRowAsync(entry, initial.Version.Next(), cancellationToken));
+
+ var stored = await membership.ReadRowAsync(siloAddress, cancellationToken);
+ var storedEntry = Assert.Single(stored.Members).Item1;
+ Assert.Equal(entry.SiloName, storedEntry.SiloName);
+ Assert.Equal(SiloStatus.Active, storedEntry.Status);
+ Assert.Equal(gatewayPort, storedEntry.ProxyPort);
+
+ var gateways = await gateway.GetGateways();
+ var gatewayUri = Assert.Single(gateways);
+ Assert.Equal(
+ SiloAddress.New(IPAddress.Loopback, gatewayPort, generation).ToGatewayUri(),
+ gatewayUri);
+ }
+ finally
+ {
+ if (membership is not null)
+ {
+ try
+ {
+ await membership.DeleteMembershipTableEntriesAsync(clusterId, CancellationToken.None);
+ }
+ catch (ResourceNotFoundException)
+ {
+ }
+ }
+
+ await DeleteTableIfExistsAsync(tableName, endpoint);
+ }
+ }
+
+ [Fact]
+ public async Task GeneratedConfiguration_NamedGrainStorage_WritesReadsAndClearsState()
+ {
+ var endpoint = GetDynamoDBLocalEndpointOrSkip();
+ var suffix = Guid.NewGuid().ToString("N");
+ var clusterId = $"phase4-cluster-{suffix}";
+ var serviceId = $"phase4-service-{suffix}";
+ var tableName = $"phase4-storage-{suffix}";
+
+ try
+ {
+ var generated = await CreateGeneratedConfigurationAsync(
+ ProviderSurface.GrainStorage,
+ endpoint,
+ clusterId,
+ serviceId,
+ tableName);
+
+ AssertGeneratedEndpoint(generated.Silo, endpoint);
+
+ using var host = BuildSiloHost(generated.Silo);
+ var storage = host.Services.GetRequiredKeyedService(StorageName);
+ var options = host.Services
+ .GetRequiredService>()
+ .Get(StorageName);
+
+ Assert.IsType(storage);
+ AssertProviderOptions(options.Service, options.TableName, options.UseProvisionedThroughput, endpoint, tableName);
+ Assert.Equal(serviceId, options.ServiceId);
+ Assert.True(options.DeleteStateOnClear);
+
+ await Assert.IsType(storage).Init(CancellationToken.None);
+
+ var grainType = $"phase4-state-{suffix}";
+ var grainId = GrainId.Create("phase4-live", suffix);
+ var state = new GrainState(
+ new LiveState
+ {
+ Name = "generated-configuration",
+ Revision = 42,
+ });
+
+ await storage.WriteStateAsync(grainType, grainId, state);
+ Assert.True(state.RecordExists);
+ Assert.Equal("0", state.ETag);
+
+ var read = new GrainState(new LiveState());
+ await storage.ReadStateAsync(grainType, grainId, read);
+ Assert.True(read.RecordExists);
+ Assert.NotNull(read.State);
+ Assert.Equal("generated-configuration", read.State.Name);
+ Assert.Equal(42, read.State.Revision);
+ Assert.Equal(state.ETag, read.ETag);
+
+ await storage.ClearStateAsync(grainType, grainId, read);
+ Assert.False(read.RecordExists);
+ Assert.Null(read.ETag);
+
+ var afterClear = new GrainState(
+ new LiveState
+ {
+ Name = "sentinel",
+ Revision = -1,
+ });
+ await storage.ReadStateAsync(grainType, grainId, afterClear);
+ Assert.False(afterClear.RecordExists);
+ Assert.Null(afterClear.ETag);
+ Assert.NotNull(afterClear.State);
+ Assert.Equal(string.Empty, afterClear.State.Name);
+ Assert.Equal(0, afterClear.State.Revision);
+ }
+ finally
+ {
+ await DeleteTableIfExistsAsync(tableName, endpoint);
+ }
+ }
+
+ [Fact]
+ public async Task GeneratedConfiguration_Reminders_UpsertsReadsAndDeletesEntry()
+ {
+ var endpoint = GetDynamoDBLocalEndpointOrSkip();
+ var suffix = Guid.NewGuid().ToString("N");
+ var clusterId = $"phase4-cluster-{suffix}";
+ var serviceId = $"phase4-service-{suffix}";
+ var tableName = $"phase4-reminders-{suffix}";
+
+ try
+ {
+ var generated = await CreateGeneratedConfigurationAsync(
+ ProviderSurface.Reminders,
+ endpoint,
+ clusterId,
+ serviceId,
+ tableName);
+
+ AssertGeneratedEndpoint(generated.Silo, endpoint);
+
+ using var host = BuildSiloHost(generated.Silo);
+ var reminders = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>().Value;
+
+ Assert.Equal("DynamoDBReminderTable", reminders.GetType().Name);
+ AssertProviderOptions(options.Service, options.TableName, options.UseProvisionedThroughput, endpoint, tableName);
+
+ await reminders.StartAsync(TestContext.Current.CancellationToken);
+
+ var entry = new ReminderEntry
+ {
+ GrainId = GrainId.Create("phase4-remindable", suffix),
+ ReminderName = $"phase4-reminder-{suffix}",
+ StartAt = new DateTime(2026, 8, 23, 12, 34, 56, DateTimeKind.Utc),
+ Period = TimeSpan.FromMinutes(7),
+ };
+
+ var cancellationToken = TestContext.Current.CancellationToken;
+ var eTag = await reminders.UpsertRow(entry, cancellationToken);
+ Assert.False(string.IsNullOrWhiteSpace(eTag));
+ Assert.Equal(eTag, entry.ETag);
+
+ var stored = await reminders.ReadRow(entry.GrainId, entry.ReminderName, cancellationToken);
+ Assert.NotNull(stored);
+ Assert.Equal(entry.GrainId, stored.GrainId);
+ Assert.Equal(entry.ReminderName, stored.ReminderName);
+ Assert.Equal(entry.StartAt, stored.StartAt);
+ Assert.Equal(entry.Period, stored.Period);
+ Assert.Equal(eTag, stored.ETag);
+
+ Assert.True(await reminders.RemoveRow(entry.GrainId, entry.ReminderName, eTag, cancellationToken));
+ Assert.Null(await reminders.ReadRow(entry.GrainId, entry.ReminderName, cancellationToken));
+ Assert.False(await reminders.RemoveRow(entry.GrainId, entry.ReminderName, eTag, cancellationToken));
+ }
+ finally
+ {
+ await DeleteTableIfExistsAsync(tableName, endpoint);
+ }
+ }
+
+ private static Uri GetDynamoDBLocalEndpointOrSkip()
+ {
+ if (!AWSTestConstants.IsDynamoDbAvailable
+ || !Uri.TryCreate(AWSTestConstants.DynamoDbService, UriKind.Absolute, out var endpoint)
+ || (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
+ {
+ throw Xunit.Sdk.SkipException.ForSkip(UnavailableSkipReason);
+ }
+
+ return endpoint;
+ }
+
+ private static IHost BuildSiloHost(IConfiguration configuration)
+ {
+ var builder = Host.CreateApplicationBuilder();
+ builder.Configuration.AddConfiguration(configuration);
+ builder.UseOrleans();
+ return builder.Build();
+ }
+
+ private static IHost BuildClientHost(IConfiguration configuration)
+ {
+ var builder = Host.CreateApplicationBuilder();
+ builder.Configuration.AddConfiguration(configuration);
+ builder.UseOrleansClient();
+ return builder.Build();
+ }
+
+ private static async Task CreateGeneratedConfigurationAsync(
+ ProviderSurface surface,
+ Uri endpoint,
+ string clusterId,
+ string serviceId,
+ string tableName)
+ {
+ await using var builder = DistributedApplicationTestingBuilder.Create();
+ var dynamodb = AddDynamoDBLocal(builder, "dynamodb");
+ var provider = new DynamoDBProviderConfiguration("dynamodb", tableName, serviceId);
+ var orleans = builder.AddOrleans($"orleans-{Guid.NewGuid():N}")
+ .WithClusterId(clusterId)
+ .WithServiceId(serviceId);
+ if (surface != ProviderSurface.Clustering)
+ {
+ orleans.WithDevelopmentClustering();
+ }
+
+ switch (surface)
+ {
+ case ProviderSurface.Clustering:
+ orleans.WithClustering(provider);
+ break;
+ case ProviderSurface.GrainStorage:
+ orleans.WithGrainStorage(StorageName, provider);
+ break;
+ case ProviderSurface.Reminders:
+ orleans.WithReminders(provider);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(surface), surface, null);
+ }
+
+ var silo = builder.AddContainer("silo", "unused")
+ .WithReference(orleans)
+ .WithEnvironment("AWS_ENDPOINT_URL_DYNAMODB", dynamodb.GetEndpoint("http"));
+ IResource? clientResource = null;
+ if (surface == ProviderSurface.Clustering)
+ {
+ clientResource = builder.AddContainer("client", "unused")
+ .WithReference(orleans.AsClient())
+ .WithEnvironment("AWS_ENDPOINT_URL_DYNAMODB", dynamodb.GetEndpoint("http"))
+ .Resource;
+ }
+
+ AllocateEndpoint(dynamodb, endpoint);
+
+ await using var app = await builder.BuildAsync();
+ var siloConfiguration = await AspireResourceConfiguration.CreateAsync(
+ silo.Resource,
+ app.Services,
+ include: IncludeProviderConfiguration);
+ var clientConfiguration = clientResource is null
+ ? null
+ : await AspireResourceConfiguration.CreateAsync(
+ clientResource,
+ app.Services,
+ include: IncludeProviderConfiguration);
+ return new GeneratedConfiguration(siloConfiguration, clientConfiguration);
+ }
+
+ private static IResourceBuilder AddDynamoDBLocal(
+ IDistributedApplicationBuilder builder,
+ string name)
+ => builder.AddContainer(name, "amazon/dynamodb-local")
+ .WithHttpEndpoint(targetPort: 8000, name: "http");
+
+ private static void AllocateEndpoint(
+ IResourceBuilder dynamodb,
+ Uri endpointUri)
+ {
+ var endpoint = dynamodb.Resource.Annotations
+ .OfType()
+ .Single(annotation => annotation.Name == "http");
+ endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, endpointUri.Host, endpointUri.Port);
+ }
+
+ private static bool IncludeProviderConfiguration(string key)
+ => !key.StartsWith("Orleans__Endpoints__", StringComparison.Ordinal)
+ && (key.StartsWith("Orleans__", StringComparison.Ordinal)
+ || key.StartsWith("AWS_", StringComparison.Ordinal)
+ || key.StartsWith("AWS__", StringComparison.Ordinal));
+
+ private static void AssertGeneratedEndpoint(
+ IConfiguration configuration,
+ Uri endpoint)
+ => Assert.Equal(
+ endpoint.GetLeftPart(UriPartial.Authority).TrimEnd('/'),
+ configuration["AWS_ENDPOINT_URL_DYNAMODB"]?.TrimEnd('/'));
+
+ private static void AssertProviderOptions(
+ string? service,
+ string? tableName,
+ bool useProvisionedThroughput,
+ Uri endpoint,
+ string expectedTableName)
+ {
+ Assert.Equal(endpoint.GetLeftPart(UriPartial.Authority).TrimEnd('/'), service?.TrimEnd('/'));
+ Assert.Equal(expectedTableName, tableName);
+ Assert.False(useProvisionedThroughput);
+ }
+
+ private static async Task DeleteTableIfExistsAsync(string tableName, Uri endpoint)
+ {
+ var storage = new DynamoDBStorage(
+ NullLoggerFactory.Instance.CreateLogger("DynamoDBAspireLiveTests"),
+ endpoint.GetLeftPart(UriPartial.Authority));
+ try
+ {
+ await storage.DeleTableAsync(tableName);
+ }
+ catch (ResourceNotFoundException)
+ {
+ }
+ }
+
+ private enum ProviderSurface
+ {
+ Clustering,
+ GrainStorage,
+ Reminders,
+ }
+
+ private sealed record GeneratedConfiguration(
+ IConfigurationRoot Silo,
+ IConfigurationRoot? Client);
+
+ private sealed class DynamoDBProviderConfiguration(
+ string serviceKey,
+ string tableName,
+ string serviceId) : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resource,
+ string configurationSection)
+ where T : IResourceWithEnvironment
+ {
+ var section = configurationSection.Replace(":", "__", StringComparison.Ordinal);
+ var prefix = $"Orleans__{section}";
+ resource.WithEnvironment($"{prefix}__ProviderType", "DynamoDB");
+ resource.WithEnvironment($"{prefix}__ServiceKey", serviceKey);
+ resource.WithEnvironment($"{prefix}__TableName", tableName);
+ resource.WithEnvironment($"{prefix}__UseProvisionedThroughput", "false");
+ resource.WithEnvironment($"{prefix}__UpdateIfExists", "false");
+
+ if (section.StartsWith("GrainStorage__", StringComparison.Ordinal))
+ {
+ resource.WithEnvironment($"{prefix}__ServiceId", serviceId);
+ resource.WithEnvironment($"{prefix}__DeleteStateOnClear", "true");
+ }
+ }
+ }
+
+ [GenerateSerializer]
+ internal sealed class LiveState
+ {
+ [Id(0)]
+ public string Name { get; set; } = string.Empty;
+
+ [Id(1)]
+ public int Revision { get; set; }
+ }
+}
diff --git a/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireTestModel.cs b/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireTestModel.cs
new file mode 100644
index 00000000000..1955e1d1afe
--- /dev/null
+++ b/test/Extensions/Orleans.AWS.Tests/DynamoDBAspireTestModel.cs
@@ -0,0 +1,236 @@
+#if NET10_0_OR_GREATER
+
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Orleans;
+using Aspire.Hosting.Testing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using TestExtensions;
+
+namespace AWSUtils.Tests.Configuration;
+
+internal sealed class DynamoDBAspireTestModel : IAsyncDisposable
+{
+ private readonly IAsyncDisposable _builder;
+ private readonly DistributedApplication _application;
+
+ private DynamoDBAspireTestModel(
+ IAsyncDisposable builder,
+ DistributedApplication application,
+ IResource silo,
+ IResource client)
+ {
+ _builder = builder;
+ _application = application;
+ Silo = silo;
+ Client = client;
+ }
+
+ public IResource Silo { get; }
+
+ public IResource Client { get; }
+
+ public DistributedApplicationModel Model
+ => _application.Services.GetRequiredService();
+
+ public static async Task CreateCdkAsync(
+ string? profile = "integration-profile",
+ string region = DynamoDBAspireTopology.Region)
+ {
+ var builder = DistributedApplicationTestingBuilder.Create();
+ var stack = builder.AddResource(new DynamoDBStackResource("orleans-dynamodb"));
+ foreach (var table in DynamoDBAspireTopology.Tables)
+ {
+ builder.AddResource(new DynamoDBTableResource(table));
+ }
+
+ var orleans = builder.AddOrleans("cluster")
+ .WithClusterId(DynamoDBAspireTopology.ClusterId)
+ .WithServiceId(DynamoDBAspireTopology.ServiceId)
+ .WithClustering(new DynamoDBProviderConfiguration(
+ DynamoDBAspireTopology.Membership,
+ profile,
+ region,
+ infrastructureOwnsTable: true))
+ .WithGrainStorage("Default", new DynamoDBProviderConfiguration(
+ DynamoDBAspireTopology.GrainState,
+ profile,
+ region,
+ infrastructureOwnsTable: true,
+ serviceId: DynamoDBAspireTopology.ServiceId))
+ .WithReminders(new DynamoDBProviderConfiguration(
+ DynamoDBAspireTopology.Reminders,
+ profile,
+ region,
+ infrastructureOwnsTable: true));
+ var silo = builder.AddContainer("silo", "unused")
+ .WithReference(orleans)
+ .WaitFor(stack);
+ var client = builder.AddContainer("client", "unused")
+ .WithReference(orleans.AsClient())
+ .WaitFor(stack)
+ .WaitFor(silo);
+
+ var application = await builder.BuildAsync();
+ return new(builder, application, silo.Resource, client.Resource);
+ }
+
+ public Task GetSiloConfigurationAsync(
+ DistributedApplicationOperation operation = DistributedApplicationOperation.Run)
+ => GetConfigurationAsync(Silo, operation);
+
+ public Task GetClientConfigurationAsync(
+ DistributedApplicationOperation operation = DistributedApplicationOperation.Run)
+ => GetConfigurationAsync(Client, operation);
+
+ public async ValueTask DisposeAsync()
+ {
+ await _application.DisposeAsync();
+ await _builder.DisposeAsync();
+ }
+
+ private Task GetConfigurationAsync(
+ IResource resource,
+ DistributedApplicationOperation operation)
+ => AspireResourceConfiguration.CreateAsync(
+ resource,
+ _application.Services,
+ operation,
+ include: static key => !key.StartsWith("Orleans__Endpoints__", StringComparison.Ordinal)
+ && (key.StartsWith("Orleans__", StringComparison.Ordinal)
+ || key.StartsWith("AWS_", StringComparison.Ordinal)
+ || key.StartsWith("AWS__", StringComparison.Ordinal)));
+
+ internal sealed class DynamoDBStackResource(string name) : Resource(name), IResourceWithWaitSupport;
+
+ internal sealed class DynamoDBTableResource(DynamoDBTableContract contract) : Resource(contract.ResourceName)
+ {
+ public DynamoDBTableContract Contract { get; } = contract;
+ }
+
+ private sealed class DynamoDBProviderConfiguration(
+ DynamoDBTableContract table,
+ string? profile,
+ string region,
+ bool infrastructureOwnsTable,
+ string? serviceId = null) : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resource,
+ string configurationSection)
+ where T : IResourceWithEnvironment
+ {
+ var prefix = $"Orleans__{configurationSection.Replace(":", "__", StringComparison.Ordinal)}";
+ resource
+ .WithEnvironment($"{prefix}__ProviderType", "DynamoDB")
+ .WithEnvironment($"{prefix}__ServiceKey", table.ResourceName)
+ .WithEnvironment($"{prefix}__Region", region)
+ .WithEnvironment($"AWS__Resources__{table.ResourceName}__TableName", table.TableName)
+ .WithEnvironment(context =>
+ {
+ if (context.ExecutionContext.IsPublishMode)
+ {
+ return;
+ }
+
+ context.EnvironmentVariables["AWS_REGION"] = region;
+ context.EnvironmentVariables["AWS__Region"] = region;
+ if (profile is not null)
+ {
+ context.EnvironmentVariables["AWS_PROFILE"] = profile;
+ context.EnvironmentVariables["AWS__Profile"] = profile;
+ }
+ });
+
+ if (infrastructureOwnsTable)
+ {
+ resource
+ .WithEnvironment($"{prefix}__UseProvisionedThroughput", "false")
+ .WithEnvironment($"{prefix}__CreateIfNotExists", "false")
+ .WithEnvironment($"{prefix}__UpdateIfExists", "false");
+ }
+
+ if (serviceId is not null)
+ {
+ resource.WithEnvironment($"{prefix}__ServiceId", serviceId);
+ }
+ }
+ }
+}
+
+internal static class DynamoDBAspireTopology
+{
+ public const string ClusterId = "orders-production";
+ public const string ServiceId = "orders-service";
+ public const string Region = "us-east-1";
+
+ public static readonly DynamoDBTableContract Membership = new(
+ "orleans-membership",
+ "orders-orleans-membership",
+ new("DeploymentId", DynamoDBAttributeType.String),
+ new("SiloIdentity", DynamoDBAttributeType.String),
+ []);
+
+ public static readonly DynamoDBTableContract GrainState = new(
+ "orleans-grain-state",
+ "orders-orleans-grain-state",
+ new("GrainReference", DynamoDBAttributeType.String),
+ new("GrainType", DynamoDBAttributeType.String),
+ []);
+
+ public static readonly DynamoDBTableContract Reminders = new(
+ "orleans-reminders",
+ "orders-orleans-reminders",
+ new("ReminderId", DynamoDBAttributeType.String),
+ new("GrainHash", DynamoDBAttributeType.Number),
+ [
+ new(
+ "ServiceIdIndex",
+ new("ServiceId", DynamoDBAttributeType.String),
+ new("GrainHash", DynamoDBAttributeType.Number)),
+ new(
+ "ServiceIdGrainReferenceIndex",
+ new("ServiceId", DynamoDBAttributeType.String),
+ new("GrainReference", DynamoDBAttributeType.String)),
+ ]);
+
+ public static readonly DynamoDBTableContract Transactions = new(
+ "orleans-transactions",
+ "orders-orleans-transactions",
+ new("PartitionKey", DynamoDBAttributeType.String),
+ new("RowKey", DynamoDBAttributeType.String),
+ []);
+
+ public static readonly DynamoDBTableContract Checkpoints = new(
+ "orleans-checkpoints",
+ "orders-orleans-checkpoints",
+ new("CheckpointNamespace", DynamoDBAttributeType.String),
+ new("Partition", DynamoDBAttributeType.String),
+ []);
+
+ public static IReadOnlyList Tables { get; } =
+ [Membership, GrainState, Reminders, Transactions, Checkpoints];
+}
+
+internal sealed record DynamoDBTableContract(
+ string ResourceName,
+ string TableName,
+ DynamoDBAttributeContract PartitionKey,
+ DynamoDBAttributeContract SortKey,
+ IReadOnlyList GlobalSecondaryIndexes);
+
+internal sealed record DynamoDBIndexContract(
+ string Name,
+ DynamoDBAttributeContract PartitionKey,
+ DynamoDBAttributeContract SortKey);
+
+internal sealed record DynamoDBAttributeContract(string Name, DynamoDBAttributeType Type);
+
+internal enum DynamoDBAttributeType
+{
+ String,
+ Number,
+}
+
+#endif
diff --git a/test/Extensions/Orleans.AWS.Tests/DynamoDBProviderBuilderTests.cs b/test/Extensions/Orleans.AWS.Tests/DynamoDBProviderBuilderTests.cs
new file mode 100644
index 00000000000..1c72dfa5ff7
--- /dev/null
+++ b/test/Extensions/Orleans.AWS.Tests/DynamoDBProviderBuilderTests.cs
@@ -0,0 +1,1098 @@
+using System.Reflection;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Orleans;
+using Orleans.AWSUtils.Tests;
+using Orleans.Clustering.DynamoDB;
+using Orleans.Configuration;
+using Orleans.Hosting;
+using Orleans.Runtime;
+using Orleans.Storage;
+using Xunit;
+using ClusteringProviderConfiguration = Orleans.Clustering.DynamoDB.DynamoDBProviderConfiguration;
+
+namespace AWSUtils.Tests.Configuration;
+
+[TestSuite("BVT")]
+[TestProvider("DynamoDB")]
+[TestArea("Storage")]
+[TestCategory("AWS"), TestCategory("DynamoDB"), TestCategory("BVT")]
+public sealed class DynamoDBProviderConfigurationTests
+{
+ [Fact]
+ public void Create_Token_BindsSessionToken()
+ {
+ var (_, provider, options) = Bind(new() { ["Provider:Token"] = "direct-token" });
+
+ Assert.Equal("direct-token", options.Token);
+ Assert.Equal("direct-token", provider.GetValue("Token", "SessionToken"));
+ }
+
+ [Fact]
+ public void Create_SessionTokenAlias_BindsSessionToken()
+ {
+ var (_, provider, options) = Bind(new() { ["Provider:SessionToken"] = "alias-token" });
+
+ Assert.Equal("alias-token", options.Token);
+ Assert.Equal("alias-token", provider.GetValue("Token", "SessionToken"));
+ }
+
+ [Fact]
+ public void Create_ProfileName_BindsProfileName()
+ {
+ var (_, provider, options) = Bind(new() { ["Provider:ProfileName"] = "direct-profile" });
+
+ Assert.Equal("direct-profile", options.ProfileName);
+ Assert.Equal("direct-profile", provider.GetValue("ProfileName", "Profile"));
+ }
+
+ [Fact]
+ public void Create_ProfileAlias_BindsProfileName()
+ {
+ var (_, provider, options) = Bind(new() { ["Provider:Profile"] = "alias-profile" });
+
+ Assert.Equal("alias-profile", options.ProfileName);
+ Assert.Equal("alias-profile", provider.GetValue("ProfileName", "Profile"));
+ }
+
+ [Fact]
+ public void Create_DirectValuesOverrideStructuredAndConnectionStringValues()
+ {
+ var (_, provider, options) = Bind(new()
+ {
+ ["Provider:ServiceKey"] = "orders",
+ ["Provider:ConnectionProperties:AccessKey"] = "connection-properties-access",
+ ["Provider:Resource:SecretKey"] = "resource-secret",
+ ["Provider:AWS:Token"] = "aws-token",
+ ["Provider:ConnectionProperties:ProfileName"] = "connection-properties-profile",
+ ["Provider:AccessKey"] = "direct-access",
+ ["Provider:SecretKey"] = "direct-secret",
+ ["Provider:Token"] = "direct-token",
+ ["Provider:ProfileName"] = "direct-profile",
+ ["Provider:Service"] = "direct-region",
+ ["Provider:TableName"] = "direct-table",
+ ["AWS:Resources:orders:AccessKey"] = "resource-reference-access",
+ ["AWS:Resources:orders:SecretKey"] = "resource-reference-secret",
+ ["AWS:Resources:orders:Service"] = "resource-reference-region",
+ ["ConnectionStrings:orders"] =
+ "AccessKey=connection-access;SecretKey=connection-secret;Token=connection-token;" +
+ "ProfileName=connection-profile;Service=connection-region;TableName=connection-table",
+ });
+
+ Assert.Equal("direct-access", options.AccessKey);
+ Assert.Equal("direct-secret", options.SecretKey);
+ Assert.Equal("direct-token", options.Token);
+ Assert.Equal("direct-profile", options.ProfileName);
+ Assert.Equal("direct-region", options.Service);
+ Assert.Equal("direct-table", provider.GetValue("TableName"));
+ }
+
+ [Fact]
+ public void Create_ConnectionPropertiesShape_BindsValues()
+ => AssertProviderLocalShape("ConnectionProperties");
+
+ [Theory]
+ [InlineData("Provider:", "Provider:ConnectionProperties:")]
+ [InlineData("Provider:ConnectionProperties:", "Provider:Resource:")]
+ [InlineData("Provider:Resource:", "Provider:AWS:")]
+ [InlineData("Provider:AWS:", "AWS:Resources:orders-table:")]
+ [InlineData("AWS:Resources:orders-table:", "ORDERS_TABLE_")]
+ [InlineData("ORDERS_TABLE_", null)]
+ public void Create_AliasesOverrideLowerPrioritySources(string prefix, string? lowerPriorityPrefix)
+ {
+ var values = new Dictionary
+ {
+ ["Provider:ServiceKey"] = "orders-table",
+ ["ConnectionStrings:orders-table"] = "Service=connection-region;Token=connection-token;ProfileName=connection-profile",
+ [$"{prefix}Region"] = "preferred-region",
+ [$"{prefix}SessionToken"] = "preferred-token",
+ [$"{prefix}Profile"] = "preferred-profile",
+ };
+ if (lowerPriorityPrefix is not null)
+ {
+ values[$"{lowerPriorityPrefix}Service"] = "lower-priority-region";
+ values[$"{lowerPriorityPrefix}Token"] = "lower-priority-token";
+ values[$"{lowerPriorityPrefix}ProfileName"] = "lower-priority-profile";
+ }
+
+ var (_, _, options) = Bind(values);
+
+ Assert.Equal("preferred-region", options.Service);
+ Assert.Equal("preferred-token", options.Token);
+ Assert.Equal("preferred-profile", options.ProfileName);
+ }
+
+ [Theory]
+ [InlineData("ServiceURL")]
+ [InlineData("Endpoint")]
+ public void Create_DirectEndpointAliasOverridesConnectionStringRegion(string alias)
+ {
+ var (_, _, options) = Bind(new()
+ {
+ [$"Provider:{alias}"] = "http://localhost:8000",
+ ["Provider:ConnectionString"] = "Region=us-west-2",
+ });
+
+ Assert.Equal("http://localhost:8000", options.Service);
+ }
+
+ [Fact]
+ public void Create_CanonicalNamesOverrideAliasesWithinSameSource()
+ {
+ var (_, _, options) = Bind(new()
+ {
+ ["Provider:Service"] = "canonical-region",
+ ["Provider:Region"] = "alias-region",
+ ["Provider:Token"] = "canonical-token",
+ ["Provider:SessionToken"] = "alias-token",
+ ["Provider:ProfileName"] = "canonical-profile",
+ ["Provider:Profile"] = "alias-profile",
+ });
+
+ Assert.Equal("canonical-region", options.Service);
+ Assert.Equal("canonical-token", options.Token);
+ Assert.Equal("canonical-profile", options.ProfileName);
+ }
+
+ [Fact]
+ public void Create_ResourceShape_BindsValues()
+ => AssertProviderLocalShape("Resource");
+
+ [Fact]
+ public void Create_ProviderLocalAWSShape_BindsValues()
+ => AssertProviderLocalShape("AWS");
+
+ [Fact]
+ public void Create_AWSResourcesReference_BindsConnectionProperties()
+ {
+ var (_, provider, options) = Bind(new()
+ {
+ ["Provider:ServiceKey"] = "orders-table",
+ ["AWS:Resources:orders-table:AccessKey"] = "structured-access",
+ ["AWS:Resources:orders-table:SecretKey"] = "structured-secret",
+ ["AWS:Resources:orders-table:Token"] = "structured-token",
+ ["AWS:Resources:orders-table:ProfileName"] = "structured-profile",
+ ["AWS:Resources:orders-table:Service"] = "structured-region",
+ ["AWS:Resources:orders-table:TableName"] = "structured-table",
+ ["AWS:Resources:orders-table:ServiceId"] = "structured-service-id",
+ ["ORDERS_TABLE_ACCESSKEY"] = "encoded-access",
+ ["ORDERS_TABLE_TABLENAME"] = "encoded-table",
+ ["AWS_ENDPOINT_URL_DYNAMODB"] = "https://fallback.invalid",
+ });
+
+ Assert.Equal("structured-access", options.AccessKey);
+ Assert.Equal("structured-secret", options.SecretKey);
+ Assert.Equal("structured-token", options.Token);
+ Assert.Equal("structured-profile", options.ProfileName);
+ Assert.Equal("structured-region", options.Service);
+ Assert.Equal("structured-table", provider.GetValue("TableName"));
+ Assert.Equal("structured-service-id", provider.GetValue("ServiceId"));
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Create_MissingNamedConnection_IdentifiesReferenceAndSection(string? connectionString)
+ {
+ var exception = Assert.Throws(() => Bind(new()
+ {
+ ["Provider:ConnectionName"] = "orders",
+ ["ConnectionStrings:orders"] = connectionString,
+ ["AWS:Region"] = "fallback-region",
+ ["AWS:Resources:orders:Service"] = "resource-region",
+ }));
+
+ Assert.Contains("Provider", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("ConnectionName", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("orders", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void Create_DirectConnectionStringOverridesReferencedResourceValues()
+ {
+ var (_, provider, options) = Bind(new()
+ {
+ ["Provider:ServiceKey"] = "orders",
+ ["Provider:ConnectionString"] =
+ "Endpoint=https://explicit.example;AccessKey=explicit-access;SecretKey=explicit-secret;" +
+ "SessionToken=explicit-token;TableName=explicit-table",
+ ["AWS:Resources:orders:Service"] = "resource-region",
+ ["AWS:Resources:orders:AccessKey"] = "resource-access",
+ ["AWS:Resources:orders:SecretKey"] = "resource-secret",
+ ["AWS:Resources:orders:Token"] = "resource-token",
+ ["AWS:Resources:orders:TableName"] = "resource-table",
+ ["AWS:Resources:orders:ServiceId"] = "resource-service-id",
+ ["ORDERS_SERVICE"] = "encoded-region",
+ ["ConnectionStrings:orders"] = "Service=named-region",
+ });
+
+ Assert.Equal("https://explicit.example", options.Service);
+ Assert.Equal("explicit-access", options.AccessKey);
+ Assert.Equal("explicit-secret", options.SecretKey);
+ Assert.Equal("explicit-token", options.Token);
+ Assert.Equal("explicit-table", provider.GetValue("TableName"));
+ Assert.Equal("resource-service-id", provider.GetValue("ServiceId"));
+ }
+
+ [Fact]
+ public void Create_ExplicitConnectionStringOverridesMissingNamedConnection()
+ {
+ var (_, _, options) = Bind(new()
+ {
+ ["Provider:ConnectionName"] = "missing",
+ ["Provider:ConnectionString"] = "Service=explicit-region",
+ });
+
+ Assert.Equal("explicit-region", options.Service);
+ }
+
+ [Fact]
+ public void Create_NamedConnectionRemainsFallbackForResourceValues()
+ {
+ var (_, provider, options) = Bind(new()
+ {
+ ["Provider:ConnectionName"] = "orders",
+ ["AWS:Resources:orders:Service"] = "resource-region",
+ ["ConnectionStrings:orders"] = "Service=named-region;TableName=named-table",
+ });
+
+ Assert.Equal("resource-region", options.Service);
+ Assert.Equal("named-table", provider.GetValue("TableName"));
+ }
+
+ [Theory]
+ [InlineData("provider-region", "https://endpoint.invalid", "aws-section", "aws-region", "aws-default", "provider-region")]
+ [InlineData(null, "https://endpoint.invalid", "aws-section", "aws-region", "aws-default", "https://endpoint.invalid")]
+ [InlineData(null, null, "aws-section", "aws-region", "aws-default", "aws-section")]
+ [InlineData(null, null, null, "aws-region", "aws-default", "aws-region")]
+ [InlineData(null, null, null, null, "aws-default", "aws-default")]
+ public void Create_ServiceFallback_UsesExpectedPrecedence(
+ string? providerService,
+ string? endpoint,
+ string? awsSectionRegion,
+ string? awsRegion,
+ string? awsDefaultRegion,
+ string expected)
+ {
+ var values = new Dictionary
+ {
+ ["Provider:Service"] = providerService,
+ ["AWS_ENDPOINT_URL_DYNAMODB"] = endpoint,
+ ["AWS:Region"] = awsSectionRegion,
+ ["AWS_REGION"] = awsRegion,
+ ["AWS_DEFAULT_REGION"] = awsDefaultRegion,
+ };
+
+ var (_, _, options) = Bind(values);
+
+ Assert.Equal(expected, options.Service);
+ }
+
+ [Fact]
+ public void Create_ConnectionStringAliases_BindAllValues()
+ {
+ var (_, provider, options) = Bind(new()
+ {
+ ["Provider:ConnectionString"] =
+ "AccessKey=connection-access;SecretKey=connection-secret;SessionToken=connection-token;" +
+ "Profile=connection-profile;Endpoint=https://localhost:8123;TableName=connection-table",
+ });
+
+ Assert.Equal("connection-access", options.AccessKey);
+ Assert.Equal("connection-secret", options.SecretKey);
+ Assert.Equal("connection-token", options.Token);
+ Assert.Equal("connection-profile", options.ProfileName);
+ Assert.Equal("https://localhost:8123", options.Service);
+ Assert.Equal("connection-table", provider.GetValue("TableName"));
+ }
+
+ [Fact]
+ public void Create_SemicolonDelimitedConnectionString_ParsesEndpointAndCredentials()
+ {
+ var (_, provider, options) = Bind(new()
+ {
+ ["Provider:ConnectionString"] =
+ "Service=https://localhost:8123;AccessKey=connection-access;" +
+ "SecretKey=connection-secret;TableName=connection-table",
+ });
+
+ Assert.Equal("https://localhost:8123", options.Service);
+ Assert.Equal("connection-access", options.AccessKey);
+ Assert.Equal("connection-secret", options.SecretKey);
+ Assert.Equal("connection-table", provider.GetValue("TableName"));
+ }
+
+ [Fact]
+ public void Create_MalformedConnectionString_FailsAsMissingOrIncompatibleConfiguration()
+ {
+ var (_, _, options) = Bind(new()
+ {
+ ["Provider:ConnectionString"] = "not-a-pair;also-not-a-pair",
+ });
+
+ var exception = Assert.Throws(
+ () => ClusteringProviderConfiguration.ValidateClientOptions(options, "test provider"));
+
+ Assert.Contains(nameof(options.Service), exception.Message);
+ Assert.Contains("test provider", exception.Message);
+ }
+
+ [Fact]
+ public void Create_IncompleteConnectionString_FailsAsMissingConfiguration()
+ {
+ var (_, _, options) = Bind(new()
+ {
+ ["Provider:ConnectionString"] = "Service=us-west-2;AccessKey=unpaired-access",
+ });
+
+ var exception = Assert.Throws(
+ () => ClusteringProviderConfiguration.ValidateClientOptions(options, "test provider"));
+
+ Assert.Contains(nameof(options.AccessKey), exception.Message);
+ Assert.Contains(nameof(options.SecretKey), exception.Message);
+ Assert.DoesNotContain("unpaired-access", exception.Message);
+ }
+
+ [Fact]
+ public void ClusteringBuilder_MapsTableName()
+ {
+ using var host = BuildSiloProviderHost(
+ new()
+ {
+ ["Provider:Service"] = "us-west-2",
+ ["Provider:TableName"] = "clustering-table",
+ },
+ (context, silo) => new DynamoDBClusteringProviderBuilder().Configure(
+ silo,
+ name: null,
+ context.Configuration.GetSection("Provider")));
+
+ var options = host.Services.GetRequiredService>().Value;
+
+ Assert.Equal("us-west-2", options.Service);
+ Assert.Equal("clustering-table", options.TableName);
+ }
+
+ [Fact]
+ public void GatewayBuilder_MapsTableName()
+ {
+ using var host = BuildClientProviderHost(
+ new()
+ {
+ ["Provider:Service"] = "us-east-2",
+ ["Provider:TableName"] = "gateway-table",
+ },
+ (context, client) => new DynamoDBClusteringProviderBuilder().Configure(
+ client,
+ name: null,
+ context.Configuration.GetSection("Provider")));
+
+ var options = host.Services.GetRequiredService>().Value;
+
+ Assert.Equal("us-east-2", options.Service);
+ Assert.Equal("gateway-table", options.TableName);
+ }
+
+ [Fact]
+ public void RemindersBuilder_MapsTableName()
+ {
+ using var host = BuildSiloProviderHost(
+ new()
+ {
+ ["Provider:Service"] = "eu-west-1",
+ ["Provider:TableName"] = "reminder-table",
+ },
+ (context, silo) => new DynamoDBRemindersProviderBuilder().Configure(
+ silo,
+ name: null,
+ context.Configuration.GetSection("Provider")));
+
+ var options = host.Services.GetRequiredService>().Value;
+
+ Assert.Equal("eu-west-1", options.Service);
+ Assert.Equal("reminder-table", options.TableName);
+ }
+
+ [Fact]
+ public void GrainStorageBuilder_MapsDirectOnlySettingsAndSerializer()
+ {
+ const string providerName = "phase-one-storage";
+ const string serializerKey = "phase-one-serializer";
+ var serializer = new StubGrainStorageSerializer();
+ using var host = BuildSiloProviderHost(
+ new()
+ {
+ ["Provider:Service"] = "ap-southeast-2",
+ ["Provider:ServiceId"] = "phase-one-service",
+ ["Provider:TableName"] = "grain-state-table",
+ ["Provider:ReadCapacityUnits"] = "17",
+ ["Provider:WriteCapacityUnits"] = "9",
+ ["Provider:UseProvisionedThroughput"] = "false",
+ ["Provider:CreateIfNotExists"] = "false",
+ ["Provider:UpdateIfExists"] = "false",
+ ["Provider:DeleteStateOnClear"] = "true",
+ ["Provider:TimeToLive"] = "01:02:03",
+ ["Provider:SerializerKey"] = serializerKey,
+ },
+ (context, silo) =>
+ {
+ silo.ConfigureServices(services =>
+ services.AddKeyedSingleton(serializerKey, serializer));
+ new DynamoDBGrainStorageProviderBuilder().Configure(
+ silo,
+ providerName,
+ context.Configuration.GetSection("Provider"));
+ });
+
+ var options = host.Services
+ .GetRequiredService>()
+ .Get(providerName);
+
+ Assert.Equal("ap-southeast-2", options.Service);
+ Assert.Equal("phase-one-service", options.ServiceId);
+ Assert.Equal("grain-state-table", options.TableName);
+ Assert.Equal(17, options.ReadCapacityUnits);
+ Assert.Equal(9, options.WriteCapacityUnits);
+ Assert.False(options.UseProvisionedThroughput);
+ Assert.False(options.CreateIfNotExists);
+ Assert.False(options.UpdateIfExists);
+ Assert.True(options.DeleteStateOnClear);
+ Assert.Equal(TimeSpan.FromHours(1) + TimeSpan.FromMinutes(2) + TimeSpan.FromSeconds(3), options.TimeToLive);
+ Assert.Same(serializer, options.GrainStorageSerializer);
+ }
+
+ [Fact]
+ public void AspireGeneratedConfiguration_ActivatesClusteringStorageAndReminders()
+ {
+ const string serviceKey = "dynamodb";
+ const string serviceUrl = "http://localhost:8000";
+ using var host = new HostBuilder()
+ .ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(
+ new Dictionary
+ {
+ ["Orleans:Clustering:ProviderType"] = "DynamoDB",
+ ["Orleans:Clustering:ServiceKey"] = serviceKey,
+ ["Orleans:GrainStorage:Default:ProviderType"] = "DynamoDB",
+ ["Orleans:GrainStorage:Default:ServiceKey"] = serviceKey,
+ ["Orleans:Reminders:ProviderType"] = "DynamoDB",
+ ["Orleans:Reminders:ServiceKey"] = serviceKey,
+ ["AWS_ENDPOINT_URL_DYNAMODB"] = serviceUrl,
+ }))
+ .UseOrleans(_ => { })
+ .Build();
+
+ var clustering = host.Services.GetRequiredService>().Value;
+ var storage = host.Services.GetRequiredService>().Get("Default");
+ var reminders = host.Services.GetRequiredService>().Value;
+
+ Assert.Equal(serviceUrl, clustering.Service);
+ Assert.Equal("OrleansSilos", clustering.TableName);
+ Assert.Equal(serviceUrl, storage.Service);
+ Assert.Equal("OrleansGrainState", storage.TableName);
+ Assert.Equal(serviceUrl, reminders.Service);
+ Assert.Equal("OrleansReminders", reminders.TableName);
+ }
+
+ private static void AssertProviderLocalShape(string shape)
+ {
+ var (_, provider, options) = Bind(new()
+ {
+ [$"Provider:{shape}:AccessKey"] = $"{shape}-access",
+ [$"Provider:{shape}:SecretKey"] = $"{shape}-secret",
+ [$"Provider:{shape}:Token"] = $"{shape}-token",
+ [$"Provider:{shape}:ProfileName"] = $"{shape}-profile",
+ [$"Provider:{shape}:Service"] = $"{shape}-region",
+ [$"Provider:{shape}:TableName"] = $"{shape}-table",
+ [$"Provider:{shape}:ServiceId"] = $"{shape}-service-id",
+ });
+
+ Assert.Equal($"{shape}-access", options.AccessKey);
+ Assert.Equal($"{shape}-secret", options.SecretKey);
+ Assert.Equal($"{shape}-token", options.Token);
+ Assert.Equal($"{shape}-profile", options.ProfileName);
+ Assert.Equal($"{shape}-region", options.Service);
+ Assert.Equal($"{shape}-table", provider.GetValue("TableName"));
+ Assert.Equal($"{shape}-service-id", provider.GetValue("ServiceId"));
+ }
+
+ private static (
+ IConfigurationRoot Configuration,
+ ClusteringProviderConfiguration Provider,
+ DynamoDBClusteringOptions Options) Bind(Dictionary values)
+ {
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(values)
+ .Build();
+ var provider = ClusteringProviderConfiguration.Create(
+ configuration.GetSection("Provider"),
+ configuration);
+ var options = new DynamoDBClusteringOptions();
+ provider.ConfigureClientOptions(options);
+ return (configuration, provider, options);
+ }
+
+ private static IHost BuildSiloProviderHost(
+ Dictionary values,
+ Action configure)
+ => new HostBuilder()
+ .ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(values))
+ .UseOrleans(configure)
+ .Build();
+
+ private static IHost BuildClientProviderHost(
+ Dictionary values,
+ Action configure)
+ => new HostBuilder()
+ .ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(values))
+ .UseOrleansClient(configure)
+ .Build();
+
+ private sealed class StubGrainStorageSerializer : IGrainStorageSerializer
+ {
+ public BinaryData Serialize(T? input) => throw new NotSupportedException();
+
+ public T? Deserialize(BinaryData input) => throw new NotSupportedException();
+ }
+}
+
+[TestSuite("BVT")]
+[TestProvider("DynamoDB")]
+[TestArea("Storage")]
+[TestCategory("AWS"), TestCategory("DynamoDB"), TestCategory("BVT")]
+public sealed class DynamoDBOptionsValidationTests
+{
+ [Fact]
+ public void RegisteredValidator_MissingService_ThrowsWithServiceSettingName()
+ {
+ var exception = AssertClusteringValidationFailure(_ => { });
+
+ Assert.Contains(nameof(DynamoDBClusteringOptions.Service), exception.Message);
+ Assert.Contains("DynamoDB clustering", exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_MismatchedServiceKeyAndConnectionName_ThrowsWithSectionAndBothSettingNames()
+ {
+ const string sectionPath = "Provider:Clustering";
+ using var host = new HostBuilder()
+ .ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(new Dictionary
+ {
+ [$"{sectionPath}:ServiceKey"] = "service-key",
+ [$"{sectionPath}:ConnectionName"] = "different-connection",
+ }))
+ .UseOrleans((context, silo) => new DynamoDBClusteringProviderBuilder().Configure(
+ silo,
+ name: null,
+ context.Configuration.GetSection(sectionPath)))
+ .Build();
+
+ var exception = Assert.Throws(
+ () => _ = host.Services.GetRequiredService>().Value);
+
+ Assert.Contains(sectionPath, exception.Message, StringComparison.Ordinal);
+ Assert.Contains("ServiceKey", exception.Message);
+ Assert.Contains("ConnectionName", exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_AccessKeyWithoutSecretKey_Throws()
+ {
+ var exception = AssertClusteringValidationFailure(options =>
+ {
+ options.Service = "us-west-2";
+ options.AccessKey = "unpaired-access";
+ });
+
+ Assert.Contains(nameof(DynamoDBClusteringOptions.AccessKey), exception.Message);
+ Assert.Contains(nameof(DynamoDBClusteringOptions.SecretKey), exception.Message);
+ Assert.DoesNotContain("unpaired-access", exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_SecretKeyWithoutAccessKey_Throws()
+ {
+ var exception = AssertGatewayValidationFailure(options =>
+ {
+ options.Service = "us-west-2";
+ options.SecretKey = "unpaired-secret";
+ });
+
+ Assert.Contains(nameof(DynamoDBGatewayOptions.AccessKey), exception.Message);
+ Assert.Contains(nameof(DynamoDBGatewayOptions.SecretKey), exception.Message);
+ Assert.DoesNotContain("unpaired-secret", exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_TokenWithoutExplicitPair_Throws()
+ {
+ var exception = AssertReminderValidationFailure(options =>
+ {
+ options.Service = "us-west-2";
+ options.Token = "unpaired-token";
+ });
+
+ Assert.Contains(nameof(DynamoDBReminderStorageOptions.Token), exception.Message);
+ Assert.Contains("explicit credentials", exception.Message);
+ Assert.DoesNotContain("unpaired-token", exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_ExplicitPairAndProfile_ThrowsWithCredentialSettingNames()
+ {
+ var exception = AssertClusteringValidationFailure(options =>
+ {
+ options.Service = "us-west-2";
+ options.AccessKey = "explicit-access";
+ options.SecretKey = "explicit-secret";
+ options.ProfileName = "ambiguous-profile";
+ });
+
+ Assert.Contains("Explicit credentials", exception.Message);
+ Assert.Contains(nameof(DynamoDBClusteringOptions.ProfileName), exception.Message);
+ Assert.DoesNotContain("explicit-secret", exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_BlankTableName_Throws()
+ {
+ var exception = AssertStorageValidationFailure(options =>
+ {
+ options.Service = "us-west-2";
+ options.TableName = " ";
+ });
+
+ Assert.Contains(nameof(DynamoDBStorageOptions.TableName), exception.Message);
+ Assert.Contains("validation-storage", exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_ProvisionedReadCapacityIsNonPositive_Throws()
+ {
+ var exception = AssertClusteringValidationFailure(options =>
+ {
+ options.Service = "us-west-2";
+ options.ReadCapacityUnits = 0;
+ options.WriteCapacityUnits = 5;
+ });
+
+ Assert.Contains(nameof(DynamoDBClusteringOptions.ReadCapacityUnits), exception.Message);
+ Assert.Contains(nameof(DynamoDBClusteringOptions.WriteCapacityUnits), exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_ProvisionedWriteCapacityIsNonPositive_Throws()
+ {
+ var exception = AssertGatewayValidationFailure(options =>
+ {
+ options.Service = "us-west-2";
+ options.ReadCapacityUnits = 10;
+ options.WriteCapacityUnits = -1;
+ });
+
+ Assert.Contains(nameof(DynamoDBGatewayOptions.ReadCapacityUnits), exception.Message);
+ Assert.Contains(nameof(DynamoDBGatewayOptions.WriteCapacityUnits), exception.Message);
+ }
+
+ [Fact]
+ public void RegisteredValidator_OnDemandCapacityMayBeZero()
+ {
+ using var host = BuildReminderValidationHost(options =>
+ {
+ options.Service = "us-west-2";
+ options.UseProvisionedThroughput = false;
+ options.ReadCapacityUnits = 0;
+ options.WriteCapacityUnits = 0;
+ });
+ var validator = GetValidator(host, "DynamoDBReminderStorageOptionsValidator");
+
+ validator.ValidateConfiguration();
+
+ var options = host.Services.GetRequiredService>().Value;
+ Assert.False(options.UseProvisionedThroughput);
+ Assert.Equal(0, options.ReadCapacityUnits);
+ Assert.Equal(0, options.WriteCapacityUnits);
+ }
+
+ private static OrleansConfigurationException AssertClusteringValidationFailure(
+ Action configure)
+ {
+ using var host = new HostBuilder()
+ .UseOrleans(silo => silo.UseDynamoDBClustering((DynamoDBClusteringOptions options) => configure(options)))
+ .Build();
+ var validator = GetValidator(host, "DynamoDBClusteringOptionsValidator");
+ return Assert.Throws(validator.ValidateConfiguration);
+ }
+
+ private static OrleansConfigurationException AssertGatewayValidationFailure(
+ Action configure)
+ {
+ using var host = new HostBuilder()
+ .UseOrleansClient(client => client.UseDynamoDBClustering((DynamoDBGatewayOptions options) => configure(options)))
+ .Build();
+ var validator = GetValidator(host, "DynamoDBGatewayOptionsValidator");
+ return Assert.Throws(validator.ValidateConfiguration);
+ }
+
+ private static OrleansConfigurationException AssertStorageValidationFailure(
+ Action configure)
+ {
+ using var host = new HostBuilder()
+ .UseOrleans(silo => silo.AddDynamoDBGrainStorage(
+ "validation-storage",
+ (DynamoDBStorageOptions options) =>
+ {
+ options.GrainStorageSerializer = ValidationGrainStorageSerializer.Instance;
+ configure(options);
+ }))
+ .Build();
+ var validator = GetValidator(host, nameof(DynamoDBGrainStorageOptionsValidator));
+ return Assert.Throws(validator.ValidateConfiguration);
+ }
+
+ private static OrleansConfigurationException AssertReminderValidationFailure(
+ Action configure)
+ {
+ using var host = BuildReminderValidationHost(configure);
+ var validator = GetValidator(host, "DynamoDBReminderStorageOptionsValidator");
+ return Assert.Throws(validator.ValidateConfiguration);
+ }
+
+ private static IHost BuildReminderValidationHost(Action configure)
+ => new HostBuilder()
+ .UseOrleans(silo => silo.ConfigureServices(services => services.UseDynamoDBReminderService(configure)))
+ .Build();
+
+ private static IConfigurationValidator GetValidator(IHost host, string typeName)
+ => host.Services
+ .GetServices()
+ .Single(validator => validator.GetType().Name == typeName);
+
+ private sealed class ValidationGrainStorageSerializer : IGrainStorageSerializer
+ {
+ public static ValidationGrainStorageSerializer Instance { get; } = new();
+
+ public BinaryData Serialize(T? input) => throw new NotSupportedException();
+
+ public T? Deserialize(BinaryData input) => throw new NotSupportedException();
+ }
+}
+
+[TestSuite("BVT")]
+[TestProvider("DynamoDB")]
+[TestArea("Storage")]
+[TestCategory("AWS"), TestCategory("DynamoDB"), TestCategory("BVT")]
+public sealed class DynamoDBProviderRegistrationTests
+{
+ [Fact]
+ public void RegisterProviderAttributes_ContainDynamoDBClusteringSiloRegistration()
+ {
+ var registrations = GetRegistrations(typeof(DynamoDBClusteringOptions).Assembly);
+
+ Assert.Equal(2, registrations.Length);
+ AssertRegistration(
+ registrations.Single(registration => registration.Target == "Silo"),
+ kind: "Clustering",
+ target: "Silo",
+ typeof(DynamoDBClusteringProviderBuilder));
+ }
+
+ [Fact]
+ public void RegisterProviderAttributes_ContainDynamoDBClusteringClientRegistration()
+ {
+ var registrations = GetRegistrations(typeof(DynamoDBGatewayOptions).Assembly);
+
+ Assert.Equal(2, registrations.Length);
+ AssertRegistration(
+ registrations.Single(registration => registration.Target == "Client"),
+ kind: "Clustering",
+ target: "Client",
+ typeof(DynamoDBClusteringProviderBuilder));
+ }
+
+ [Fact]
+ public void RegisterProviderAttributes_ContainDynamoDBGrainStorageRegistration()
+ {
+ var registration = Assert.Single(GetRegistrations(typeof(DynamoDBStorageOptions).Assembly));
+
+ AssertRegistration(
+ registration,
+ kind: "GrainStorage",
+ target: "Silo",
+ typeof(DynamoDBGrainStorageProviderBuilder));
+ }
+
+ [Fact]
+ public void RegisterProviderAttributes_ContainDynamoDBReminderRegistration()
+ {
+ var registration = Assert.Single(GetRegistrations(typeof(DynamoDBReminderStorageOptions).Assembly));
+
+ AssertRegistration(
+ registration,
+ kind: "Reminders",
+ target: "Silo",
+ typeof(DynamoDBRemindersProviderBuilder));
+ }
+
+ private static RegisterProviderAttribute[] GetRegistrations(Assembly assembly)
+ => assembly.GetCustomAttributes().ToArray();
+
+ private static void AssertRegistration(
+ RegisterProviderAttribute registration,
+ string kind,
+ string target,
+ Type builderType)
+ {
+ Assert.Equal("DynamoDB", registration.Name);
+ Assert.Equal(kind, registration.Kind);
+ Assert.Equal(target, registration.Target);
+ Assert.Equal(builderType, registration.Type);
+ }
+}
+
+[TestSuite("BVT")]
+[TestProvider("DynamoDB")]
+[TestArea("Storage")]
+[TestCategory("AWS"), TestCategory("DynamoDB"), TestCategory("BVT")]
+public sealed class DynamoDBProviderBuilderTests
+{
+ [Fact]
+ public void ClusteringSiloBuilder_Configure_RegistersMembershipAndBindsOptions()
+ {
+ using var host = BuildSiloHost(
+ new()
+ {
+ ["Provider:Service"] = "us-west-2",
+ ["Provider:TableName"] = "phase-two-silos",
+ ["Provider:ReadCapacityUnits"] = "13",
+ ["Provider:WriteCapacityUnits"] = "7",
+ ["Provider:UseProvisionedThroughput"] = "false",
+ ["Provider:CreateIfNotExists"] = "false",
+ ["Provider:UpdateIfExists"] = "false",
+ },
+ (context, silo) => new DynamoDBClusteringProviderBuilder().Configure(
+ silo,
+ name: null,
+ context.Configuration.GetSection("Provider")));
+
+ var membership = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>().Value;
+ var validator = GetValidator(host, "DynamoDBClusteringOptionsValidator");
+
+ Assert.Equal("DynamoDBMembershipTable", membership.GetType().Name);
+ Assert.Equal("us-west-2", options.Service);
+ Assert.Equal("phase-two-silos", options.TableName);
+ Assert.Equal(13, options.ReadCapacityUnits);
+ Assert.Equal(7, options.WriteCapacityUnits);
+ Assert.False(options.UseProvisionedThroughput);
+ Assert.False(options.CreateIfNotExists);
+ Assert.False(options.UpdateIfExists);
+ validator.ValidateConfiguration();
+ }
+
+ [Fact]
+ public void ClusteringClientBuilder_Configure_RegistersGatewayAndBindsOptions()
+ {
+ using var host = BuildClientHost(
+ new()
+ {
+ ["Provider:Service"] = "eu-central-1",
+ ["Provider:TableName"] = "phase-two-gateways",
+ ["Provider:ReadCapacityUnits"] = "19",
+ ["Provider:WriteCapacityUnits"] = "11",
+ ["Provider:UseProvisionedThroughput"] = "false",
+ ["Provider:CreateIfNotExists"] = "false",
+ ["Provider:UpdateIfExists"] = "false",
+ },
+ (context, client) => new DynamoDBClusteringProviderBuilder().Configure(
+ client,
+ name: null,
+ context.Configuration.GetSection("Provider")));
+
+ var gateway = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>().Value;
+ var validator = GetValidator(host, "DynamoDBGatewayOptionsValidator");
+
+ Assert.Equal("DynamoDBGatewayListProvider", gateway.GetType().Name);
+ Assert.Equal("eu-central-1", options.Service);
+ Assert.Equal("phase-two-gateways", options.TableName);
+ Assert.Equal(19, options.ReadCapacityUnits);
+ Assert.Equal(11, options.WriteCapacityUnits);
+ Assert.False(options.UseProvisionedThroughput);
+ Assert.False(options.CreateIfNotExists);
+ Assert.False(options.UpdateIfExists);
+ validator.ValidateConfiguration();
+ }
+
+ [Fact]
+ public void GrainStorageBuilder_Configure_RegistersNamedStorageAndBindsOptions()
+ {
+ const string providerName = "phase-two-storage";
+ const string serializerKey = "phase-two-serializer";
+ var serializer = new PhaseTwoGrainStorageSerializer();
+ using var host = BuildSiloHost(
+ new()
+ {
+ ["Provider:Service"] = "ap-northeast-1",
+ ["Provider:ServiceId"] = "phase-two-service",
+ ["Provider:TableName"] = "phase-two-grain-state",
+ ["Provider:ReadCapacityUnits"] = "23",
+ ["Provider:WriteCapacityUnits"] = "17",
+ ["Provider:UseProvisionedThroughput"] = "false",
+ ["Provider:CreateIfNotExists"] = "false",
+ ["Provider:UpdateIfExists"] = "false",
+ ["Provider:DeleteStateOnClear"] = "true",
+ ["Provider:TimeToLive"] = "02:03:04",
+ ["Provider:SerializerKey"] = serializerKey,
+ },
+ (context, silo) =>
+ {
+ silo.ConfigureServices(services =>
+ services.AddKeyedSingleton(serializerKey, serializer));
+ new DynamoDBGrainStorageProviderBuilder().Configure(
+ silo,
+ providerName,
+ context.Configuration.GetSection("Provider"));
+ });
+
+ var storage = host.Services.GetRequiredKeyedService(providerName);
+ var options = host.Services.GetRequiredService>().Get(providerName);
+ var validator = GetValidator(host, nameof(DynamoDBGrainStorageOptionsValidator));
+
+ Assert.IsType(storage);
+ Assert.Equal("ap-northeast-1", options.Service);
+ Assert.Equal("phase-two-service", options.ServiceId);
+ Assert.Equal("phase-two-grain-state", options.TableName);
+ Assert.Equal(23, options.ReadCapacityUnits);
+ Assert.Equal(17, options.WriteCapacityUnits);
+ Assert.False(options.UseProvisionedThroughput);
+ Assert.False(options.CreateIfNotExists);
+ Assert.False(options.UpdateIfExists);
+ Assert.True(options.DeleteStateOnClear);
+ Assert.Equal(new TimeSpan(2, 3, 4), options.TimeToLive);
+ Assert.Same(serializer, options.GrainStorageSerializer);
+ validator.ValidateConfiguration();
+ }
+
+ [Fact]
+ public void RemindersBuilder_Configure_RegistersReminderTableAndBindsOptions()
+ {
+ using var host = BuildSiloHost(
+ new()
+ {
+ ["Provider:Service"] = "ca-central-1",
+ ["Provider:TableName"] = "phase-two-reminders",
+ ["Provider:ReadCapacityUnits"] = "29",
+ ["Provider:WriteCapacityUnits"] = "21",
+ ["Provider:UseProvisionedThroughput"] = "false",
+ ["Provider:CreateIfNotExists"] = "false",
+ ["Provider:UpdateIfExists"] = "false",
+ },
+ (context, silo) => new DynamoDBRemindersProviderBuilder().Configure(
+ silo,
+ name: null,
+ context.Configuration.GetSection("Provider")));
+
+ var reminderTable = host.Services.GetRequiredService();
+ var options = host.Services.GetRequiredService>().Value;
+ var validator = GetValidator(host, "DynamoDBReminderStorageOptionsValidator");
+
+ Assert.Equal("DynamoDBReminderTable", reminderTable.GetType().Name);
+ Assert.Equal("ca-central-1", options.Service);
+ Assert.Equal("phase-two-reminders", options.TableName);
+ Assert.Equal(29, options.ReadCapacityUnits);
+ Assert.Equal(21, options.WriteCapacityUnits);
+ Assert.False(options.UseProvisionedThroughput);
+ Assert.False(options.CreateIfNotExists);
+ Assert.False(options.UpdateIfExists);
+ validator.ValidateConfiguration();
+ }
+
+ [Theory]
+ [InlineData("SiloClustering")]
+ [InlineData("ClientClustering")]
+ [InlineData("Reminders")]
+ public void ProviderBuilder_InvalidLifecycleValue_ThrowsConfigurationException(string target)
+ {
+ const string invalidValue = "fales";
+ var values = new Dictionary
+ {
+ ["Provider:Service"] = "us-east-1",
+ ["Provider:TableName"] = "orleans-table",
+ ["Provider:CreateIfNotExists"] = invalidValue,
+ };
+
+ using var host = target switch
+ {
+ "SiloClustering" => BuildSiloHost(
+ values,
+ (context, silo) => new DynamoDBClusteringProviderBuilder().Configure(
+ silo,
+ name: null,
+ context.Configuration.GetSection("Provider"))),
+ "ClientClustering" => BuildClientHost(
+ values,
+ (context, client) => new DynamoDBClusteringProviderBuilder().Configure(
+ client,
+ name: null,
+ context.Configuration.GetSection("Provider"))),
+ "Reminders" => BuildSiloHost(
+ values,
+ (context, silo) => new DynamoDBRemindersProviderBuilder().Configure(
+ silo,
+ name: null,
+ context.Configuration.GetSection("Provider"))),
+ _ => throw new ArgumentOutOfRangeException(nameof(target), target, null),
+ };
+
+ var exception = Assert.Throws(() =>
+ {
+ if (target == "SiloClustering")
+ {
+ _ = host.Services.GetRequiredService>().Value;
+ }
+ else if (target == "ClientClustering")
+ {
+ _ = host.Services.GetRequiredService>().Value;
+ }
+ else
+ {
+ _ = host.Services.GetRequiredService>().Value;
+ }
+ });
+
+ Assert.Contains("Provider", exception.Message, StringComparison.Ordinal);
+ Assert.Contains(nameof(DynamoDBClusteringOptions.CreateIfNotExists), exception.Message, StringComparison.Ordinal);
+ Assert.Contains(invalidValue, exception.Message, StringComparison.Ordinal);
+ }
+
+ private static IHost BuildSiloHost(
+ Dictionary values,
+ Action configure)
+ => new HostBuilder()
+ .ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(values))
+ .UseOrleans(configure)
+ .Build();
+
+ private static IHost BuildClientHost(
+ Dictionary values,
+ Action configure)
+ => new HostBuilder()
+ .ConfigureAppConfiguration((_, configuration) => configuration.AddInMemoryCollection(values))
+ .UseOrleansClient(configure)
+ .Build();
+
+ private static IConfigurationValidator GetValidator(IHost host, string typeName)
+ => host.Services
+ .GetServices()
+ .Single(validator => validator.GetType().Name == typeName);
+
+ private sealed class PhaseTwoGrainStorageSerializer : IGrainStorageSerializer
+ {
+ public BinaryData Serialize(T? input) => throw new NotSupportedException();
+
+ public T? Deserialize(BinaryData input) => throw new NotSupportedException();
+ }
+}
diff --git a/test/Extensions/Orleans.AWS.Tests/MembershipTests/DynamoDBClusteringProviderBuilderTests.cs b/test/Extensions/Orleans.AWS.Tests/MembershipTests/DynamoDBClusteringProviderBuilderTests.cs
index e99d5e04b5f..0bc27d2f4df 100644
--- a/test/Extensions/Orleans.AWS.Tests/MembershipTests/DynamoDBClusteringProviderBuilderTests.cs
+++ b/test/Extensions/Orleans.AWS.Tests/MembershipTests/DynamoDBClusteringProviderBuilderTests.cs
@@ -68,14 +68,14 @@ private static IConfiguration CreateConfiguration(string? token, string? profile
private sealed class TestSiloBuilder(IConfiguration configuration) : ISiloBuilder
{
- public IServiceCollection Services { get; } = new ServiceCollection();
+ public IServiceCollection Services { get; } = new ServiceCollection().AddSingleton(configuration);
public IConfiguration Configuration { get; } = configuration;
}
private sealed class TestClientBuilder(IConfiguration configuration) : IClientBuilder
{
- public IServiceCollection Services { get; } = new ServiceCollection();
+ public IServiceCollection Services { get; } = new ServiceCollection().AddSingleton(configuration);
public IConfiguration Configuration { get; } = configuration;
}
diff --git a/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj b/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj
index 9a2311007cc..5b8870a56cb 100644
--- a/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj
+++ b/test/Extensions/Orleans.AWS.Tests/Orleans.AWS.Tests.csproj
@@ -1,12 +1,35 @@
+
+
$(DefineConstants);AWSUTILS_TESTS
$(TestTargetFrameworks)
true
+ false
enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Extensions/Orleans.AWS.Tests/Reminder/DynamoDBRemindersProviderBuilderTests.cs b/test/Extensions/Orleans.AWS.Tests/Reminder/DynamoDBRemindersProviderBuilderTests.cs
index 0641e23023e..4d63dbf0e5a 100644
--- a/test/Extensions/Orleans.AWS.Tests/Reminder/DynamoDBRemindersProviderBuilderTests.cs
+++ b/test/Extensions/Orleans.AWS.Tests/Reminder/DynamoDBRemindersProviderBuilderTests.cs
@@ -45,7 +45,7 @@ public void Configure_CredentialKeysBindIndependently(string? token, string? pro
private sealed class TestSiloBuilder(IConfiguration configuration) : ISiloBuilder
{
- public IServiceCollection Services { get; } = new ServiceCollection();
+ public IServiceCollection Services { get; } = new ServiceCollection().AddSingleton(configuration);
public IConfiguration Configuration { get; } = configuration;
}
diff --git a/test/Extensions/Orleans.AWS.Tests/StorageTests/DynamoDBGrainStorageProviderBuilderTests.cs b/test/Extensions/Orleans.AWS.Tests/StorageTests/DynamoDBGrainStorageProviderBuilderTests.cs
index 04c2d5284ab..447a05b6272 100644
--- a/test/Extensions/Orleans.AWS.Tests/StorageTests/DynamoDBGrainStorageProviderBuilderTests.cs
+++ b/test/Extensions/Orleans.AWS.Tests/StorageTests/DynamoDBGrainStorageProviderBuilderTests.cs
@@ -111,23 +111,16 @@ public void Configure_RegionWithoutService_UsesRegion()
[InlineData(nameof(DynamoDBStorageOptions.UpdateIfExists), "not-a-boolean")]
[InlineData(nameof(DynamoDBStorageOptions.DeleteStateOnClear), "not-a-boolean")]
[InlineData(nameof(DynamoDBStorageOptions.TimeToLive), "not-a-timespan")]
- public void Configure_InvalidTypedValue_PreservesDefault(string key, string invalidValue)
+ public void Configure_InvalidTypedValue_ThrowsConfigurationException(string key, string invalidValue)
{
var (builder, _) = ConfigureBuilder((key, invalidValue));
using var services = builder.Services.BuildServiceProvider();
- DynamoDBStorageOptions? options = null;
- var exception = Record.Exception(() => options = GetOptions(services));
+ var exception = Assert.Throws(() => GetOptions(services));
- Assert.Null(exception);
- Assert.NotNull(options);
- Assert.Equal(10, options.ReadCapacityUnits);
- Assert.Equal(5, options.WriteCapacityUnits);
- Assert.True(options.UseProvisionedThroughput);
- Assert.True(options.CreateIfNotExists);
- Assert.True(options.UpdateIfExists);
- Assert.False(options.DeleteStateOnClear);
- Assert.Null(options.TimeToLive);
+ Assert.Contains("Storage", exception.Message, StringComparison.Ordinal);
+ Assert.Contains(key, exception.Message, StringComparison.Ordinal);
+ Assert.Contains(invalidValue, exception.Message, StringComparison.Ordinal);
}
[Fact]
@@ -180,6 +173,7 @@ public void Configure_WithoutSerializerKey_UsesDefaultSerializer()
public void Configure_RegistersNamedOptionsValidatorAndKeyedStorageDescriptor()
{
var (builder, _) = ConfigureBuilder(
+ (nameof(DynamoDBStorageOptions.Service), "us-east-1"),
(nameof(DynamoDBStorageOptions.TableName), "named-table"),
(nameof(DynamoDBStorageOptions.ServiceId), "named-service"));
var storageDescriptor = Assert.Single(
@@ -307,6 +301,7 @@ private static DynamoDBStorageOptions GetOptions(IServiceProvider services)
private static DynamoDBStorageOptions CreateValidOptions() => new()
{
+ Service = "us-east-1",
TableName = "valid-table",
UseProvisionedThroughput = true,
ReadCapacityUnits = 11,
diff --git a/test/Extensions/Orleans.AdoNet.Tests/AdoNetAspireIntegrationTests.cs b/test/Extensions/Orleans.AdoNet.Tests/AdoNetAspireIntegrationTests.cs
new file mode 100644
index 00000000000..1e666553231
--- /dev/null
+++ b/test/Extensions/Orleans.AdoNet.Tests/AdoNetAspireIntegrationTests.cs
@@ -0,0 +1,228 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Orleans;
+using Aspire.Hosting.Testing;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Orleans.Configuration;
+using Orleans.GrainDirectory.AdoNet;
+using Orleans.Hosting;
+using Orleans.Runtime;
+using TestExtensions;
+
+namespace UnitTests.AdoNet;
+
+[TestCategory("AdoNet")]
+[TestSuite("BVT")]
+[TestProvider("None")]
+[TestArea("Persistence")]
+public sealed class AdoNetAspireIntegrationTests
+{
+ [Theory]
+ [InlineData(AdoNetAspireDatabase.SqlServer, "SqlServerDatabase", "Microsoft.Data.SqlClient")]
+ [InlineData(AdoNetAspireDatabase.PostgreSql, "PostgresDatabase", "Npgsql")]
+ [InlineData(AdoNetAspireDatabase.MySql, "MySqlDatabase", "MySql.Data.MySqlClient")]
+ [InlineData(AdoNetAspireDatabase.Oracle, "OracleDatabase", "Oracle.DataAccess.Client")]
+ public async Task GeneratedConfiguration_ActivatesAllSupportedCapabilities(
+ AdoNetAspireDatabase databaseType,
+ string expectedProviderType,
+ string expectedInvariant)
+ {
+ var capabilities = GetSupportedCapabilities(databaseType);
+ using var generated = await AdoNetAspireTestConfiguration.CreateAsync(databaseType, capabilities);
+ var normalizedConnectionStringKey = $"ConnectionStrings:{generated.DatabaseName}";
+
+ var connectionString = generated.HostConfiguration.GetConnectionString(generated.DatabaseName);
+ Assert.False(string.IsNullOrWhiteSpace(connectionString));
+ Assert.Equal(connectionString, generated.HostConfiguration[normalizedConnectionStringKey]);
+ Assert.Contains(generated.DatabaseName, connectionString, StringComparison.Ordinal);
+
+ foreach (var capability in capabilities)
+ {
+ var capabilityPath = GetCapabilityPath(generated, capability);
+ var normalizedPrefix = $"Orleans:{capabilityPath}";
+
+ Assert.Equal(expectedProviderType, generated.HostConfiguration[$"{normalizedPrefix}:ProviderType"]);
+ Assert.Equal(generated.DatabaseName, generated.HostConfiguration[$"{normalizedPrefix}:ServiceKey"]);
+ }
+
+ using var siloHost = CreateSiloHost(generated.HostConfiguration);
+ AssertSiloOptions(siloHost.Services, generated, expectedInvariant, connectionString);
+
+ using var clientHost = CreateClientHost(generated);
+ var clientOptions = clientHost.Services.GetRequiredService>().Value;
+ Assert.Equal(expectedInvariant, clientOptions.Invariant);
+ Assert.Equal(connectionString, clientOptions.ConnectionString);
+
+ if (capabilities.Contains(AdoNetAspireCapability.Streaming))
+ {
+ var streamOptions = clientHost.Services
+ .GetRequiredService>()
+ .Get(generated.ProviderName);
+ Assert.Equal(expectedInvariant, streamOptions.Invariant);
+ Assert.Equal(connectionString, streamOptions.ConnectionString);
+ }
+ }
+
+ [Fact]
+ public async Task GeneratedConfiguration_ExplicitAdoNetAndInvariant_ProducesEquivalentOverrideShape()
+ {
+ const string invariant = "Microsoft.Data.SqlClient";
+ using var generated = await AdoNetAspireTestConfiguration.CreateAsync(
+ AdoNetAspireDatabase.SqlServer,
+ [AdoNetAspireCapability.Clustering],
+ invariant);
+
+ Assert.Equal("AdoNet", generated.HostConfiguration["Orleans:Clustering:ProviderType"]);
+ Assert.Equal(invariant, generated.HostConfiguration["Orleans:Clustering:Invariant"]);
+ Assert.Equal(generated.DatabaseName, generated.HostConfiguration["Orleans:Clustering:ServiceKey"]);
+ Assert.False(string.IsNullOrWhiteSpace(
+ generated.HostConfiguration.GetConnectionString(generated.DatabaseName)));
+ }
+
+ private static void AssertSiloOptions(
+ IServiceProvider services,
+ AdoNetAspireTestConfiguration.GeneratedConfiguration generated,
+ string expectedInvariant,
+ string connectionString)
+ {
+ var clustering = services.GetRequiredService>().Value;
+ Assert.Equal(expectedInvariant, clustering.Invariant);
+ Assert.Equal(connectionString, clustering.ConnectionString);
+
+ var storage = services.GetRequiredService>().Get(generated.ProviderName);
+ Assert.Equal(expectedInvariant, storage.Invariant);
+ Assert.Equal(connectionString, storage.ConnectionString);
+
+ var reminders = services.GetRequiredService>().Value;
+ Assert.Equal(expectedInvariant, reminders.Invariant);
+ Assert.Equal(connectionString, reminders.ConnectionString);
+
+ if (generated.Capabilities.Contains(AdoNetAspireCapability.GrainDirectory))
+ {
+ var directory = services
+ .GetRequiredService>()
+ .Get(generated.ProviderName);
+ Assert.Equal(expectedInvariant, directory.Invariant);
+ Assert.Equal(connectionString, directory.ConnectionString);
+ }
+
+ if (generated.Capabilities.Contains(AdoNetAspireCapability.Streaming))
+ {
+ var streaming = services
+ .GetRequiredService>()
+ .Get(generated.ProviderName);
+ Assert.Equal(expectedInvariant, streaming.Invariant);
+ Assert.Equal(connectionString, streaming.ConnectionString);
+ }
+ }
+
+ [Fact]
+ public async Task MissingConnectionReference_FailsStreamingValidation()
+ {
+ await using var builder = DistributedApplicationTestingBuilder.Create();
+ var orleans = builder.AddOrleans("cluster")
+ .WithDevelopmentClustering()
+ .WithStreaming("streams", new MissingConnectionProviderConfiguration());
+ var silo = builder.AddContainer("silo", "unused").WithReference(orleans);
+ await using var app = await builder.BuildAsync(TestContext.Current.CancellationToken);
+ var configuration = await AspireResourceConfiguration.CreateAsync(
+ silo.Resource,
+ app.Services,
+ include: static key => key.StartsWith("Orleans__Streaming__", StringComparison.Ordinal));
+ using var host = CreateSiloHost(configuration);
+ var validator = host.Services
+ .GetServices()
+ .OfType()
+ .Single();
+
+ var exception = Assert.Throws(validator.ValidateConfiguration);
+
+ Assert.Contains("configure exactly one of ConnectionString or DataSource", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("not-an-integer")]
+ [InlineData("0")]
+ [InlineData("-1")]
+ public void InvalidPartitionCount_FailsWithConfigurationPath(string partitionCount)
+ {
+ const string providerName = "streams";
+ var sectionPath = $"Orleans:Streaming:{providerName}";
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [$"{sectionPath}:ProviderType"] = "AdoNet",
+ [$"{sectionPath}:Invariant"] = "Microsoft.Data.SqlClient",
+ [$"{sectionPath}:ConnectionString"] = "Server=localhost;Database=orleans",
+ [$"{sectionPath}:PartitionCount"] = partitionCount,
+ })
+ .Build();
+
+ var exception = Assert.Throws(() => CreateSiloHost(configuration));
+
+ Assert.Contains(sectionPath, exception.Message, StringComparison.Ordinal);
+ Assert.Contains("PartitionCount", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("positive integer", exception.Message, StringComparison.Ordinal);
+ }
+
+ private static IHost CreateSiloHost(IConfiguration configuration)
+ {
+ var builder = Host.CreateApplicationBuilder();
+ builder.Configuration.AddConfiguration(configuration);
+ builder.UseOrleans();
+ return builder.Build();
+ }
+
+ private static IHost CreateClientHost(AdoNetAspireTestConfiguration.GeneratedConfiguration generated)
+ {
+ var builder = Host.CreateApplicationBuilder();
+ builder.Configuration.AddConfiguration(generated.ClientConfiguration);
+ builder.UseOrleansClient();
+ return builder.Build();
+ }
+
+ private sealed class MissingConnectionProviderConfiguration : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resourceBuilder,
+ string configurationSectionPath)
+ where T : IResourceWithEnvironment
+ => resourceBuilder.WithEnvironment(
+ $"Orleans__{configurationSectionPath.Replace(":", "__", StringComparison.Ordinal)}__ProviderType",
+ "SqlServerDatabase");
+ }
+
+ private static IReadOnlyList GetSupportedCapabilities(AdoNetAspireDatabase databaseType)
+ => databaseType == AdoNetAspireDatabase.Oracle
+ ?
+ [
+ AdoNetAspireCapability.Clustering,
+ AdoNetAspireCapability.GrainStorage,
+ AdoNetAspireCapability.Reminders,
+ ]
+ :
+ [
+ AdoNetAspireCapability.Clustering,
+ AdoNetAspireCapability.GrainStorage,
+ AdoNetAspireCapability.Reminders,
+ AdoNetAspireCapability.GrainDirectory,
+ AdoNetAspireCapability.Streaming,
+ ];
+
+ private static string GetCapabilityPath(
+ AdoNetAspireTestConfiguration.GeneratedConfiguration generated,
+ AdoNetAspireCapability capability)
+ => capability switch
+ {
+ AdoNetAspireCapability.Clustering => "Clustering",
+ AdoNetAspireCapability.GrainStorage => $"GrainStorage:{generated.ProviderName}",
+ AdoNetAspireCapability.Reminders => "Reminders",
+ AdoNetAspireCapability.GrainDirectory => $"GrainDirectory:{generated.ProviderName}",
+ AdoNetAspireCapability.Streaming => $"Streaming:{generated.ProviderName}",
+ _ => throw new ArgumentOutOfRangeException(nameof(capability), capability, null),
+ };
+
+}
diff --git a/test/Extensions/Orleans.AdoNet.Tests/AdoNetAspireTestConfiguration.cs b/test/Extensions/Orleans.AdoNet.Tests/AdoNetAspireTestConfiguration.cs
new file mode 100644
index 00000000000..fa2a8558175
--- /dev/null
+++ b/test/Extensions/Orleans.AdoNet.Tests/AdoNetAspireTestConfiguration.cs
@@ -0,0 +1,250 @@
+global using Orleans.GrainDirectory.AdoNet;
+
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Orleans;
+using Aspire.Hosting.Testing;
+using Microsoft.Extensions.Configuration;
+using TestExtensions;
+
+namespace UnitTests.AdoNet;
+
+public enum AdoNetAspireDatabase
+{
+ SqlServer,
+ PostgreSql,
+ MySql,
+ Oracle,
+}
+
+public enum AdoNetAspireCapability
+{
+ Clustering,
+ GrainStorage,
+ Reminders,
+ GrainDirectory,
+ Streaming,
+}
+
+internal sealed class AdoNetAspireTestConfiguration
+{
+ private static int s_resourceId;
+
+ public static async Task CreateAsync(
+ AdoNetAspireDatabase databaseType,
+ IReadOnlyList capabilities,
+ string? explicitInvariant = null)
+ {
+ var id = Interlocked.Increment(ref s_resourceId);
+ var stem = $"{databaseType.ToString().ToLowerInvariant()}-{id}";
+ var databaseName = $"database-{stem}";
+ var providerName = $"provider-{stem}";
+ var clusterName = $"cluster-{stem}";
+
+ await using var builder = DistributedApplicationTestingBuilder.Create();
+ var database = AddDatabase(builder, databaseType, $"server-{stem}", databaseName);
+ var orleans = builder.AddOrleans(clusterName);
+ if (!capabilities.Contains(AdoNetAspireCapability.Clustering))
+ {
+ orleans.WithDevelopmentClustering();
+ }
+
+ foreach (var capability in capabilities)
+ {
+ if (explicitInvariant is null)
+ {
+ ConfigureCapability(orleans, database, capability, providerName);
+ }
+ else
+ {
+ ConfigureCapability(
+ orleans,
+ new ExplicitAdoNetProviderConfiguration(database, explicitInvariant),
+ capability,
+ providerName);
+ }
+ }
+
+ var silo = builder.AddContainer($"silo-{stem}", "unused")
+ .WithReference(orleans);
+ var client = builder.AddContainer($"client-{stem}", "unused")
+ .WithReference(orleans.AsClient());
+
+ await using var app = await builder.BuildAsync();
+ var hostConfiguration = await AspireResourceConfiguration.CreateAsync(
+ silo.Resource,
+ app.Services,
+ include: IsRelevantEnvironmentVariable);
+ var clientConfiguration = await AspireResourceConfiguration.CreateAsync(
+ client.Resource,
+ app.Services,
+ include: IsRelevantEnvironmentVariable);
+
+ return new GeneratedConfiguration(
+ databaseType,
+ capabilities,
+ databaseName,
+ providerName,
+ hostConfiguration,
+ clientConfiguration);
+ }
+
+ private static IResourceBuilder AddDatabase(
+ IDistributedApplicationBuilder builder,
+ AdoNetAspireDatabase databaseType,
+ string serverName,
+ string databaseName)
+ => databaseType switch
+ {
+ AdoNetAspireDatabase.SqlServer => AddSqlServerDatabase(builder, serverName, databaseName),
+ AdoNetAspireDatabase.PostgreSql => AddPostgresDatabase(builder, serverName, databaseName),
+ AdoNetAspireDatabase.MySql => AddMySqlDatabase(builder, serverName, databaseName),
+ AdoNetAspireDatabase.Oracle => AddOracleDatabase(builder, serverName, databaseName),
+ _ => throw new ArgumentOutOfRangeException(nameof(databaseType), databaseType, null),
+ };
+
+ private static IResourceBuilder AddSqlServerDatabase(
+ IDistributedApplicationBuilder builder,
+ string serverName,
+ string databaseName)
+ {
+ var server = builder.AddSqlServer(serverName);
+ AllocateEndpoint(server.Resource, 1433);
+ return server.AddDatabase(databaseName);
+ }
+
+ private static IResourceBuilder AddPostgresDatabase(
+ IDistributedApplicationBuilder builder,
+ string serverName,
+ string databaseName)
+ {
+ var server = builder.AddPostgres(serverName);
+ AllocateEndpoint(server.Resource, 5432);
+ return server.AddDatabase(databaseName);
+ }
+
+ private static IResourceBuilder AddMySqlDatabase(
+ IDistributedApplicationBuilder builder,
+ string serverName,
+ string databaseName)
+ {
+ var server = builder.AddMySql(serverName);
+ AllocateEndpoint(server.Resource, 3306);
+ return server.AddDatabase(databaseName);
+ }
+
+ private static IResourceBuilder AddOracleDatabase(
+ IDistributedApplicationBuilder builder,
+ string serverName,
+ string databaseName)
+ {
+ var server = builder.AddOracle(serverName);
+ AllocateEndpoint(server.Resource, 1521);
+ return server.AddDatabase(databaseName);
+ }
+
+ private static void AllocateEndpoint(IResource resource, int port)
+ {
+ var endpoint = resource.Annotations.OfType().Single();
+ endpoint.AllocatedEndpoint = new AllocatedEndpoint(endpoint, "localhost", port);
+ }
+
+ private static void ConfigureCapability(
+ OrleansService orleans,
+ IResourceBuilder database,
+ AdoNetAspireCapability capability,
+ string providerName)
+ {
+ switch (capability)
+ {
+ case AdoNetAspireCapability.Clustering:
+ orleans.WithClustering(database);
+ break;
+ case AdoNetAspireCapability.GrainStorage:
+ orleans.WithGrainStorage(providerName, database);
+ break;
+ case AdoNetAspireCapability.Reminders:
+ orleans.WithReminders(database);
+ break;
+ case AdoNetAspireCapability.GrainDirectory:
+ orleans.WithGrainDirectory(providerName, database);
+ break;
+ case AdoNetAspireCapability.Streaming:
+ orleans.WithStreaming(providerName, database);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(capability), capability, null);
+ }
+ }
+
+ private static void ConfigureCapability(
+ OrleansService orleans,
+ IProviderConfiguration provider,
+ AdoNetAspireCapability capability,
+ string providerName)
+ {
+ switch (capability)
+ {
+ case AdoNetAspireCapability.Clustering:
+ orleans.WithClustering(provider);
+ break;
+ case AdoNetAspireCapability.GrainStorage:
+ orleans.WithGrainStorage(providerName, provider);
+ break;
+ case AdoNetAspireCapability.Reminders:
+ orleans.WithReminders(provider);
+ break;
+ case AdoNetAspireCapability.GrainDirectory:
+ orleans.WithGrainDirectory(providerName, provider);
+ break;
+ case AdoNetAspireCapability.Streaming:
+ orleans.WithStreaming(providerName, provider);
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(capability), capability, null);
+ }
+ }
+
+ private static bool IsRelevantEnvironmentVariable(string name)
+ => name.StartsWith("Orleans__Clustering__", StringComparison.Ordinal)
+ || name.StartsWith("Orleans__GrainStorage__", StringComparison.Ordinal)
+ || name.StartsWith("Orleans__Reminders__", StringComparison.Ordinal)
+ || name.StartsWith("Orleans__GrainDirectory__", StringComparison.Ordinal)
+ || name.StartsWith("Orleans__Streaming__", StringComparison.Ordinal)
+ || name.StartsWith("ConnectionStrings__", StringComparison.Ordinal);
+
+ private sealed class ExplicitAdoNetProviderConfiguration(
+ IResourceBuilder database,
+ string invariant) : IProviderConfiguration
+ {
+ public void ConfigureResource(
+ IResourceBuilder resourceBuilder,
+ string configurationSectionPath)
+ where T : IResourceWithEnvironment
+ {
+ var prefix = configurationSectionPath.Replace(":", "__", StringComparison.Ordinal);
+ resourceBuilder
+ .WithEnvironment($"Orleans__{prefix}__ProviderType", "AdoNet")
+ .WithEnvironment($"Orleans__{prefix}__Invariant", invariant)
+ .WithEnvironment($"Orleans__{prefix}__ServiceKey", database.Resource.Name)
+ .WithReference(database);
+ }
+ }
+
+ internal sealed record GeneratedConfiguration(
+ AdoNetAspireDatabase DatabaseType,
+ IReadOnlyList Capabilities,
+ string DatabaseName,
+ string ProviderName,
+ IConfigurationRoot HostConfiguration,
+ IConfigurationRoot ClientConfiguration) : IDisposable
+ {
+ public AdoNetAspireCapability Capability => Capabilities.Single();
+
+ public void Dispose()
+ {
+ (HostConfiguration as IDisposable)?.Dispose();
+ (ClientConfiguration as IDisposable)?.Dispose();
+ }
+ }
+}
diff --git a/test/Extensions/Orleans.AdoNet.Tests/AdoNetProviderConfigurationTests.cs b/test/Extensions/Orleans.AdoNet.Tests/AdoNetProviderConfigurationTests.cs
new file mode 100644
index 00000000000..81df7233d4b
--- /dev/null
+++ b/test/Extensions/Orleans.AdoNet.Tests/AdoNetProviderConfigurationTests.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Orleans.Runtime;
+using Orleans.Tests.SqlUtils;
+using TestExtensions;
+
+namespace UnitTests.AdoNet;
+
+[TestCategory("AdoNet")]
+[TestSuite("BVT")]
+[TestProvider("None")]
+[TestArea("Persistence")]
+public sealed class AdoNetProviderConfigurationTests
+{
+ [Theory]
+ [InlineData(null)]
+ [InlineData("direct-connection")]
+ public void GetConnectionString_ConflictingReferences_IdentifiesSectionAndSettings(string? directConnection)
+ {
+ var configuration = CreateConfiguration("first", "second", directConnection);
+ using var services = new ServiceCollection().AddSingleton(configuration).BuildServiceProvider();
+
+ var exception = Assert.Throws(() =>
+ AdoNetProviderConfiguration.GetConnectionString(configuration.GetSection("Provider"), services));
+
+ Assert.Contains("Provider", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("ServiceKey", exception.Message, StringComparison.Ordinal);
+ Assert.Contains("ConnectionName", exception.Message, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData("orders", "orders", null, "named-connection")]
+ [InlineData("orders", "ORDERS", null, "named-connection")]
+ [InlineData("orders", null, null, "named-connection")]
+ [InlineData(null, "orders", null, "named-connection")]
+ [InlineData(" ", "orders", null, "named-connection")]
+ [InlineData("orders", "orders", "direct-connection", "direct-connection")]
+ [InlineData(null, null, "direct-connection", "direct-connection")]
+ [InlineData(null, null, null, null)]
+ public void GetConnectionString_CompatibleReferences_PreservesResolution(
+ string? serviceKey,
+ string? connectionName,
+ string? directConnection,
+ string? expected)
+ {
+ var configuration = CreateConfiguration(serviceKey, connectionName, directConnection);
+ using var services = new ServiceCollection().AddSingleton(configuration).BuildServiceProvider();
+
+ var result = AdoNetProviderConfiguration.GetConnectionString(configuration.GetSection("Provider"), services);
+
+ Assert.Equal(expected, result);
+ }
+
+ private static IConfigurationRoot CreateConfiguration(string? serviceKey, string? connectionName, string? directConnection)
+ => new ConfigurationBuilder().AddInMemoryCollection(new Dictionary
+ {
+ ["Provider:ServiceKey"] = serviceKey,
+ ["Provider:ConnectionName"] = connectionName,
+ ["Provider:ConnectionString"] = directConnection,
+ ["ConnectionStrings:orders"] = "named-connection",
+ ["ConnectionStrings:first"] = "first-connection",
+ ["ConnectionStrings:second"] = "second-connection",
+ }).Build();
+}
diff --git a/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj b/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj
index ef9876f13f7..3324a966305 100644
--- a/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj
+++ b/test/Extensions/Orleans.AdoNet.Tests/Orleans.AdoNet.Tests.csproj
@@ -1,15 +1,36 @@
+
+
$(DefineConstants);TESTER_SQLUTILS
$(TestTargetFrameworks)
true
+ false
enable
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -47,4 +68,7 @@
PreserveNewest
+
+
+
diff --git a/test/Extensions/Orleans.Azure.Tests/AzureStorageAspireIntegrationTests.cs b/test/Extensions/Orleans.Azure.Tests/AzureStorageAspireIntegrationTests.cs
new file mode 100644
index 00000000000..43138d0af52
--- /dev/null
+++ b/test/Extensions/Orleans.Azure.Tests/AzureStorageAspireIntegrationTests.cs
@@ -0,0 +1,236 @@
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Testing;
+using Azure.Data.Tables;
+using Azure.Storage.Blobs;
+using Azure.Storage.Queues;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+using Orleans.Clustering.AzureStorage;
+using Orleans.Configuration;
+using Orleans.Hosting;
+using Orleans.Journaling;
+using Orleans.Reminders.AzureStorage;
+using TestExtensions;
+using Xunit;
+
+#pragma warning disable ORLEANSEXP005
+
+namespace Tester.AzureUtils;
+
+[TestSuite("BVT")]
+[TestProvider("AzureStorage")]
+[TestCategory("AzureStorage"), TestCategory("BVT")]
+public sealed class AzureStorageAspireIntegrationTests
+{
+ private const string TablesResourceName = "orleans-tables";
+ private const string BlobsResourceName = "orleans-blobs";
+ private const string QueuesResourceName = "orleans-queues";
+
+ [Fact]
+ public async Task AspireAppModel_ActivatesAllAzureStorageProviders()
+ {
+ var configuration = await CreateAspireConfigurationAsync("UseDevelopmentStorage=true");
+
+ using var host = CreateHost(configuration);
+ var services = host.Services;
+ var tableClient = services.GetRequiredKeyedService(TablesResourceName);
+ var blobClient = services.GetRequiredKeyedService(BlobsResourceName);
+ var queueClient = services.GetRequiredKeyedService(QueuesResourceName);
+
+ Assert.Same(
+ tableClient,
+ services.GetRequiredService>().Value.TableServiceClient);
+ Assert.Same(
+ tableClient,
+ services.GetRequiredService>().Get("table-state").TableServiceClient);
+ Assert.Same(
+ tableClient,
+ services.GetRequiredService>().Value.TableServiceClient);
+ Assert.Same(
+ tableClient,
+ services.GetRequiredService>().Get("directory").TableServiceClient);
+ Assert.Same(
+ blobClient,
+ services.GetRequiredService>().Get("blob-state").BlobServiceClient);
+ Assert.Same(
+ queueClient,
+ services.GetRequiredService