Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8db977da95 | ||
|
|
bff14420f5 |
5
.vscode/extensions.json
vendored
Normal file
5
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"anysphere.csharp"
|
||||
]
|
||||
}
|
||||
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"[csharp]": {
|
||||
"editor.defaultFormatter": "anysphere.csharp",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
}
|
||||
52
CHANGELOG.md
52
CHANGELOG.md
@ -5,14 +5,62 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.2.1] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
- **Dependencies:** `MaksIT.Core` `1.6.9`, `MaksIT.Results` `2.0.4`; test SDK `Microsoft.NET.Test.Sdk` `18.9.0`.
|
||||
- **RepoUtils:** synced Community Non-Helm utils — plugin success checks use exact `$true` (CLI stdout no longer masks failures), `Invoke-ExternalCommand` defaults to throw-on-error with soft callers opting out, plugin helper discovery by group directories.
|
||||
- Package version **2.2.1**.
|
||||
|
||||
### Removed
|
||||
- `Update-RepoUtils.bat` and `utils/tools/Update-RepoUtils/` (local-copy sync only).
|
||||
|
||||
## [2.2.0] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- **Actors facade:** `IDaprActorService` / `DaprActorService` — create typed/weak actor clients and invoke methods with `Result` outcomes; DI `RegisterActors(configure?)` and pipeline `RegisterActorsHandlers()`.
|
||||
- **Workflows facade:** `IDaprWorkflowService` / `DaprWorkflowService` — schedule, get/wait state, raise event, terminate/suspend/resume/purge, list instance IDs, get history, rerun from event; DI `RegisterWorkflows(configure?)`.
|
||||
- **Pub/Sub facade rename/expand:** `IDaprPubSubService` — publish with metadata, byte publish, bulk publish; DI `RegisterPubSub()`.
|
||||
- **State expand:** bulk get/save/delete, `TryDeleteStateAsync`, `QueryStateAsync`, `ExecuteStateTransactionAsync`, optional metadata/consistency/options on existing methods.
|
||||
- **Client facades:** invocation, binding, secrets, configuration, cryptography, sidecar (`IDapr*Service`) + `Register*` and `RegisterDaprClientFacades()`.
|
||||
- **Lock facade:** `IDaprLockService` / `DaprLockService` — `LockAsync` / `UnlockAsync` with `Result` outcomes; DI `RegisterLock()` (included in `RegisterDaprClientFacades()`).
|
||||
- **HA helpers:** `TryHoldAsync` / `DaprWorkLeaseHold` (auto-renew + `Generation` fencing), `LeasedBackgroundService`, `DaprWorkLeaseBootstrap.RunBootstrapUnderLeaseAsync`, `RegisterWorkLeases(storeName)` / `IDaprWorkLeaseOptions`.
|
||||
- **Work leases rename:** `IDaprWorkLeaseStore` / `DaprWorkLeaseStore` → `IDaprWorkLeaseService` / `DaprWorkLeaseService`.
|
||||
|
||||
### Fixed
|
||||
- **State get miss on `state.jetstream`:** `GetStateAndETagAsync` / `GetStateAsync` treat NATS/Dapr `key not found` (often gRPC `Internal`) as empty without logging Error — so work-lease first acquire works after release or on a fresh bucket.
|
||||
- **Results hygiene:** rethrow `OperationCanceledException`; `BadRequest` for empty store/key or pubsub/topic; idempotent `DeleteStateAsync` when key is missing.
|
||||
- **DI:** `AddDaprClient` is skipped per `IServiceCollection` when `DaprClient` is already registered (no process-wide static flag).
|
||||
- **Docs:** README and CONTRIBUTING aligned with `net10.0`, RepoUtils Non-Helm bats, and current package version.
|
||||
|
||||
### Changed
|
||||
- **Work leases layout:** all HA lease types live under `Services/WorkLease/` (`MaksIT.Dapr.Services.WorkLease`) — `DaprWorkLease`, `DaprWorkLeaseHold` / bootstrap / `LeasedBackgroundService`, `DaprWorkLeaseService`, `IDaprWorkLeaseOptions`, `IDaprRuntimeInstanceId`.
|
||||
- **`GetStateAsync` miss:** returns `Ok(null)` instead of `NotFound` (aligned with ETag get and typical MaksIT optional-read pattern). Check `Value is null` for absence; `!IsSuccess` means infra failure.
|
||||
- **CancellationToken:** optional `cancellationToken` on facade APIs; lease `ReleaseAsync` forwards it to delete.
|
||||
- **Invocation:** `DaprInvocationService` uses `DaprClient.CreateInvokableHttpClient` (HTTP POST + JSON) instead of obsolete `InvokeMethodAsync(appId, method…)` helpers.
|
||||
- **Workflows DI:** `RegisterWorkflows()` calls parameterless `AddDaprWorkflow()` when no configure delegate is passed (SDK 1.18 auto-discovers workflows/activities).
|
||||
- **Dependencies:** Dapr packages bumped to `1.18.5`.
|
||||
- **Docs:** README documents each facade (when to use / when not, DI, API), including lock vs work-lease guidance, and a short coordination-pattern chooser.
|
||||
- **XML docs** on public APIs; `GenerateDocumentationFile` enabled.
|
||||
- Removed obsolete `assets/badges` pack items (CoverageBadges uses shields.io).
|
||||
- Package version **2.2.0**.
|
||||
|
||||
### Removed
|
||||
- Custom pub/sub accept types and `IDaprPubSubWorkHandler` (`DaprPubSubAcceptOutcome`, `DaprPubSubAcceptResult`, `DaprPubSubAck`). Topic controllers should return `MaksIT.Results.Result` via `ToActionResult()` directly (`Ok` ACK, `ServiceUnavailable` retry, `BadRequest` drop).
|
||||
- `IDaprPublisherService` / `RegisterPublisher` (use `IDaprPubSubService` / `RegisterPubSub`).
|
||||
- Leftover `DaprWorkLeaseStore` and `PubSub/DaprPubSubWork` types (no obsolete shims).
|
||||
- `DaprFacadeGuard` helper (inline try/catch + validation in each service).
|
||||
|
||||
## [2.1.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- **HA work leases via Dapr state:** `IDaprWorkLeaseStore` / `DaprWorkLeaseStore` (acquire, renew, release, get) using ETag concurrency — broker-agnostic (NATS KV / Postgres / other state Component).
|
||||
- **HA work leases via Dapr state:** `IDaprWorkLeaseService` / `DaprWorkLeaseService` (acquire, renew, release, get) using ETag concurrency — broker-agnostic (NATS KV / Postgres / other state Component).
|
||||
- **State ETag API:** `GetStateAndETagAsync` and `TrySaveStateAsync` on `IDaprStateStoreService`.
|
||||
- **Runtime instance id:** `IDaprRuntimeInstanceId` / `DaprRuntimeInstanceIdProvider` (`POD_NAME` in Kubernetes).
|
||||
- **Pub/sub worker helpers:** `IDaprPubSubWorkHandler<T>`, `DaprPubSubAcceptOutcome` / `DaprPubSubAcceptResult`, `DaprPubSubAck` (HTTP ACK/NAK mapping).
|
||||
- DI: `RegisterWorkLeases()` registers state store + lease store + instance id.
|
||||
- DI: `RegisterWorkLeases()` registers state store + work-lease service + instance id.
|
||||
|
||||
### Changed
|
||||
- Package version **2.1.0**.
|
||||
|
||||
@ -14,7 +14,7 @@ Thank you for your interest in contributing to `MaksIT.Dapr`.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 8 SDK or later
|
||||
- .NET 10 SDK
|
||||
- Git
|
||||
- PowerShell 7+ (recommended for utility scripts)
|
||||
|
||||
@ -27,9 +27,19 @@ dotnet build MaksIT.Dapr.slnx
|
||||
|
||||
### Test
|
||||
|
||||
Prefer the RepoUtils test engine (coverage + README shields.io badges):
|
||||
|
||||
```powershell
|
||||
pwsh -File .\utils\engines\test\Invoke-TestEngine.ps1
|
||||
```
|
||||
|
||||
Or double-click `utils\Invoke-TestEngine.bat`.
|
||||
|
||||
Quick local run:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
dotnet test MaksIT.Dapr.Tests
|
||||
dotnet test MaksIT.Dapr.slnx
|
||||
```
|
||||
|
||||
## Commit Message Format
|
||||
@ -47,7 +57,14 @@ Use:
|
||||
| `(feature):` | New feature or enhancement |
|
||||
| `(bugfix):` | Bug fix |
|
||||
| `(refactor):` | Refactoring without behavior change |
|
||||
| `(chore):` | Maintenance tasks (dependencies, tooling, docs) |
|
||||
| `(perf):` | Performance improvement |
|
||||
| `(test):` | Tests only |
|
||||
| `(docs):` | Documentation |
|
||||
| `(build):` | Build / packaging |
|
||||
| `(ci):` | CI configuration |
|
||||
| `(style):` | Formatting / style |
|
||||
| `(chore):` | Maintenance tasks (dependencies, tooling) |
|
||||
| `(revert):` | Revert a previous change |
|
||||
|
||||
### Guidelines
|
||||
|
||||
@ -57,10 +74,11 @@ Use:
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
1. Ensure build and tests pass.
|
||||
1. Ensure build and tests pass (RepoUtils test engine when possible).
|
||||
2. Update `README.md` if behavior or usage changed.
|
||||
3. Update `CHANGELOG.md` under the target version.
|
||||
4. Keep changes scoped and explain rationale in the PR description.
|
||||
4. If coverage changed, refresh README shields.io badges via the test engine.
|
||||
5. Keep changes scoped and explain rationale in the PR description.
|
||||
|
||||
## Versioning
|
||||
|
||||
@ -92,7 +110,7 @@ Builds, tests, packs, and publishes to NuGet and GitHub release flows.
|
||||
pwsh -File .\utils\engines\release\Invoke-ReleasePackage.ps1
|
||||
```
|
||||
|
||||
Or run `utils\Invoke-ReleasePackage-Single.bat` (or `-HA.bat` when Helm deploy is wired).
|
||||
Or double-click `utils\Invoke-ReleasePackage.bat`.
|
||||
|
||||
Prerequisites:
|
||||
|
||||
@ -102,6 +120,8 @@ Prerequisites:
|
||||
|
||||
Configuration: `utils/engines/release/scriptSettings.json`
|
||||
|
||||
Public GitHub releases target `https://github.com/MAKS-IT-COM/maksit-core-dapr` (see release `scriptSettings.json`).
|
||||
|
||||
### Update repo utilities
|
||||
|
||||
Refreshes `utils/` from maksit-repoutils while preserving local `scriptSettings.json` files.
|
||||
|
||||
439
README.md
439
README.md
@ -1,206 +1,381 @@
|
||||
# MaksIT.Dapr
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
This repository hosts the `maksit-dapr` project, which utilizes [Dapr](https://dapr.io/) (Distributed Application Runtime) to facilitate building and managing microservices with ease. The project focuses on implementing a robust, scalable solution leveraging Dapr's building blocks and abstractions.
|
||||
NuGet facade over [Dapr](https://dapr.io/) for ASP.NET Core: pub/sub, state, invocation, bindings, secrets, configuration, cryptography, sidecar, lock, HA work leases, actors, and workflows — all with `MaksIT.Results` outcomes.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [MaksIT.Dapr](#maksitdapr)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Overview](#overview)
|
||||
- [Features](#features)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Installation](#installation)
|
||||
- [Usage](#usage)
|
||||
- [Registering Dapr Services](#registering-dapr-services)
|
||||
- [Injecting and Using Dapr Services](#injecting-and-using-dapr-services)
|
||||
- [Contributing](#contributing)
|
||||
- [Contact](#contact)
|
||||
- [License](#license)
|
||||
- [Overview](#overview)
|
||||
- [Result conventions](#result-conventions)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Installation](#installation)
|
||||
- [Registering services](#registering-services)
|
||||
- [Services](#services)
|
||||
- [Pub/Sub — `IDaprPubSubService`](#pubsub--idaprpubsubservice)
|
||||
- [State store — `IDaprStateStoreService`](#state-store--idaprstatestoreservice)
|
||||
- [Service invocation — `IDaprInvocationService`](#service-invocation--idaprinvocationservice)
|
||||
- [Bindings — `IDaprBindingService`](#bindings--idaprbindingservice)
|
||||
- [Secrets — `IDaprSecretService`](#secrets--idaprsecretservice)
|
||||
- [Configuration — `IDaprConfigurationService`](#configuration--idaprconfigurationservice)
|
||||
- [Cryptography — `IDaprCryptographyService`](#cryptography--idaprcryptographyservice)
|
||||
- [Sidecar — `IDaprSidecarService`](#sidecar--idaprsidecarservice)
|
||||
- [Distributed lock — `IDaprLockService`](#distributed-lock--idaprlockservice)
|
||||
- [HA work leases — `IDaprWorkLeaseService`](#ha-work-leases--idaprworkleaseservice)
|
||||
- [Actors — `IDaprActorService`](#actors--idapractorservice)
|
||||
- [Workflows — `IDaprWorkflowService`](#workflows--idaprworkflowservice)
|
||||
- [Choosing a coordination pattern](#choosing-a-coordination-pattern)
|
||||
- [Contributing](#contributing)
|
||||
- [Contact](#contact)
|
||||
- [License](#license)
|
||||
|
||||
## Overview
|
||||
|
||||
`maksit-dapr` serves as a foundational project to explore and implement Dapr-based microservices, demonstrating the integration of Dapr<70>s pub-sub, bindings, state management, and other building blocks in a distributed system environment.
|
||||
`MaksIT.Dapr` wraps Dapr building blocks so application code depends on small `IDapr*Service` facades instead of raw `DaprClient` calls. Failures surface as `MaksIT.Results` outcomes that map cleanly to HTTP via `ToActionResult()`.
|
||||
|
||||
## Features
|
||||
| Need | Register | Inject |
|
||||
|------|----------|--------|
|
||||
| Publish events | `RegisterPubSub()` | `IDaprPubSubService` |
|
||||
| Key/value state | `RegisterStateStore()` | `IDaprStateStoreService` |
|
||||
| Call another Dapr app | `RegisterInvocation()` | `IDaprInvocationService` |
|
||||
| Trigger external systems (queues, cron, …) | `RegisterBinding()` | `IDaprBindingService` |
|
||||
| Read secrets from a Component | `RegisterSecrets()` | `IDaprSecretService` |
|
||||
| Dynamic config + subscribe | `RegisterConfiguration()` | `IDaprConfigurationService` |
|
||||
| Encrypt/decrypt via Dapr crypto Component | `RegisterCryptography()` | `IDaprCryptographyService` |
|
||||
| Wait for sidecar / health / metadata | `RegisterSidecar()` | `IDaprSidecarService` |
|
||||
| Short-lived distributed mutex | `RegisterLock()` | `IDaprLockService` |
|
||||
| Multi-replica exclusive work (leader/bootstrap) | `RegisterWorkLeases(storeName)` | `IDaprWorkLeaseService` |
|
||||
| Virtual actors | `RegisterActors(...)` | `IDaprActorService` |
|
||||
| Durable workflows | `RegisterWorkflows(...)` | `IDaprWorkflowService` |
|
||||
|
||||
- **Pub-Sub Integration**: Uses Dapr's pub-sub component for seamless event-driven communication.
|
||||
- **State Management**: Efficient, distributed state handling across microservices.
|
||||
`RegisterDaprClientFacades()` registers all `DaprClient`-backed rows above (not actors, workflows, or work leases).
|
||||
|
||||
## Result conventions
|
||||
|
||||
- **Success / failure:** check `result.IsSuccess`. Map to HTTP with `result.ToActionResult()`.
|
||||
- **Cancellation:** facades rethrow `OperationCanceledException` (do not wrap as a failed `Result`).
|
||||
- **State misses:** `GetStateAsync` / `GetStateAndETagAsync` return `Ok(null)` / `Ok((null, null))` when the key is absent (including JetStream `key not found` surfaced as gRPC `Internal`). `!IsSuccess` means infrastructure failure.
|
||||
- **Topic handlers:** return `Result` via `ToActionResult()` — `Ok` ACK, `ServiceUnavailable` retry, `BadRequest` drop.
|
||||
|
||||
## Getting Started
|
||||
|
||||
Ensure that you have the following installed:
|
||||
|
||||
- [.NET 8.0 SDK](https://dotnet.microsoft.com/download)
|
||||
- [Docker](https://www.docker.com/get-started)
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download)
|
||||
- [Dapr CLI](https://docs.dapr.io/getting-started/install-dapr-cli/) (for local sidecar runs)
|
||||
- [Docker](https://www.docker.com/get-started) (optional; used by RepoUtils Linux test validation)
|
||||
|
||||
## Installation
|
||||
|
||||
To install MaksIT.Core, add the package to your project via NuGet:
|
||||
|
||||
```powershell
|
||||
dotnet add package MaksIT.Dapr
|
||||
```
|
||||
|
||||
Or manually add it to your .csproj file:
|
||||
Or in your `.csproj`:
|
||||
|
||||
```powershell
|
||||
<PackageReference Include="MaksIT.Dapr" Version="1.0.0" />
|
||||
```xml
|
||||
<PackageReference Include="MaksIT.Dapr" Version="2.2.1" />
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Registering Dapr Services
|
||||
|
||||
To use `maksit-dapr` in your application, you must register the provided services for dependency injection. Follow these steps to integrate Dapr's pub-sub and state management capabilities in your ASP.NET Core application:
|
||||
|
||||
1. **Register the Publisher and State Store Services**: Add these services in `Program.cs` or `Startup.cs`.
|
||||
## Registering services
|
||||
|
||||
```csharp
|
||||
using MaksIT.Dapr.Extensions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register Dapr services
|
||||
builder.Services.RegisterPublisher();
|
||||
builder.Services.RegisterStateStore();
|
||||
// All DaprClient-backed facades (pub/sub, state, invocation, binding, secrets, …)
|
||||
builder.Services.RegisterDaprClientFacades();
|
||||
// Or register individually: RegisterPubSub(), RegisterStateStore(), …
|
||||
|
||||
// HA coordination (leases via Dapr state Component — e.g. persistent NATS KV)
|
||||
builder.Services.RegisterWorkLeases();
|
||||
builder.Services.RegisterWorkLeases("maksit-cicd-state"); // storeName for LeasedBackgroundService
|
||||
|
||||
builder.Services.RegisterActors(options => {
|
||||
options.Actors.RegisterActor<MyActor>();
|
||||
});
|
||||
builder.Services.RegisterWorkflows();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Set up Dapr subscriber middleware (optional)
|
||||
// Only after Webapi Authorization services
|
||||
// After authentication/authorization middleware
|
||||
app.RegisterSubscriber();
|
||||
app.RegisterActorsHandlers();
|
||||
```
|
||||
|
||||
2. **Use Controller as a Dapr Subscriber**:
|
||||
## Services
|
||||
|
||||
To designate a controller as a Dapr subscriber, annotate it with the `[Topic("pubsubName", "name")]` attribute:
|
||||
### Pub/Sub — `IDaprPubSubService`
|
||||
|
||||
**When to use:** fire-and-forget or at-least-once messaging between services (commands, domain events, fan-out). Prefer this over direct broker SDKs so the app stays Component-agnostic (Redis, NATS, Kafka, …).
|
||||
|
||||
**When not to:** request/response RPC → [service invocation](#service-invocation--idaprinvocationservice); long-running orchestrations → [workflows](#workflows--idaprworkflowservice); exclusive multi-replica jobs → [work leases](#ha-work-leases--idaprworkleaseservice).
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterPubSub()` (also in `RegisterDaprClientFacades()`) |
|
||||
| **Pipeline** | `app.RegisterSubscriber()` + `[Topic("pubsubName", "topic")]` on controllers |
|
||||
| **API** | `PublishEventAsync`, `PublishByteEventAsync`, `BulkPublishEventAsync` |
|
||||
|
||||
```csharp
|
||||
using Dapr;
|
||||
var result = await pubSub.PublishEventAsync("my-pubsub", "my-topic", command, cancellationToken: ct);
|
||||
if (!result.IsSuccess) { /* handle */ }
|
||||
|
||||
[Topic("my-pubsub", "my-topic")]
|
||||
public class MyController : ControllerBase
|
||||
{
|
||||
[HttpPost("/my-endpoint")]
|
||||
public IActionResult ReceiveMessage([FromBody] MyCommand payload)
|
||||
{
|
||||
|
||||
|
||||
// Handle message
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
[HttpPost("/my-endpoint")]
|
||||
public IActionResult Receive([FromBody] MyCommand payload) =>
|
||||
Result.Ok().ToActionResult(); // ACK; ServiceUnavailable → retry; BadRequest → drop
|
||||
```
|
||||
|
||||
### Injecting and Using Dapr Services
|
||||
### State store — `IDaprStateStoreService`
|
||||
|
||||
With the services registered, you can inject `IDaprPublisherService` and `IDaprStateStoreService` into controllers or other services as needed:
|
||||
**When to use:** shared key/value state (caches, documents, coordination metadata) through a Dapr state Component. Also the foundation for [work leases](#ha-work-leases--idaprworkleaseservice).
|
||||
|
||||
**When not to:** primary transactional business data that belongs in your product database; use the app’s own data layer for that.
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterStateStore()` |
|
||||
| **API** | `Set` / `Get` / `Delete`, ETag OCC (`GetStateAndETagAsync`, `TrySaveStateAsync`, `TryDeleteStateAsync`), bulk, query, transactions |
|
||||
|
||||
```csharp
|
||||
using MaksIT.Dapr;
|
||||
|
||||
public class MyService
|
||||
{
|
||||
private readonly IDaprPublisherService _publisher;
|
||||
private readonly IDaprStateStoreService _stateStore;
|
||||
|
||||
public MyService(IDaprPublisherService publisher, IDaprStateStoreService stateStore)
|
||||
{
|
||||
_publisher = publisher;
|
||||
_stateStore = stateStore;
|
||||
}
|
||||
|
||||
public async Task PublishEventAsync()
|
||||
{
|
||||
var command = new MyCommand
|
||||
{
|
||||
CommandId = Guid.NewGuid(),
|
||||
CommandName = "SampleCommand",
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
var payload = JsonSerializer.Serialize(command, options);
|
||||
|
||||
var result = await _publisher.PublishEventAsync("my-pubsub", "my-topic", payload);
|
||||
if (!result.IsSuccess)
|
||||
{
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SetStateAsync()
|
||||
{
|
||||
var saveResult = await _stateStore.SetStateAsync("my-store", "my-key", "my-value");
|
||||
if (!saveResult.IsSuccess)
|
||||
{
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> GetStateAsync()
|
||||
{
|
||||
var stateResult = await _stateStore.GetStateAsync<string>("my-store", "my-key");
|
||||
return stateResult.IsSuccess ? stateResult.Value : null;
|
||||
}
|
||||
|
||||
public async Task DeleteStateAsync()
|
||||
{
|
||||
var deleteResult = await _stateStore.DeleteStateAsync("my-store", "my-key");
|
||||
if (!deleteResult.IsSuccess)
|
||||
{
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
}
|
||||
var save = await stateStore.SetStateAsync("my-store", "my-key", "my-value", cancellationToken: ct);
|
||||
var get = await stateStore.GetStateAsync<string>("my-store", "my-key", cancellationToken: ct);
|
||||
if (!get.IsSuccess) { /* infra failure */ }
|
||||
var value = get.Value; // null when key is missing
|
||||
```
|
||||
|
||||
This setup enables your ASP.NET Core application to utilize Dapr's pub-sub, state management, and other building blocks with minimal boilerplate.
|
||||
### Service invocation — `IDaprInvocationService`
|
||||
|
||||
### HA work leases (multi-replica)
|
||||
**When to use:** synchronous HTTP-style calls to another Dapr app id (mTLS, retries, and discovery handled by the sidecars). Good for request/response between microservices without hard-coding URLs.
|
||||
|
||||
Use Dapr **state** (not product DB lock tables) for bootstrap/sweep/holder coordination. The state Component is infrastructure (e.g. persistent NATS JetStream KV); application code stays broker-agnostic.
|
||||
**When not to:** broadcast or decoupled events → [pub/sub](#pubsub--idaprpubsubservice); calling non-Dapr external APIs → [bindings](#bindings--idaprbindingservice) or a normal `HttpClient`.
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterInvocation()` |
|
||||
| **API** | `InvokeAsync` overloads (void / request / response / both) — HTTP **POST** via `CreateInvokableHttpClient` |
|
||||
|
||||
```csharp
|
||||
builder.Services.RegisterWorkLeases();
|
||||
var result = await invocation.InvokeAsync<OrderRequest, OrderResponse>(
|
||||
appId: "orders",
|
||||
methodName: "create",
|
||||
data: request,
|
||||
cancellationToken: ct);
|
||||
```
|
||||
|
||||
// inject IDaprWorkLeaseStore + IDaprRuntimeInstanceId
|
||||
var acquired = await leases.TryAcquireAsync(
|
||||
### Bindings — `IDaprBindingService`
|
||||
|
||||
**When to use:** invoke an **output** binding Component (send to a queue, call Twilio, write to blob storage, cron-triggered input handlers on the app side, etc.) without embedding vendor SDKs.
|
||||
|
||||
**When not to:** app-to-app messaging → [pub/sub](#pubsub--idaprpubsubservice) or [invocation](#service-invocation--idaprinvocationservice).
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterBinding()` |
|
||||
| **API** | `InvokeAsync` (fire-and-forget or typed response) |
|
||||
|
||||
```csharp
|
||||
var result = await bindings.InvokeAsync("my-smtp", "create", emailPayload, cancellationToken: ct);
|
||||
```
|
||||
|
||||
### Secrets — `IDaprSecretService`
|
||||
|
||||
**When to use:** load secrets at runtime from a Dapr secret store Component (Kubernetes secrets, Azure Key Vault, local files, …) when the host should not bind every secret into `IConfiguration` up front.
|
||||
|
||||
**When not to:** replace standard ASP.NET `appsettings` / `appsecrets.json` for ordinary host config — MaksIT hosts still prefer configuration binding for app settings; use this facade for Component-backed secret reads from application code.
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterSecrets()` |
|
||||
| **API** | `GetAsync`, `GetBulkAsync` |
|
||||
|
||||
```csharp
|
||||
var secret = await secrets.GetAsync("my-secret-store", "connection-string", cancellationToken: ct);
|
||||
```
|
||||
|
||||
### Configuration — `IDaprConfigurationService`
|
||||
|
||||
**When to use:** read or subscribe to keys from a Dapr configuration Component (feature flags, dynamic settings that change without redeploy).
|
||||
|
||||
**When not to:** static startup configuration already covered by `IConfiguration` / Options — keep using the host configuration stack for that.
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterConfiguration()` |
|
||||
| **API** | `GetAsync`, `SubscribeAsync`, `UnsubscribeAsync` |
|
||||
|
||||
### Cryptography — `IDaprCryptographyService`
|
||||
|
||||
**When to use:** encrypt/decrypt payloads with keys managed by a Dapr cryptography Component (keys stay in the sidecar/Component, not in app memory as raw key material).
|
||||
|
||||
**When not to:** simple password hashing or app-local crypto libraries when you do not need Dapr-managed keys.
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterCryptography()` |
|
||||
| **API** | `EncryptAsync`, `DecryptAsync` |
|
||||
|
||||
### Sidecar — `IDaprSidecarService`
|
||||
|
||||
**When to use:** gate startup or background work until the sidecar is ready; health/outbound checks; inspect metadata; request graceful sidecar shutdown.
|
||||
|
||||
**Typical scenario:** call `WaitForSidecarAsync` before the first [work-lease](#ha-work-leases--idaprworkleaseservice) race or state call on cold start so replicas do not fail while the sidecar is still connecting.
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterSidecar()` |
|
||||
| **API** | `CheckHealthAsync`, `CheckOutboundHealthAsync`, `WaitForSidecarAsync`, `GetMetadataAsync`, `ShutdownAsync` |
|
||||
|
||||
```csharp
|
||||
var ready = await sidecar.WaitForSidecarAsync(ct);
|
||||
if (!ready.IsSuccess) { /* abort startup path */ }
|
||||
```
|
||||
|
||||
### Distributed lock — `IDaprLockService`
|
||||
|
||||
**When to use:** a short-lived distributed mutex on a Dapr **lock** Component — e.g. protect a critical section across processes for seconds, then unlock. Check `TryLockResponse.Success`; dispose the response (or call `UnlockAsync`) when finished. Expiry is in seconds via the Dapr API.
|
||||
|
||||
**When not to:** multi-replica leader election, bootstrap, or long-running exclusive sweeps → prefer [HA work leases](#ha-work-leases--idaprworkleaseservice) (state-backed TTL, auto-renew, generation fencing, `LeasedBackgroundService` / bootstrap helpers). Per-entity serialized logic → [actors](#actors--idapractorservice).
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterLock()` (also in `RegisterDaprClientFacades()`) |
|
||||
| **API** | `LockAsync` → `TryLockResponse`, `UnlockAsync` → `UnlockResponse` |
|
||||
| **Component** | Dapr lock store (separate from the state Component used by work leases) |
|
||||
|
||||
```csharp
|
||||
var locked = await locks.LockAsync("my-lock-store", "invoice-42", runtimeInstance.InstanceId, expiryInSeconds: 15, ct);
|
||||
if (!locked.IsSuccess)
|
||||
return; // infra failure
|
||||
|
||||
await using var handle = locked.Value; // DisposeAsync unlocks
|
||||
if (!handle.Success)
|
||||
return; // another owner holds the lock
|
||||
|
||||
/* critical section */
|
||||
```
|
||||
|
||||
Or unlock explicitly with `UnlockAsync` when you are not disposing the `TryLockResponse`.
|
||||
|
||||
### HA work leases — `IDaprWorkLeaseService`
|
||||
|
||||
**When to use:** only one replica among many should run a piece of work (DB migrate/bootstrap, periodic sweep, leader-style job). Leases live in a Dapr **state** Component (e.g. persistent NATS JetStream KV) — broker-agnostic, ETag concurrency, optional auto-renew and generation fencing.
|
||||
|
||||
**When not to:** short critical sections with an explicit unlock → [distributed lock](#distributed-lock--idaprlockservice); per-entity serialized state machines → [actors](#actors--idapractorservice); multi-step durable processes → [workflows](#workflows--idaprworkflowservice).
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **Namespace** | `MaksIT.Dapr.Services.WorkLease` |
|
||||
| **DI** | `RegisterWorkLeases()` or `RegisterWorkLeases("state-component-name")` |
|
||||
| **Also registered** | `IDaprStateStoreService`, `IDaprRuntimeInstanceId` (`POD_NAME` in Kubernetes) |
|
||||
| **Helpers** | `TryHoldAsync` → `DaprWorkLeaseHold`, `DaprWorkLeaseBootstrap.RunBootstrapUnderLeaseAsync`, `LeasedBackgroundService` |
|
||||
|
||||
```csharp
|
||||
using MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
builder.Services.RegisterWorkLeases("maksit-cicd-state");
|
||||
|
||||
await using var hold = (await leases.TryHoldAsync(
|
||||
storeName: "maksit-cicd-state",
|
||||
workKey: "bootstrap",
|
||||
holderId: runtimeInstance.InstanceId,
|
||||
ttl: TimeSpan.FromMinutes(5),
|
||||
ct);
|
||||
autoRenew: true,
|
||||
cancellationToken: ct)).Value;
|
||||
|
||||
if (acquired is { IsSuccess: true, Value: true }) {
|
||||
try { /* exclusive work */ }
|
||||
finally { await leases.ReleaseAsync("maksit-cicd-state", "bootstrap", runtimeInstance.InstanceId, CancellationToken.None); }
|
||||
if (hold is null)
|
||||
return; // another replica holds the lease
|
||||
|
||||
var stillHeld = await hold.EnsureStillHeldAsync(ct);
|
||||
if (stillHeld is { IsSuccess: true, Value: true }) {
|
||||
/* exclusive work; hold.Generation is a fencing token */
|
||||
}
|
||||
|
||||
// Bootstrap: leader runs under lease; followers poll until ready
|
||||
await DaprWorkLeaseBootstrap.RunBootstrapUnderLeaseAsync(
|
||||
leases,
|
||||
"maksit-cicd-state",
|
||||
"bootstrap",
|
||||
runtimeInstance.InstanceId,
|
||||
TimeSpan.FromMinutes(5),
|
||||
bootstrap: async ct => { /* migrate / seed */ return Result.Ok(); },
|
||||
isReady: async ct => Result<bool>.Ok(await db.IsMigratedAsync(ct)),
|
||||
cancellationToken: ct);
|
||||
```
|
||||
|
||||
Pub/sub workers: implement `IDaprPubSubWorkHandler<T>` and map with `DaprPubSubAck.ToActionResult` (2xx ACK, 503 Busy → redelivery).
|
||||
For recurring exclusive sweeps, subclass `LeasedBackgroundService` and implement `ExecuteLeasedAsync` (uses `IDaprWorkLeaseOptions.StoreName` from `RegisterWorkLeases(storeName)`). Before lease races on cold start, call `IDaprSidecarService.WaitForSidecarAsync`.
|
||||
|
||||
### Actors — `IDaprActorService`
|
||||
|
||||
**When to use:** virtual-actor patterns — single-threaded access to an entity id (cart, device, session), turn-based concurrency, reminders/timers. Define actor interfaces and implementations in the product; this facade creates proxies and invokes methods with `Result` outcomes.
|
||||
|
||||
**When not to:** cluster-wide singleton jobs → [work leases](#ha-work-leases--idaprworkleaseservice); multi-step saga across many services → [workflows](#workflows--idaprworkflowservice).
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterActors(options => options.Actors.RegisterActor<T>())` |
|
||||
| **Pipeline** | `app.RegisterActorsHandlers()` |
|
||||
| **API** | `Create` / `Create<TActor>`, `InvokeAsync` overloads |
|
||||
|
||||
```csharp
|
||||
builder.Services.RegisterActors(o => o.Actors.RegisterActor<CounterActor>());
|
||||
app.RegisterActorsHandlers();
|
||||
|
||||
var created = actors.Create<ICounterActor>("cart-42", nameof(CounterActor));
|
||||
if (!created.IsSuccess)
|
||||
return created.ToResult().ToActionResult();
|
||||
|
||||
var count = await created.Value.IncrementAsync();
|
||||
```
|
||||
|
||||
### Workflows — `IDaprWorkflowService`
|
||||
|
||||
**When to use:** durable, multi-step business processes (order pipeline, approval flow) with history, wait-for-external-event, suspend/resume, and replay. Author `Workflow` / `WorkflowActivity` types in the product (auto-discovered on Dapr SDK 1.18+).
|
||||
|
||||
**When not to:** simple fire-and-forget messages → [pub/sub](#pubsub--idaprpubsubservice); single exclusive background job → [work leases](#ha-work-leases--idaprworkleaseservice).
|
||||
|
||||
| | |
|
||||
|--|--|
|
||||
| **DI** | `RegisterWorkflows(configure?)` |
|
||||
| **API** | `ScheduleAsync`, get/wait state, `RaiseEventAsync`, terminate/suspend/resume/purge, list IDs, history, `RerunFromEventAsync` |
|
||||
|
||||
```csharp
|
||||
builder.Services.RegisterWorkflows();
|
||||
|
||||
var scheduled = await workflows.ScheduleAsync(
|
||||
workflowName: nameof(OrderProcessingWorkflow),
|
||||
input: order,
|
||||
instanceId: order.Id,
|
||||
cancellationToken: ct);
|
||||
|
||||
if (!scheduled.IsSuccess)
|
||||
return scheduled.ToResult().ToActionResult();
|
||||
|
||||
var completed = await workflows.WaitForCompletionAsync(scheduled.Value, cancellationToken: ct);
|
||||
```
|
||||
|
||||
## Choosing a coordination pattern
|
||||
|
||||
| Scenario | Prefer |
|
||||
|----------|--------|
|
||||
| One replica runs migrate/bootstrap/sweep | **Work leases** |
|
||||
| Short critical section across processes | **Distributed lock** |
|
||||
| Per-entity single-threaded logic | **Actors** |
|
||||
| Long-running multi-step process with history | **Workflows** |
|
||||
| Decoupled async events | **Pub/sub** |
|
||||
| Sync call to another Dapr app | **Invocation** |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions to this project are welcome! Please fork the repository and submit a pull request with your changes. If you encounter any issues or have feature requests, feel free to open an issue on GitHub.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for build/test (RepoUtils), commit format, and release scripts.
|
||||
|
||||
## Contact
|
||||
|
||||
If you have any questions or need further assistance, feel free to reach out:
|
||||
|
||||
- **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com)
|
||||
|
||||
## License
|
||||
|
||||
See `LICENSE.md`.
|
||||
See `LICENSE.md`.
|
||||
|
||||
66
src/MaksIT.Dapr.Tests/DaprActorServiceTests.cs
Normal file
66
src/MaksIT.Dapr.Tests/DaprActorServiceTests.cs
Normal file
@ -0,0 +1,66 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Actors;
|
||||
using Dapr.Actors.Client;
|
||||
using Moq;
|
||||
using MaksIT.Dapr.Services;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
public class DaprActorServiceTests {
|
||||
[Fact]
|
||||
public void Create_ReturnsBadRequest_WhenActorIdEmpty() {
|
||||
var service = new DaprActorService(
|
||||
Mock.Of<ILogger<DaprActorService>>(),
|
||||
Mock.Of<IActorProxyFactory>());
|
||||
|
||||
var result = service.Create(" ", "MyActor");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Create_ReturnsOk_WhenFactorySucceeds() {
|
||||
var actor = ActorProxy.Create(new ActorId("1"), "MyActor");
|
||||
var factory = new Mock<IActorProxyFactory>();
|
||||
factory
|
||||
.Setup(f => f.Create(It.IsAny<ActorId>(), "MyActor", null))
|
||||
.Returns(actor);
|
||||
|
||||
var service = new DaprActorService(
|
||||
Mock.Of<ILogger<DaprActorService>>(),
|
||||
factory.Object);
|
||||
|
||||
var result = service.Create("1", "MyActor");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsBadRequest_WhenMethodEmpty() {
|
||||
var service = new DaprActorService(
|
||||
Mock.Of<ILogger<DaprActorService>>(),
|
||||
Mock.Of<IActorProxyFactory>());
|
||||
|
||||
var result = await service.InvokeAsync("1", "MyActor", " ");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsInternalServerError_WhenFactoryThrows() {
|
||||
var factory = new Mock<IActorProxyFactory>();
|
||||
factory
|
||||
.Setup(f => f.Create(It.IsAny<ActorId>(), "MyActor", null))
|
||||
.Throws(new InvalidOperationException("sidecar unavailable"));
|
||||
|
||||
var service = new DaprActorService(
|
||||
Mock.Of<ILogger<DaprActorService>>(),
|
||||
factory.Object);
|
||||
|
||||
var result = await service.InvokeAsync("1", "MyActor", "DoWork");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
}
|
||||
92
src/MaksIT.Dapr.Tests/DaprLockServiceTests.cs
Normal file
92
src/MaksIT.Dapr.Tests/DaprLockServiceTests.cs
Normal file
@ -0,0 +1,92 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using Moq;
|
||||
using MaksIT.Dapr.Services;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
#pragma warning disable DAPR_DISTRIBUTEDLOCK
|
||||
|
||||
public class DaprLockServiceTests {
|
||||
[Fact]
|
||||
public async Task LockAsync_ReturnsOk_WhenClientSucceeds() {
|
||||
var response = new TryLockResponse();
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.Lock("lock-store", "resource", "owner", 30, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(response);
|
||||
|
||||
var service = new DaprLockService(Mock.Of<ILogger<DaprLockService>>(), clientMock.Object);
|
||||
|
||||
var result = await service.LockAsync("lock-store", "resource", "owner", 30);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Same(response, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockAsync_ReturnsBadRequest_WhenArgsInvalid() {
|
||||
var service = new DaprLockService(Mock.Of<ILogger<DaprLockService>>(), Mock.Of<DaprClient>());
|
||||
|
||||
var empty = await service.LockAsync(" ", "resource", "owner", 30);
|
||||
var badExpiry = await service.LockAsync("lock-store", "resource", "owner", 0);
|
||||
|
||||
Assert.False(empty.IsSuccess);
|
||||
Assert.False(badExpiry.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockAsync_ReturnsInternalServerError_WhenClientFails() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.Lock(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("lock failed"));
|
||||
|
||||
var service = new DaprLockService(Mock.Of<ILogger<DaprLockService>>(), clientMock.Object);
|
||||
|
||||
var result = await service.LockAsync("lock-store", "resource", "owner", 30);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LockAsync_Rethrows_WhenCanceled() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.Lock(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<int>(), It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
var service = new DaprLockService(Mock.Of<ILogger<DaprLockService>>(), clientMock.Object);
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
service.LockAsync("lock-store", "resource", "owner", 30));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnlockAsync_ReturnsOk_WhenClientSucceeds() {
|
||||
var response = new UnlockResponse(LockStatus.Success);
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.Unlock("lock-store", "resource", "owner", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(response);
|
||||
|
||||
var service = new DaprLockService(Mock.Of<ILogger<DaprLockService>>(), clientMock.Object);
|
||||
|
||||
var result = await service.UnlockAsync("lock-store", "resource", "owner");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Same(response, result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnlockAsync_ReturnsBadRequest_WhenArgsEmpty() {
|
||||
var service = new DaprLockService(Mock.Of<ILogger<DaprLockService>>(), Mock.Of<DaprClient>());
|
||||
|
||||
var result = await service.UnlockAsync("lock-store", " ", "owner");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore DAPR_DISTRIBUTEDLOCK
|
||||
@ -1,11 +1,12 @@
|
||||
using Dapr.Client;
|
||||
using MaksIT.Dapr.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using Moq;
|
||||
using MaksIT.Dapr.Services;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
public class DaprPublisherServiceTests {
|
||||
public class DaprPubSubServiceTests {
|
||||
[Fact]
|
||||
public async Task PublishEventAsync_ReturnsOk_WhenPublishSucceeds() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
@ -17,8 +18,8 @@ public class DaprPublisherServiceTests {
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var service = new DaprPublisherService(
|
||||
Mock.Of<ILogger<DaprPublisherService>>(),
|
||||
var service = new DaprPubSubService(
|
||||
Mock.Of<ILogger<DaprPubSubService>>(),
|
||||
clientMock.Object);
|
||||
object payload = new { Name = "payload" };
|
||||
|
||||
@ -38,8 +39,8 @@ public class DaprPublisherServiceTests {
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("publish failed"));
|
||||
|
||||
var service = new DaprPublisherService(
|
||||
Mock.Of<ILogger<DaprPublisherService>>(),
|
||||
var service = new DaprPubSubService(
|
||||
Mock.Of<ILogger<DaprPubSubService>>(),
|
||||
clientMock.Object);
|
||||
object payload = new { Name = "payload" };
|
||||
|
||||
@ -47,4 +48,34 @@ public class DaprPublisherServiceTests {
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishEventAsync_ReturnsBadRequest_WhenPubsubOrTopicEmpty() {
|
||||
var service = new DaprPubSubService(
|
||||
Mock.Of<ILogger<DaprPubSubService>>(),
|
||||
Mock.Of<DaprClient>());
|
||||
|
||||
var result = await service.PublishEventAsync(" ", "topic", new { });
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PublishEventAsync_Rethrows_WhenCanceled() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.PublishEventAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<object>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
var service = new DaprPubSubService(
|
||||
Mock.Of<ILogger<DaprPubSubService>>(),
|
||||
clientMock.Object);
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
service.PublishEventAsync("pubsub", "topic", new { }));
|
||||
}
|
||||
}
|
||||
13
src/MaksIT.Dapr.Tests/DaprRuntimeInstanceIdProviderTests.cs
Normal file
13
src/MaksIT.Dapr.Tests/DaprRuntimeInstanceIdProviderTests.cs
Normal file
@ -0,0 +1,13 @@
|
||||
using MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
public class DaprRuntimeInstanceIdProviderTests {
|
||||
[Fact]
|
||||
public void InstanceId_IsNonEmpty() {
|
||||
var provider = new DaprRuntimeInstanceIdProvider();
|
||||
|
||||
Assert.False(string.IsNullOrWhiteSpace(provider.InstanceId));
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,9 @@
|
||||
using Dapr.Client;
|
||||
using MaksIT.Dapr.Services;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Grpc.Core;
|
||||
using Dapr.Client;
|
||||
using Moq;
|
||||
using MaksIT.Dapr.Services;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
@ -28,6 +30,17 @@ public class DaprStateStoreServiceTests {
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStateAsync_ReturnsBadRequest_WhenStoreOrKeyEmpty() {
|
||||
var service = new DaprStateStoreService(
|
||||
Mock.Of<ILogger<DaprStateStoreService>>(),
|
||||
Mock.Of<DaprClient>());
|
||||
|
||||
var result = await service.SetStateAsync(" ", "key", "value");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_ReturnsOk_WhenStateExists() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
@ -51,7 +64,7 @@ public class DaprStateStoreServiceTests {
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_ReturnsNotFound_WhenStateIsNull() {
|
||||
public async Task GetStateAsync_ReturnsOkNull_WhenStateIsNull() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.GetStateAsync<string?>(
|
||||
@ -68,7 +81,7 @@ public class DaprStateStoreServiceTests {
|
||||
|
||||
var result = await service.GetStateAsync<string>("store", "key");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(result.Value);
|
||||
}
|
||||
|
||||
@ -92,4 +105,138 @@ public class DaprStateStoreServiceTests {
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteStateAsync_ReturnsOk_WhenJetStreamKeyNotFound() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.DeleteStateAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<StateOptions>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(CreateJetStreamKeyNotFoundException("missing", "store"));
|
||||
|
||||
var logger = new Mock<ILogger<DaprStateStoreService>>();
|
||||
var service = new DaprStateStoreService(logger.Object, clientMock.Object);
|
||||
|
||||
var result = await service.DeleteStateAsync("store", "key");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAndETagAsync_ReturnsEmpty_WhenJetStreamKeyNotFound() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.GetStateAndETagAsync<string?>(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(CreateJetStreamKeyNotFoundException("identity-hub-otc-cleanup", "maksit-identity-hub-state"));
|
||||
|
||||
var logger = new Mock<ILogger<DaprStateStoreService>>();
|
||||
var service = new DaprStateStoreService(logger.Object, clientMock.Object);
|
||||
|
||||
var result = await service.GetStateAndETagAsync<string>("store", "key");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(result.Value.Value);
|
||||
Assert.Null(result.Value.ETag);
|
||||
logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_ReturnsOkNull_WhenJetStreamKeyNotFound() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.GetStateAsync<string?>(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(CreateJetStreamKeyNotFoundException("missing-key", "store"));
|
||||
|
||||
var logger = new Mock<ILogger<DaprStateStoreService>>();
|
||||
var service = new DaprStateStoreService(logger.Object, clientMock.Object);
|
||||
|
||||
var result = await service.GetStateAsync<string>("store", "missing-key");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Null(result.Value);
|
||||
logger.Verify(
|
||||
x => x.Log(
|
||||
LogLevel.Error,
|
||||
It.IsAny<EventId>(),
|
||||
It.IsAny<It.IsAnyType>(),
|
||||
It.IsAny<Exception?>(),
|
||||
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
||||
Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAndETagAsync_ReturnsInternalServerError_WhenOtherFailure() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.GetStateAndETagAsync<string?>(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new InvalidOperationException("connection refused"));
|
||||
|
||||
var service = new DaprStateStoreService(
|
||||
Mock.Of<ILogger<DaprStateStoreService>>(),
|
||||
clientMock.Object);
|
||||
|
||||
var result = await service.GetStateAndETagAsync<string>("store", "key");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStateAsync_Rethrows_WhenCanceled() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.SaveStateAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<StateOptions>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ThrowsAsync(new OperationCanceledException());
|
||||
|
||||
var service = new DaprStateStoreService(
|
||||
Mock.Of<ILogger<DaprStateStoreService>>(),
|
||||
clientMock.Object);
|
||||
|
||||
await Assert.ThrowsAsync<OperationCanceledException>(() =>
|
||||
service.SetStateAsync("store", "key", "value"));
|
||||
}
|
||||
|
||||
private static RpcException CreateJetStreamKeyNotFoundException(string key, string storeName) {
|
||||
var detail = $"fail to get {key} from state store {storeName}: nats: key not found";
|
||||
return new RpcException(new Status(StatusCode.Internal, detail));
|
||||
}
|
||||
}
|
||||
|
||||
486
src/MaksIT.Dapr.Tests/DaprWorkLeaseServiceTests.cs
Normal file
486
src/MaksIT.Dapr.Tests/DaprWorkLeaseServiceTests.cs
Normal file
@ -0,0 +1,486 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using Moq;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Dapr.Services;
|
||||
using MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
public class DaprWorkLeaseServiceTests {
|
||||
private static Mock<IDaprStateStoreService> CreateStateMock() => new();
|
||||
|
||||
[Fact]
|
||||
public async Task TryAcquireAsync_Succeeds_WhenKeyMissing() {
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((null, null)));
|
||||
state
|
||||
.Setup(s => s.TrySaveStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<DaprWorkLease>(),
|
||||
null,
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<bool>.Ok(true));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.TryAcquireAsync("store", "work", "pod-a", TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAcquireAsync_Fails_WhenHeldByOtherAndNotExpired() {
|
||||
var lease = new DaprWorkLease("pod-b", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(5), 3);
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "1")));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.TryAcquireAsync("store", "work", "pod-a", TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAcquireAsync_TakesOver_WhenLeaseExpired_AndBumpsGeneration() {
|
||||
var lease = new DaprWorkLease("pod-b", DateTimeOffset.UtcNow.AddMinutes(-10), DateTimeOffset.UtcNow.AddMinutes(-5), 7);
|
||||
DaprWorkLease? saved = null;
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "1")));
|
||||
state
|
||||
.Setup(s => s.TrySaveStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<DaprWorkLease>(),
|
||||
"1",
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string, DaprWorkLease, string?, StateOptions?, IReadOnlyDictionary<string, string>?, CancellationToken>(
|
||||
(_, _, value, _, _, _, _) => saved = value)
|
||||
.ReturnsAsync(Result<bool>.Ok(true));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.TryAcquireAsync("store", "work", "pod-a", TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value);
|
||||
Assert.Equal(8, saved!.Generation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAcquireAsync_ReturnsBadRequest_WhenTtlNonPositive() {
|
||||
var service = new DaprWorkLeaseService(Mock.Of<IDaprStateStoreService>());
|
||||
var result = await service.TryAcquireAsync("store", "work", "pod-a", TimeSpan.Zero);
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRenewAsync_Succeeds_WhenSameHolder() {
|
||||
var lease = new DaprWorkLease("pod-a", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(1), 2);
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "2")));
|
||||
state
|
||||
.Setup(s => s.TrySaveStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<DaprWorkLease>(),
|
||||
"2",
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<bool>.Ok(true));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.TryRenewAsync("store", "work", "pod-a", TimeSpan.FromMinutes(5));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryRenewAsync_Fails_WhenDifferentHolder() {
|
||||
var lease = new DaprWorkLease("pod-b", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(1));
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "2")));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.TryRenewAsync("store", "work", "pod-a", TimeSpan.FromMinutes(5));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseAsync_Deletes_WhenSameHolder() {
|
||||
var lease = new DaprWorkLease("pod-a", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(1));
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "3")));
|
||||
state
|
||||
.Setup(s => s.DeleteStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.ReleaseAsync("store", "work", "pod-a");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
state.Verify(s => s.DeleteStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseAsync_ReturnsConflict_WhenDifferentHolder() {
|
||||
var lease = new DaprWorkLease("pod-b", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(1));
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "3")));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.ReleaseAsync("store", "work", "pod-a");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReleaseAsync_ReturnsOk_WhenKeyMissing() {
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((null, null)));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.ReleaseAsync("store", "work", "pod-a");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsLease_WhenPresent() {
|
||||
var lease = new DaprWorkLease("pod-a", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(1), 4);
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "1")));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var result = await service.GetAsync("store", "work");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("pod-a", result.Value!.HolderId);
|
||||
Assert.Equal(4, result.Value.Generation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryHoldAsync_ReturnsHold_WhenAcquired() {
|
||||
var state = CreateStateMock();
|
||||
DaprWorkLease? saved = null;
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(() => Result<(DaprWorkLease? Value, string? ETag)>.Ok((saved, saved is null ? null : "1")));
|
||||
state
|
||||
.Setup(s => s.TrySaveStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<DaprWorkLease>(),
|
||||
It.IsAny<string?>(),
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Callback<string, string, DaprWorkLease, string?, StateOptions?, IReadOnlyDictionary<string, string>?, CancellationToken>(
|
||||
(_, _, value, _, _, _, _) => saved = value)
|
||||
.ReturnsAsync(Result<bool>.Ok(true));
|
||||
state
|
||||
.Setup(s => s.DeleteStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var holdResult = await service.TryHoldAsync("store", "work", "pod-a", TimeSpan.FromMinutes(1), autoRenew: false);
|
||||
|
||||
Assert.True(holdResult.IsSuccess);
|
||||
Assert.NotNull(holdResult.Value);
|
||||
Assert.Equal(1, holdResult.Value!.Generation);
|
||||
|
||||
await holdResult.Value.DisposeAsync();
|
||||
state.Verify(s => s.DeleteStateAsync(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<StateOptions?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryHoldAsync_ReturnsNull_WhenBusy() {
|
||||
var lease = new DaprWorkLease("pod-b", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(5), 1);
|
||||
var state = CreateStateMock();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>(
|
||||
"store",
|
||||
"work",
|
||||
It.IsAny<ConsistencyMode?>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>?>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "1")));
|
||||
|
||||
var service = new DaprWorkLeaseService(state.Object);
|
||||
var holdResult = await service.TryHoldAsync("store", "work", "pod-a", TimeSpan.FromMinutes(1), autoRenew: false);
|
||||
|
||||
Assert.True(holdResult.IsSuccess);
|
||||
Assert.Null(holdResult.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprWorkLeaseBootstrapTests {
|
||||
[Fact]
|
||||
public async Task RunBootstrapUnderLeaseAsync_RunsBootstrap_WhenLeaseAcquired() {
|
||||
var leases = new Mock<IDaprWorkLeaseService>();
|
||||
var hold = new DaprWorkLeaseHold(leases.Object, "store", "boot", "pod-a", TimeSpan.FromMinutes(1), 1, autoRenew: false);
|
||||
leases
|
||||
.Setup(l => l.TryHoldAsync("store", "boot", "pod-a", It.IsAny<TimeSpan>(), true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<DaprWorkLeaseHold?>.Ok(hold));
|
||||
leases
|
||||
.Setup(l => l.ReleaseAsync("store", "boot", "pod-a", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result.Ok());
|
||||
|
||||
var ran = false;
|
||||
var result = await DaprWorkLeaseBootstrap.RunBootstrapUnderLeaseAsync(
|
||||
leases.Object,
|
||||
"store",
|
||||
"boot",
|
||||
"pod-a",
|
||||
TimeSpan.FromMinutes(1),
|
||||
_ => {
|
||||
ran = true;
|
||||
return Task.FromResult(Result.Ok());
|
||||
},
|
||||
_ => Task.FromResult(Result<bool>.Ok(true)));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(ran);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunBootstrapUnderLeaseAsync_WaitsForReady_WhenFollower() {
|
||||
var leases = new Mock<IDaprWorkLeaseService>();
|
||||
leases
|
||||
.Setup(l => l.TryHoldAsync("store", "boot", "pod-b", It.IsAny<TimeSpan>(), true, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Result<DaprWorkLeaseHold?>.Ok(null));
|
||||
|
||||
var polls = 0;
|
||||
var result = await DaprWorkLeaseBootstrap.RunBootstrapUnderLeaseAsync(
|
||||
leases.Object,
|
||||
"store",
|
||||
"boot",
|
||||
"pod-b",
|
||||
TimeSpan.FromMinutes(1),
|
||||
_ => Task.FromResult(Result.Ok()),
|
||||
_ => {
|
||||
polls++;
|
||||
return Task.FromResult(Result<bool>.Ok(polls >= 2));
|
||||
},
|
||||
followerPollInterval: TimeSpan.FromMilliseconds(1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(polls >= 2);
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprStateStoreETagTests {
|
||||
[Fact]
|
||||
public async Task TrySaveStateAsync_ReturnsOkFalse_WhenClientReturnsFalse() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.TrySaveStateAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<StateOptions>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var service = new DaprStateStoreService(Mock.Of<ILogger<DaprStateStoreService>>(), clientMock.Object);
|
||||
var result = await service.TrySaveStateAsync("store", "key", "value", "etag");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprInvocationServiceTests {
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsBadRequest_WhenAppIdEmpty() {
|
||||
var service = new DaprInvocationService(Mock.Of<ILogger<DaprInvocationService>>(), Mock.Of<DaprClient>());
|
||||
var result = await service.InvokeAsync(" ", "method");
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_PostsViaInvokableHttpClient_AndReturnsOk() {
|
||||
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK));
|
||||
using var http = new HttpClient(handler) { BaseAddress = new Uri("http://orders/") };
|
||||
|
||||
var client = new Mock<DaprClient>();
|
||||
client.Setup(c => c.CreateInvokableHttpClient("orders")).Returns(http);
|
||||
client.SetupGet(c => c.JsonSerializerOptions).Returns(new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
using var service = new DaprInvocationService(Mock.Of<ILogger<DaprInvocationService>>(), client.Object);
|
||||
var result = await service.InvokeAsync("orders", "create", new { Id = 1 });
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(HttpMethod.Post, handler.LastMethod);
|
||||
Assert.Equal(new Uri("http://orders/create"), handler.LastUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_DeserializesJsonResponse() {
|
||||
var handler = new RecordingHandler(new HttpResponseMessage(HttpStatusCode.OK) {
|
||||
Content = new StringContent("""{"total":42}""", Encoding.UTF8, "application/json")
|
||||
});
|
||||
using var http = new HttpClient(handler) { BaseAddress = new Uri("http://orders/") };
|
||||
|
||||
var client = new Mock<DaprClient>();
|
||||
client.Setup(c => c.CreateInvokableHttpClient("orders")).Returns(http);
|
||||
client.SetupGet(c => c.JsonSerializerOptions).Returns(new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
using var service = new DaprInvocationService(Mock.Of<ILogger<DaprInvocationService>>(), client.Object);
|
||||
var result = await service.InvokeAsync<OrderTotal>("orders", "total");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(42, result.Value?.Total);
|
||||
}
|
||||
|
||||
private sealed class OrderTotal {
|
||||
public int Total { get; set; }
|
||||
}
|
||||
|
||||
private sealed class RecordingHandler(HttpResponseMessage response) : HttpMessageHandler {
|
||||
public HttpMethod? LastMethod { get; private set; }
|
||||
public Uri? LastUri { get; private set; }
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
|
||||
LastMethod = request.Method;
|
||||
LastUri = request.RequestUri;
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprSidecarServiceTests {
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_ReturnsOk_WhenHealthy() {
|
||||
var client = new Mock<DaprClient>();
|
||||
client.Setup(c => c.CheckHealthAsync(It.IsAny<CancellationToken>())).ReturnsAsync(true);
|
||||
|
||||
var service = new DaprSidecarService(Mock.Of<ILogger<DaprSidecarService>>(), client.Object);
|
||||
var result = await service.CheckHealthAsync();
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprSecretServiceTests {
|
||||
[Fact]
|
||||
public async Task GetAsync_ReturnsBadRequest_WhenStoreEmpty() {
|
||||
var service = new DaprSecretService(Mock.Of<ILogger<DaprSecretService>>(), Mock.Of<DaprClient>());
|
||||
var result = await service.GetAsync(" ", "key");
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprBindingServiceTests {
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsBadRequest_WhenBindingEmpty() {
|
||||
var service = new DaprBindingService(Mock.Of<ILogger<DaprBindingService>>(), Mock.Of<DaprClient>());
|
||||
var result = await service.InvokeAsync(" ", "create", new { });
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
using Dapr.Client;
|
||||
using MaksIT.Dapr.PubSub;
|
||||
using MaksIT.Dapr.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
public class DaprWorkLeaseStoreTests {
|
||||
[Fact]
|
||||
public async Task TryAcquireAsync_Succeeds_WhenKeyMissing() {
|
||||
var state = new Mock<IDaprStateStoreService>();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>("store", "work", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(MaksIT.Results.Result<(DaprWorkLease? Value, string? ETag)>.Ok((null, null)));
|
||||
state
|
||||
.Setup(s => s.TrySaveStateAsync("store", "work", It.IsAny<DaprWorkLease>(), null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(MaksIT.Results.Result<bool>.Ok(true));
|
||||
|
||||
var store = new DaprWorkLeaseStore(state.Object);
|
||||
var result = await store.TryAcquireAsync("store", "work", "pod-a", TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.True(result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TryAcquireAsync_Fails_WhenHeldByOtherAndNotExpired() {
|
||||
var lease = new DaprWorkLease("pod-b", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddMinutes(5));
|
||||
var state = new Mock<IDaprStateStoreService>();
|
||||
state
|
||||
.Setup(s => s.GetStateAndETagAsync<DaprWorkLease>("store", "work", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(MaksIT.Results.Result<(DaprWorkLease? Value, string? ETag)>.Ok((lease, "1")));
|
||||
|
||||
var store = new DaprWorkLeaseStore(state.Object);
|
||||
var result = await store.TryAcquireAsync("store", "work", "pod-a", TimeSpan.FromMinutes(1));
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Value);
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprPubSubAckTests {
|
||||
[Fact]
|
||||
public void ToActionResult_Busy_Returns503() {
|
||||
var result = DaprPubSubAck.ToActionResult(DaprPubSubAcceptResult.Busy("full"));
|
||||
var objectResult = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status503ServiceUnavailable, objectResult.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToActionResult_Accepted_Returns200() {
|
||||
var result = DaprPubSubAck.ToActionResult(DaprPubSubAcceptResult.Accepted());
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
}
|
||||
}
|
||||
|
||||
public class DaprStateStoreETagTests {
|
||||
[Fact]
|
||||
public async Task TrySaveStateAsync_ReturnsOkFalse_WhenClientReturnsFalse() {
|
||||
var clientMock = new Mock<DaprClient>();
|
||||
clientMock
|
||||
.Setup(x => x.TrySaveStateAsync(
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<string>(),
|
||||
It.IsAny<StateOptions>(),
|
||||
It.IsAny<IReadOnlyDictionary<string, string>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(false);
|
||||
|
||||
var service = new DaprStateStoreService(Mock.Of<ILogger<DaprStateStoreService>>(), clientMock.Object);
|
||||
var result = await service.TrySaveStateAsync("store", "key", "value", "etag");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.Value);
|
||||
}
|
||||
}
|
||||
70
src/MaksIT.Dapr.Tests/DaprWorkflowServiceTests.cs
Normal file
70
src/MaksIT.Dapr.Tests/DaprWorkflowServiceTests.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Workflow;
|
||||
using Moq;
|
||||
using MaksIT.Dapr.Services;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
public class DaprWorkflowServiceTests {
|
||||
[Fact]
|
||||
public async Task ScheduleAsync_ReturnsBadRequest_WhenWorkflowNameEmpty() {
|
||||
var service = new DaprWorkflowService(
|
||||
Mock.Of<ILogger<DaprWorkflowService>>(),
|
||||
Mock.Of<IDaprWorkflowClient>());
|
||||
|
||||
var result = await service.ScheduleAsync(" ");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetStateAsync_ReturnsBadRequest_WhenInstanceIdEmpty() {
|
||||
var service = new DaprWorkflowService(
|
||||
Mock.Of<ILogger<DaprWorkflowService>>(),
|
||||
Mock.Of<IDaprWorkflowClient>());
|
||||
|
||||
var result = await service.GetStateAsync(" ");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RaiseEventAsync_ReturnsBadRequest_WhenEventNameEmpty() {
|
||||
var service = new DaprWorkflowService(
|
||||
Mock.Of<ILogger<DaprWorkflowService>>(),
|
||||
Mock.Of<IDaprWorkflowClient>());
|
||||
|
||||
var result = await service.RaiseEventAsync("instance-1", " ");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TerminateAsync_ReturnsBadRequest_WhenInstanceIdEmpty() {
|
||||
var service = new DaprWorkflowService(
|
||||
Mock.Of<ILogger<DaprWorkflowService>>(),
|
||||
Mock.Of<IDaprWorkflowClient>());
|
||||
|
||||
var result = await service.TerminateAsync(" ");
|
||||
|
||||
Assert.False(result.IsSuccess);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ScheduleAsync_ReturnsOk_WhenClientSucceeds() {
|
||||
var client = new Mock<IDaprWorkflowClient>();
|
||||
client
|
||||
.Setup(c => c.ScheduleNewWorkflowAsync("OrderFlow", null, null, null, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync("instance-1");
|
||||
|
||||
var service = new DaprWorkflowService(
|
||||
Mock.Of<ILogger<DaprWorkflowService>>(),
|
||||
client.Object);
|
||||
|
||||
var result = await service.ScheduleAsync("OrderFlow");
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("instance-1", result.Value);
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@ -12,7 +13,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<PackageReference Include="Moq" Version="4.*" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
|
||||
<PackageReference Include="xunit.v3" Version="3.*" />
|
||||
|
||||
113
src/MaksIT.Dapr.Tests/ServiceCollectionExtensionsTests.cs
Normal file
113
src/MaksIT.Dapr.Tests/ServiceCollectionExtensionsTests.cs
Normal file
@ -0,0 +1,113 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Dapr.Actors.Client;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Dapr.Extensions;
|
||||
using MaksIT.Dapr.Services;
|
||||
using MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Tests;
|
||||
|
||||
public class ServiceCollectionExtensionsTests {
|
||||
[Fact]
|
||||
public void RegisterPubSub_AndStateStore_RegisterSingleDaprClient() {
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.RegisterPubSub();
|
||||
services.RegisterStateStore();
|
||||
|
||||
Assert.Equal(1, services.Count(d => d.ServiceType == typeof(DaprClient)));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprPubSubService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprStateStoreService));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterDaprClientFacades_RegistersClientBackedServices() {
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.RegisterDaprClientFacades();
|
||||
|
||||
Assert.Equal(1, services.Count(d => d.ServiceType == typeof(DaprClient)));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprPubSubService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprStateStoreService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprInvocationService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprBindingService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprSecretService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprConfigurationService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprCryptographyService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprSidecarService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprLockService));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterLock_RegistersLockService() {
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.RegisterLock();
|
||||
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprLockService));
|
||||
Assert.Equal(1, services.Count(d => d.ServiceType == typeof(DaprClient)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterWorkLeases_RegistersLeaseAndInstanceId() {
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.RegisterWorkLeases();
|
||||
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprWorkLeaseService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprRuntimeInstanceId));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprStateStoreService));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterWorkLeases_WithStoreName_RegistersOptions() {
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.RegisterWorkLeases("my-state");
|
||||
|
||||
var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IDaprWorkLeaseOptions>();
|
||||
Assert.Equal("my-state", options.StoreName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDaprClientOnce_IsPerCollection_NotProcessWide() {
|
||||
var first = new ServiceCollection();
|
||||
first.AddLogging();
|
||||
first.RegisterStateStore();
|
||||
|
||||
var second = new ServiceCollection();
|
||||
second.AddLogging();
|
||||
second.RegisterStateStore();
|
||||
|
||||
Assert.Equal(1, first.Count(d => d.ServiceType == typeof(DaprClient)));
|
||||
Assert.Equal(1, second.Count(d => d.ServiceType == typeof(DaprClient)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterActors_RegistersActorService() {
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.RegisterActors();
|
||||
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprActorService));
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IActorProxyFactory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RegisterWorkflows_RegistersWorkflowService() {
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
|
||||
services.RegisterWorkflows();
|
||||
|
||||
Assert.Contains(services, d => d.ServiceType == typeof(IDaprWorkflowService));
|
||||
}
|
||||
}
|
||||
@ -1,36 +1,160 @@
|
||||
using MaksIT.Dapr.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Dapr.Actors.Client;
|
||||
using Dapr.Actors.Runtime;
|
||||
using Dapr.Client;
|
||||
using Dapr.Workflow;
|
||||
using MaksIT.Dapr.Services;
|
||||
using MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// DI registration helpers for MaksIT.Dapr services.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions {
|
||||
private static bool _isDaprClientRegistered;
|
||||
|
||||
private static void AddDaprClientOnce(this IServiceCollection services) {
|
||||
if (_isDaprClientRegistered)
|
||||
if (services.Any(d => d.ServiceType == typeof(DaprClient)))
|
||||
return;
|
||||
|
||||
services.AddDaprClient();
|
||||
_isDaprClientRegistered = true;
|
||||
}
|
||||
|
||||
public static void RegisterPublisher(this IServiceCollection services) {
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprPubSubService"/> and a <see cref="DaprClient"/> when missing.
|
||||
/// </summary>
|
||||
public static void RegisterPubSub(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprPublisherService, DaprPublisherService>();
|
||||
services.AddSingleton<IDaprPubSubService, DaprPubSubService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprStateStoreService"/> and a <see cref="DaprClient"/> when missing.
|
||||
/// </summary>
|
||||
public static void RegisterStateStore(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprStateStoreService, DaprStateStoreService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Dapr state store plus HA work-lease coordination and runtime instance id.
|
||||
/// Registers <see cref="IDaprInvocationService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterInvocation(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprInvocationService, DaprInvocationService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprBindingService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterBinding(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprBindingService, DaprBindingService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprSecretService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterSecrets(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprSecretService, DaprSecretService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprConfigurationService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterConfiguration(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprConfigurationService, DaprConfigurationService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprCryptographyService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterCryptography(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprCryptographyService, DaprCryptographyService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprSidecarService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterSidecar(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprSidecarService, DaprSidecarService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers <see cref="IDaprLockService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterLock(this IServiceCollection services) {
|
||||
services.AddDaprClientOnce();
|
||||
services.AddSingleton<IDaprLockService, DaprLockService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers all <see cref="DaprClient"/>-backed facades (pub/sub, state, invocation, binding, secrets,
|
||||
/// configuration, cryptography, sidecar, lock). Actors, workflows, and work leases stay separate.
|
||||
/// </summary>
|
||||
public static void RegisterDaprClientFacades(this IServiceCollection services) {
|
||||
services.RegisterPubSub();
|
||||
services.RegisterStateStore();
|
||||
services.RegisterInvocation();
|
||||
services.RegisterBinding();
|
||||
services.RegisterSecrets();
|
||||
services.RegisterConfiguration();
|
||||
services.RegisterCryptography();
|
||||
services.RegisterSidecar();
|
||||
services.RegisterLock();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Dapr state store plus HA work-lease service and runtime instance id.
|
||||
/// </summary>
|
||||
public static void RegisterWorkLeases(this IServiceCollection services) {
|
||||
services.RegisterStateStore();
|
||||
services.AddSingleton<IDaprRuntimeInstanceId, DaprRuntimeInstanceIdProvider>();
|
||||
services.AddSingleton<IDaprWorkLeaseStore, DaprWorkLeaseStore>();
|
||||
services.AddSingleton<IDaprWorkLeaseService, DaprWorkLeaseService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers work leases and binds the default Dapr state Component name for <see cref="LeasedBackgroundService"/>.
|
||||
/// </summary>
|
||||
public static void RegisterWorkLeases(this IServiceCollection services, string storeName) {
|
||||
if (string.IsNullOrWhiteSpace(storeName))
|
||||
throw new ArgumentException("storeName is required.", nameof(storeName));
|
||||
|
||||
services.RegisterWorkLeases();
|
||||
services.AddSingleton<IDaprWorkLeaseOptions>(new DaprWorkLeaseOptions { StoreName = storeName });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Dapr actor runtime and <see cref="IDaprActorService"/>.
|
||||
/// Use <paramref name="configure"/> to <c>options.Actors.RegisterActor<T>()</c> and tune idle/drain settings.
|
||||
/// Pair with <see cref="WebApplicationExtensions.RegisterActorsHandlers"/>.
|
||||
/// </summary>
|
||||
public static void RegisterActors(this IServiceCollection services, Action<ActorRuntimeOptions>? configure = null) {
|
||||
if (!services.Any(d => d.ServiceType == typeof(IActorProxyFactory)))
|
||||
services.AddActors(options => configure?.Invoke(options));
|
||||
|
||||
services.AddSingleton<IDaprActorService, DaprActorService>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers Dapr workflow runtime/client and <see cref="IDaprWorkflowService"/>.
|
||||
/// From Dapr SDK 1.18+, workflows/activities in the entry assembly are auto-discovered by the source generator;
|
||||
/// pass <paramref name="configure"/> only for explicit <c>RegisterWorkflow</c> / <c>RegisterActivity</c> or other
|
||||
/// <see cref="WorkflowRuntimeOptions"/> tuning. Types in referenced assemblies need
|
||||
/// <c>DaprWorkflowVersioningScanReferences</c> in the host <c>.csproj</c>.
|
||||
/// </summary>
|
||||
public static void RegisterWorkflows(this IServiceCollection services, Action<WorkflowRuntimeOptions>? configure = null) {
|
||||
if (!services.Any(d => d.ServiceType == typeof(DaprWorkflowClient) || d.ServiceType == typeof(IDaprWorkflowClient))) {
|
||||
if (configure is null)
|
||||
services.AddDaprWorkflow();
|
||||
else
|
||||
services.AddDaprWorkflow(configure);
|
||||
}
|
||||
|
||||
services.AddSingleton<IDaprWorkflowService, DaprWorkflowService>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,11 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// ASP.NET Core pipeline helpers for Dapr pub/sub and actors.
|
||||
/// </summary>
|
||||
public static class WebApplicationExtensions {
|
||||
|
||||
/// <summary>
|
||||
@ -9,7 +13,7 @@ public static class WebApplicationExtensions {
|
||||
/// and authorization but before any other middleware that needs to process incoming HTTP requests.
|
||||
/// This ensures that the UseCloudEvents and MapSubscribeHandler middleware has access to the raw HTTP request data
|
||||
/// before it is processed by other middleware.
|
||||
///
|
||||
///
|
||||
/// <para>If you need to set controller as subscriber, use [Topic("pubsubName", "name")] attribute
|
||||
/// where:
|
||||
/// <list type="table">
|
||||
@ -24,9 +28,17 @@ public static class WebApplicationExtensions {
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="app"></param>
|
||||
/// <param name="app">The application builder.</param>
|
||||
public static void RegisterSubscriber(this WebApplication app) {
|
||||
app.UseCloudEvents();
|
||||
app.MapSubscribeHandler();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Dapr actor HTTP handlers. Call after <see cref="ServiceCollectionExtensions.RegisterActors"/>
|
||||
/// (typically near endpoint mapping; avoid HTTPS redirection before actors in development sidecars).
|
||||
/// </summary>
|
||||
/// <param name="app">The application builder.</param>
|
||||
public static void RegisterActorsHandlers(this WebApplication app) =>
|
||||
app.MapActorsHandlers();
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
|
||||
<!-- NuGet package metadata -->
|
||||
<PackageId>MaksIT.Dapr</PackageId>
|
||||
<Version>2.1.0</Version>
|
||||
<Version>2.2.1</Version>
|
||||
<Authors>Maksym Sadovnychyy</Authors>
|
||||
<Company>MAKS-IT</Company>
|
||||
<Product>MaksIT.Dapr</Product>
|
||||
<Description>MaksIT.Dapr is a facade library for Dapr.</Description>
|
||||
<Description>MaksIT.Dapr Result facades for Dapr pub/sub, state, invocation, bindings, secrets, configuration, cryptography, sidecar, lock, actors, workflows, and HA work-lease helpers.</Description>
|
||||
<PackageTags>dotnet;dapr;</PackageTags>
|
||||
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-core-dapr</RepositoryUrl>
|
||||
<License>MIT</License>
|
||||
@ -21,21 +22,17 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapr.Actors.AspNetCore" Version="1.18.4" />
|
||||
<PackageReference Include="Dapr.AspNetCore" Version="1.18.4" />
|
||||
<PackageReference Include="Dapr.Workflow" Version="1.18.4" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.8" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.3" />
|
||||
<PackageReference Include="Dapr.Actors.AspNetCore" Version="1.18.5" />
|
||||
<PackageReference Include="Dapr.AspNetCore" Version="1.18.5" />
|
||||
<PackageReference Include="Dapr.Workflow" Version="1.18.5" />
|
||||
<PackageReference Include="MaksIT.Core" Version="1.6.9" />
|
||||
<PackageReference Include="MaksIT.Results" Version="2.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
|
||||
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
|
||||
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.md" />
|
||||
|
||||
<None Include="..\..\assets\badges\**\*" Link="assets\badges\%(RecursiveDir)%(Filename)%(Extension)">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.PubSub;
|
||||
|
||||
public enum DaprPubSubAcceptOutcome {
|
||||
Accepted = 0,
|
||||
AlreadyHandled = 1,
|
||||
Busy = 2,
|
||||
Rejected = 3,
|
||||
}
|
||||
|
||||
public sealed record DaprPubSubAcceptResult(DaprPubSubAcceptOutcome Outcome, string? Detail = null) {
|
||||
public static DaprPubSubAcceptResult Accepted(string? detail = null) => new(DaprPubSubAcceptOutcome.Accepted, detail);
|
||||
public static DaprPubSubAcceptResult AlreadyHandled(string? detail = null) => new(DaprPubSubAcceptOutcome.AlreadyHandled, detail);
|
||||
public static DaprPubSubAcceptResult Busy(string? detail = null) => new(DaprPubSubAcceptOutcome.Busy, detail);
|
||||
public static DaprPubSubAcceptResult Rejected(string? detail = null) => new(DaprPubSubAcceptOutcome.Rejected, detail);
|
||||
}
|
||||
|
||||
/// <summary>Product implements accept/claim; HTTP layer maps to Dapr ACK/NAK via <see cref="DaprPubSubAck"/>.</summary>
|
||||
public interface IDaprPubSubWorkHandler<TMessage> {
|
||||
Task<DaprPubSubAcceptResult> TryAcceptAsync(TMessage message, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>Maps accept outcomes to HTTP status codes for Dapr pub/sub delivery.</summary>
|
||||
public static class DaprPubSubAck {
|
||||
public static IActionResult ToActionResult(DaprPubSubAcceptResult result) =>
|
||||
result.Outcome switch {
|
||||
DaprPubSubAcceptOutcome.Accepted => new OkObjectResult(new { status = "accepted", detail = result.Detail }),
|
||||
DaprPubSubAcceptOutcome.AlreadyHandled => new OkObjectResult(new { status = "alreadyHandled", detail = result.Detail }),
|
||||
DaprPubSubAcceptOutcome.Busy => new ObjectResult(new { status = "busy", detail = result.Detail }) { StatusCode = StatusCodes.Status503ServiceUnavailable },
|
||||
DaprPubSubAcceptOutcome.Rejected => new BadRequestObjectResult(new { status = "rejected", detail = result.Detail }),
|
||||
_ => new StatusCodeResult(StatusCodes.Status500InternalServerError),
|
||||
};
|
||||
|
||||
public static Microsoft.AspNetCore.Http.IResult ToHttpResult(DaprPubSubAcceptResult result) =>
|
||||
result.Outcome switch {
|
||||
DaprPubSubAcceptOutcome.Accepted => Microsoft.AspNetCore.Http.Results.Ok(new { status = "accepted", detail = result.Detail }),
|
||||
DaprPubSubAcceptOutcome.AlreadyHandled => Microsoft.AspNetCore.Http.Results.Ok(new { status = "alreadyHandled", detail = result.Detail }),
|
||||
DaprPubSubAcceptOutcome.Busy => Microsoft.AspNetCore.Http.Results.Json(new { status = "busy", detail = result.Detail }, statusCode: StatusCodes.Status503ServiceUnavailable),
|
||||
DaprPubSubAcceptOutcome.Rejected => Microsoft.AspNetCore.Http.Results.BadRequest(new { status = "rejected", detail = result.Detail }),
|
||||
_ => Microsoft.AspNetCore.Http.Results.StatusCode(StatusCodes.Status500InternalServerError),
|
||||
};
|
||||
}
|
||||
203
src/MaksIT.Dapr/Services/DaprActorService.cs
Normal file
203
src/MaksIT.Dapr/Services/DaprActorService.cs
Normal file
@ -0,0 +1,203 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Actors;
|
||||
using Dapr.Actors.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Facade over Dapr actors: create typed clients and invoke methods with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprActorService {
|
||||
/// <summary>
|
||||
/// Creates a strongly typed actor client for <typeparamref name="TActor"/>.
|
||||
/// </summary>
|
||||
Result<TActor> Create<TActor>(string actorId, string actorType) where TActor : IActor;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a weakly typed <see cref="ActorProxy"/> for dynamic method invocation.
|
||||
/// </summary>
|
||||
Result<ActorProxy> Create(string actorId, string actorType);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes an actor method with no request or response payload.
|
||||
/// </summary>
|
||||
Task<Result> InvokeAsync(string actorId, string actorType, string methodName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes an actor method with a request payload and no response.
|
||||
/// </summary>
|
||||
Task<Result> InvokeAsync<TRequest>(string actorId, string actorType, string methodName, TRequest data, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes an actor method with no request payload and a typed response.
|
||||
/// </summary>
|
||||
Task<Result<TResponse?>> InvokeAsync<TResponse>(string actorId, string actorType, string methodName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes an actor method with a request payload and a typed response.
|
||||
/// </summary>
|
||||
Task<Result<TResponse?>> InvokeAsync<TRequest, TResponse>(string actorId, string actorType, string methodName, TRequest data, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprActorService"/> using Dapr's <see cref="IActorProxyFactory"/>.
|
||||
/// </summary>
|
||||
public class DaprActorService : IDaprActorService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Actor service error";
|
||||
|
||||
private readonly IActorProxyFactory _actorProxyFactory;
|
||||
private readonly ILogger<DaprActorService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates an actor service backed by <paramref name="actorProxyFactory"/>.
|
||||
/// </summary>
|
||||
public DaprActorService(ILogger<DaprActorService> logger, IActorProxyFactory actorProxyFactory) {
|
||||
_logger = logger;
|
||||
_actorProxyFactory = actorProxyFactory;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Result<TActor> Create<TActor>(string actorId, string actorType) where TActor : IActor {
|
||||
var validation = ValidateActorKey(actorId, actorType);
|
||||
if (!validation.IsSuccess)
|
||||
return Result<TActor>.BadRequest(default!, validation.Messages.ToArray());
|
||||
|
||||
try {
|
||||
var actor = _actorProxyFactory.CreateActorProxy<TActor>(new ActorId(actorId), actorType);
|
||||
return Result<TActor>.Ok(actor);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<TActor>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Result<ActorProxy> Create(string actorId, string actorType) {
|
||||
var validation = ValidateActorKey(actorId, actorType);
|
||||
if (!validation.IsSuccess)
|
||||
return Result<ActorProxy>.BadRequest(default!, validation.Messages.ToArray());
|
||||
|
||||
try {
|
||||
var actor = _actorProxyFactory.Create(new ActorId(actorId), actorType);
|
||||
return Result<ActorProxy>.Ok(actor);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<ActorProxy>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> InvokeAsync(
|
||||
string actorId,
|
||||
string actorType,
|
||||
string methodName,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInvoke(actorId, actorType, methodName);
|
||||
if (!validation.IsSuccess)
|
||||
return validation;
|
||||
|
||||
try {
|
||||
var actor = _actorProxyFactory.Create(new ActorId(actorId), actorType);
|
||||
await actor.InvokeMethodAsync(methodName, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> InvokeAsync<TRequest>(
|
||||
string actorId,
|
||||
string actorType,
|
||||
string methodName,
|
||||
TRequest data,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInvoke(actorId, actorType, methodName);
|
||||
if (!validation.IsSuccess)
|
||||
return validation;
|
||||
|
||||
try {
|
||||
var actor = _actorProxyFactory.Create(new ActorId(actorId), actorType);
|
||||
await actor.InvokeMethodAsync(methodName, data, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<TResponse?>> InvokeAsync<TResponse>(
|
||||
string actorId,
|
||||
string actorType,
|
||||
string methodName,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInvoke(actorId, actorType, methodName);
|
||||
if (!validation.IsSuccess)
|
||||
return validation.ToResultOfType<TResponse?>(default);
|
||||
|
||||
try {
|
||||
var actor = _actorProxyFactory.Create(new ActorId(actorId), actorType);
|
||||
var response = await actor.InvokeMethodAsync<TResponse>(methodName, cancellationToken);
|
||||
return Result<TResponse?>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<TResponse?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<TResponse?>> InvokeAsync<TRequest, TResponse>(
|
||||
string actorId,
|
||||
string actorType,
|
||||
string methodName,
|
||||
TRequest data,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInvoke(actorId, actorType, methodName);
|
||||
if (!validation.IsSuccess)
|
||||
return validation.ToResultOfType<TResponse?>(default);
|
||||
|
||||
try {
|
||||
var actor = _actorProxyFactory.Create(new ActorId(actorId), actorType);
|
||||
var response = await actor.InvokeMethodAsync<TRequest, TResponse>(methodName, data, cancellationToken);
|
||||
return Result<TResponse?>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<TResponse?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
private static Result ValidateActorKey(string actorId, string actorType) {
|
||||
if (string.IsNullOrWhiteSpace(actorId) || string.IsNullOrWhiteSpace(actorType))
|
||||
return Result.BadRequest("actorId and actorType are required.");
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
private static Result ValidateInvoke(string actorId, string actorType, string methodName) {
|
||||
if (string.IsNullOrWhiteSpace(actorId) || string.IsNullOrWhiteSpace(actorType) || string.IsNullOrWhiteSpace(methodName))
|
||||
return Result.BadRequest("actorId, actorType, and methodName are required.");
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
88
src/MaksIT.Dapr/Services/DaprBindingService.cs
Normal file
88
src/MaksIT.Dapr/Services/DaprBindingService.cs
Normal file
@ -0,0 +1,88 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr output bindings with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprBindingService {
|
||||
/// <summary>
|
||||
/// Invokes a binding operation.
|
||||
/// </summary>
|
||||
Task<Result> InvokeAsync<TRequest>(
|
||||
string bindingName,
|
||||
string operation,
|
||||
TRequest data,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a binding operation and deserializes the response.
|
||||
/// </summary>
|
||||
Task<Result<TResponse?>> InvokeAsync<TRequest, TResponse>(
|
||||
string bindingName,
|
||||
string operation,
|
||||
TRequest data,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprBindingService"/>.
|
||||
/// </summary>
|
||||
public class DaprBindingService(
|
||||
ILogger<DaprBindingService> logger,
|
||||
DaprClient client
|
||||
) : IDaprBindingService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Binding error";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> InvokeAsync<TRequest>(
|
||||
string bindingName,
|
||||
string operation,
|
||||
TRequest data,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(bindingName) || string.IsNullOrWhiteSpace(operation))
|
||||
return Result.BadRequest("bindingName and operation are required.");
|
||||
|
||||
try {
|
||||
await client.InvokeBindingAsync(bindingName, operation, data, metadata, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<TResponse?>> InvokeAsync<TRequest, TResponse>(
|
||||
string bindingName,
|
||||
string operation,
|
||||
TRequest data,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(bindingName) || string.IsNullOrWhiteSpace(operation))
|
||||
return Result<TResponse?>.BadRequest(default, "bindingName and operation are required.");
|
||||
|
||||
try {
|
||||
var response = await client.InvokeBindingAsync<TRequest, TResponse>(bindingName, operation, data, metadata, cancellationToken);
|
||||
return Result<TResponse?>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<TResponse?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
107
src/MaksIT.Dapr/Services/DaprConfigurationService.cs
Normal file
107
src/MaksIT.Dapr/Services/DaprConfigurationService.cs
Normal file
@ -0,0 +1,107 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr configuration API with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprConfigurationService {
|
||||
/// <summary>
|
||||
/// Gets configuration items.
|
||||
/// </summary>
|
||||
Task<Result<GetConfigurationResponse>> GetAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<string> keys,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to configuration changes.
|
||||
/// </summary>
|
||||
Task<Result<SubscribeConfigurationResponse>> SubscribeAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<string> keys,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes from configuration changes.
|
||||
/// </summary>
|
||||
Task<Result> UnsubscribeAsync(string storeName, string id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprConfigurationService"/>.
|
||||
/// </summary>
|
||||
public class DaprConfigurationService(
|
||||
ILogger<DaprConfigurationService> logger,
|
||||
DaprClient client
|
||||
) : IDaprConfigurationService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Configuration error";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<GetConfigurationResponse>> GetAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<string> keys,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || keys is null || keys.Count == 0)
|
||||
return Result<GetConfigurationResponse>.BadRequest(default!, "storeName and keys are required.");
|
||||
|
||||
try {
|
||||
var response = await client.GetConfiguration(storeName, keys, metadata, cancellationToken);
|
||||
return Result<GetConfigurationResponse>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<GetConfigurationResponse>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<SubscribeConfigurationResponse>> SubscribeAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<string> keys,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || keys is null || keys.Count == 0)
|
||||
return Result<SubscribeConfigurationResponse>.BadRequest(default!, "storeName and keys are required.");
|
||||
|
||||
try {
|
||||
var response = await client.SubscribeConfiguration(storeName, keys, metadata, cancellationToken);
|
||||
return Result<SubscribeConfigurationResponse>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<SubscribeConfigurationResponse>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> UnsubscribeAsync(string storeName, string id, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(id))
|
||||
return Result.BadRequest("storeName and id are required.");
|
||||
|
||||
try {
|
||||
_ = await client.UnsubscribeConfiguration(storeName, id, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
96
src/MaksIT.Dapr/Services/DaprCryptographyService.cs
Normal file
96
src/MaksIT.Dapr/Services/DaprCryptographyService.cs
Normal file
@ -0,0 +1,96 @@
|
||||
#pragma warning disable DAPR_CRYPTOGRAPHY
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr cryptography building block with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprCryptographyService {
|
||||
/// <summary>
|
||||
/// Encrypts plaintext bytes.
|
||||
/// </summary>
|
||||
Task<Result<ReadOnlyMemory<byte>>> EncryptAsync(
|
||||
string componentName,
|
||||
ReadOnlyMemory<byte> plaintext,
|
||||
string keyName,
|
||||
EncryptionOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Decrypts ciphertext bytes.
|
||||
/// </summary>
|
||||
Task<Result<ReadOnlyMemory<byte>>> DecryptAsync(
|
||||
string componentName,
|
||||
ReadOnlyMemory<byte> ciphertext,
|
||||
string keyName,
|
||||
DecryptionOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprCryptographyService"/>.
|
||||
/// </summary>
|
||||
public class DaprCryptographyService(
|
||||
ILogger<DaprCryptographyService> logger,
|
||||
DaprClient client
|
||||
) : IDaprCryptographyService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Cryptography error";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<ReadOnlyMemory<byte>>> EncryptAsync(
|
||||
string componentName,
|
||||
ReadOnlyMemory<byte> plaintext,
|
||||
string keyName,
|
||||
EncryptionOptions options,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(componentName) || string.IsNullOrWhiteSpace(keyName))
|
||||
return Result<ReadOnlyMemory<byte>>.BadRequest(default, "componentName and keyName are required.");
|
||||
if (options is null)
|
||||
return Result<ReadOnlyMemory<byte>>.BadRequest(default, "options are required.");
|
||||
|
||||
try {
|
||||
var ciphertext = await client.EncryptAsync(componentName, plaintext, keyName, options, cancellationToken);
|
||||
return Result<ReadOnlyMemory<byte>>.Ok(ciphertext);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<ReadOnlyMemory<byte>>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<ReadOnlyMemory<byte>>> DecryptAsync(
|
||||
string componentName,
|
||||
ReadOnlyMemory<byte> ciphertext,
|
||||
string keyName,
|
||||
DecryptionOptions? options = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(componentName) || string.IsNullOrWhiteSpace(keyName))
|
||||
return Result<ReadOnlyMemory<byte>>.BadRequest(default, "componentName and keyName are required.");
|
||||
|
||||
try {
|
||||
var plaintext = options is null
|
||||
? await client.DecryptAsync(componentName, ciphertext, keyName, cancellationToken)
|
||||
: await client.DecryptAsync(componentName, ciphertext, keyName, options, cancellationToken);
|
||||
return Result<ReadOnlyMemory<byte>>.Ok(plaintext);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<ReadOnlyMemory<byte>>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore DAPR_CRYPTOGRAPHY
|
||||
155
src/MaksIT.Dapr/Services/DaprInvocationService.cs
Normal file
155
src/MaksIT.Dapr/Services/DaprInvocationService.cs
Normal file
@ -0,0 +1,155 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr service invocation with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprInvocationService {
|
||||
/// <summary>
|
||||
/// Invokes a method with no request body (HTTP POST).
|
||||
/// </summary>
|
||||
Task<Result> InvokeAsync(string appId, string methodName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a method with a JSON request body (HTTP POST).
|
||||
/// </summary>
|
||||
Task<Result> InvokeAsync<TRequest>(string appId, string methodName, TRequest data, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a method with no request body and deserializes the JSON response (HTTP POST).
|
||||
/// </summary>
|
||||
Task<Result<TResponse?>> InvokeAsync<TResponse>(string appId, string methodName, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a method with a JSON request body and deserializes the JSON response (HTTP POST).
|
||||
/// </summary>
|
||||
Task<Result<TResponse?>> InvokeAsync<TRequest, TResponse>(string appId, string methodName, TRequest data, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprInvocationService"/> using <see cref="DaprClient.CreateInvokableHttpClient"/>.
|
||||
/// </summary>
|
||||
public sealed class DaprInvocationService(
|
||||
ILogger<DaprInvocationService> logger,
|
||||
DaprClient client
|
||||
) : IDaprInvocationService, IDisposable {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Invocation error";
|
||||
|
||||
private readonly ConcurrentDictionary<string, HttpClient> _clients = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> InvokeAsync(string appId, string methodName, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(methodName))
|
||||
return Result.BadRequest("appId and methodName are required.");
|
||||
|
||||
try {
|
||||
using var response = await GetClient(appId).PostAsync(methodName, content: null, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> InvokeAsync<TRequest>(string appId, string methodName, TRequest data, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(methodName))
|
||||
return Result.BadRequest("appId and methodName are required.");
|
||||
|
||||
try {
|
||||
using var response = await GetClient(appId).PostAsJsonAsync(methodName, data, client.JsonSerializerOptions, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<TResponse?>> InvokeAsync<TResponse>(string appId, string methodName, CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(methodName))
|
||||
return Result<TResponse?>.BadRequest(default, "appId and methodName are required.");
|
||||
|
||||
try {
|
||||
using var response = await GetClient(appId).PostAsync(methodName, content: null, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<TResponse>(client.JsonSerializerOptions, cancellationToken);
|
||||
return Result<TResponse?>.Ok(body);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<TResponse?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<TResponse?>> InvokeAsync<TRequest, TResponse>(
|
||||
string appId,
|
||||
string methodName,
|
||||
TRequest data,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(methodName))
|
||||
return Result<TResponse?>.BadRequest(default, "appId and methodName are required.");
|
||||
|
||||
try {
|
||||
using var response = await GetClient(appId).PostAsJsonAsync(methodName, data, client.JsonSerializerOptions, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var body = await response.Content.ReadFromJsonAsync<TResponse>(client.JsonSerializerOptions, cancellationToken);
|
||||
return Result<TResponse?>.Ok(body);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<TResponse?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() {
|
||||
if (_disposed)
|
||||
return;
|
||||
|
||||
_disposed = true;
|
||||
foreach (var http in _clients.Values)
|
||||
http.Dispose();
|
||||
|
||||
_clients.Clear();
|
||||
}
|
||||
|
||||
private HttpClient GetClient(string appId) {
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_clients.TryGetValue(appId, out var existing))
|
||||
return existing;
|
||||
|
||||
var created = client.CreateInvokableHttpClient(appId);
|
||||
if (_clients.TryAdd(appId, created))
|
||||
return created;
|
||||
|
||||
created.Dispose();
|
||||
return _clients[appId];
|
||||
}
|
||||
}
|
||||
97
src/MaksIT.Dapr/Services/DaprLockService.cs
Normal file
97
src/MaksIT.Dapr/Services/DaprLockService.cs
Normal file
@ -0,0 +1,97 @@
|
||||
#pragma warning disable DAPR_DISTRIBUTEDLOCK
|
||||
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr distributed lock API with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Short-lived mutex over a Dapr lock Component. For MaksIT multi-replica leader/bootstrap/sweep
|
||||
/// coordination prefer <see cref="IDaprWorkLeaseService"/> (state-backed named leases with renew/hold helpers).
|
||||
/// </remarks>
|
||||
public interface IDaprLockService {
|
||||
/// <summary>
|
||||
/// Attempts to acquire a lock. Check <see cref="TryLockResponse.Success"/>; dispose the response when done (or call <see cref="UnlockAsync"/>).
|
||||
/// </summary>
|
||||
Task<Result<TryLockResponse>> LockAsync(
|
||||
string storeName,
|
||||
string resourceId,
|
||||
string lockOwner,
|
||||
int expiryInSeconds,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases a lock held by <paramref name="lockOwner"/>.
|
||||
/// </summary>
|
||||
Task<Result<UnlockResponse>> UnlockAsync(
|
||||
string storeName,
|
||||
string resourceId,
|
||||
string lockOwner,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprLockService"/>.
|
||||
/// </summary>
|
||||
public class DaprLockService(
|
||||
ILogger<DaprLockService> logger,
|
||||
DaprClient client
|
||||
) : IDaprLockService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Lock error";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<TryLockResponse>> LockAsync(
|
||||
string storeName,
|
||||
string resourceId,
|
||||
string lockOwner,
|
||||
int expiryInSeconds,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(resourceId) || string.IsNullOrWhiteSpace(lockOwner))
|
||||
return Result<TryLockResponse>.BadRequest(default!, "storeName, resourceId, and lockOwner are required.");
|
||||
if (expiryInSeconds <= 0)
|
||||
return Result<TryLockResponse>.BadRequest(default!, "expiryInSeconds must be positive.");
|
||||
|
||||
try {
|
||||
var response = await client.Lock(storeName, resourceId, lockOwner, expiryInSeconds, cancellationToken);
|
||||
return Result<TryLockResponse>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<TryLockResponse>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<UnlockResponse>> UnlockAsync(
|
||||
string storeName,
|
||||
string resourceId,
|
||||
string lockOwner,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(resourceId) || string.IsNullOrWhiteSpace(lockOwner))
|
||||
return Result<UnlockResponse>.BadRequest(default!, "storeName, resourceId, and lockOwner are required.");
|
||||
|
||||
try {
|
||||
var response = await client.Unlock(storeName, resourceId, lockOwner, cancellationToken);
|
||||
return Result<UnlockResponse>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<UnlockResponse>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning restore DAPR_DISTRIBUTEDLOCK
|
||||
145
src/MaksIT.Dapr/Services/DaprPubSubService.cs
Normal file
145
src/MaksIT.Dapr/Services/DaprPubSubService.cs
Normal file
@ -0,0 +1,145 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes events to a Dapr pub/sub component.
|
||||
/// </summary>
|
||||
public interface IDaprPubSubService {
|
||||
/// <summary>
|
||||
/// Publishes <paramref name="payload"/> to <paramref name="topicName"/> on <paramref name="pubsubName"/>.
|
||||
/// </summary>
|
||||
Task<Result> PublishEventAsync(
|
||||
string pubsubName,
|
||||
string topicName,
|
||||
object payload,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a raw byte payload.
|
||||
/// </summary>
|
||||
Task<Result> PublishByteEventAsync(
|
||||
string pubsubName,
|
||||
string topicName,
|
||||
ReadOnlyMemory<byte> data,
|
||||
string contentType = "application/json",
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes multiple events; returns the Dapr bulk response (including failed entries).
|
||||
/// </summary>
|
||||
Task<Result<BulkPublishResponse<T>>> BulkPublishEventAsync<T>(
|
||||
string pubsubName,
|
||||
string topicName,
|
||||
IReadOnlyList<T> events,
|
||||
Dictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprPubSubService"/> using <see cref="DaprClient"/>.
|
||||
/// </summary>
|
||||
public class DaprPubSubService : IDaprPubSubService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Pub/sub error";
|
||||
|
||||
private readonly DaprClient _client;
|
||||
private readonly ILogger<DaprPubSubService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a pub/sub facade backed by <paramref name="client"/>.
|
||||
/// </summary>
|
||||
public DaprPubSubService(ILogger<DaprPubSubService> logger, DaprClient client) {
|
||||
_logger = logger;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> PublishEventAsync(
|
||||
string pubsubName,
|
||||
string topicName,
|
||||
object payload,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(pubsubName) || string.IsNullOrWhiteSpace(topicName))
|
||||
return Result.BadRequest("pubsubName and topicName are required.");
|
||||
|
||||
try {
|
||||
if (metadata is null)
|
||||
await _client.PublishEventAsync(pubsubName, topicName, payload, cancellationToken);
|
||||
else
|
||||
await _client.PublishEventAsync(
|
||||
pubsubName,
|
||||
topicName,
|
||||
payload,
|
||||
metadata as Dictionary<string, string> ?? metadata.ToDictionary(static kv => kv.Key, static kv => kv.Value),
|
||||
cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> PublishByteEventAsync(
|
||||
string pubsubName,
|
||||
string topicName,
|
||||
ReadOnlyMemory<byte> data,
|
||||
string contentType = "application/json",
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(pubsubName) || string.IsNullOrWhiteSpace(topicName))
|
||||
return Result.BadRequest("pubsubName and topicName are required.");
|
||||
|
||||
try {
|
||||
await _client.PublishByteEventAsync(
|
||||
pubsubName,
|
||||
topicName,
|
||||
data,
|
||||
contentType,
|
||||
metadata as Dictionary<string, string> ?? metadata?.ToDictionary(static kv => kv.Key, static kv => kv.Value),
|
||||
cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<BulkPublishResponse<T>>> BulkPublishEventAsync<T>(
|
||||
string pubsubName,
|
||||
string topicName,
|
||||
IReadOnlyList<T> events,
|
||||
Dictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(pubsubName) || string.IsNullOrWhiteSpace(topicName))
|
||||
return Result<BulkPublishResponse<T>>.BadRequest(default!, "pubsubName and topicName are required.");
|
||||
|
||||
try {
|
||||
var response = await _client.BulkPublishEventAsync(pubsubName, topicName, events, metadata, cancellationToken);
|
||||
return Result<BulkPublishResponse<T>>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<BulkPublishResponse<T>>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
using Dapr.Client;
|
||||
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
public interface IDaprPublisherService {
|
||||
Task<Result> PublishEventAsync(string pubsubName, string topicName, object payload);
|
||||
}
|
||||
|
||||
public class DaprPublisherService : IDaprPublisherService {
|
||||
private const string _errorMessage = "MaksIT.Dapr - Event publishing error";
|
||||
|
||||
private readonly DaprClient _client;
|
||||
private readonly ILogger<DaprPublisherService> _logger;
|
||||
|
||||
public DaprPublisherService(
|
||||
ILogger<DaprPublisherService> logger,
|
||||
DaprClient client
|
||||
) {
|
||||
_logger = logger;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task<Result> PublishEventAsync(string pubsubName, string topicName, object payload) {
|
||||
try {
|
||||
await _client.PublishEventAsync(pubsubName, topicName, payload);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, _errorMessage);
|
||||
return Result.InternalServerError([_errorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
82
src/MaksIT.Dapr/Services/DaprSecretService.cs
Normal file
82
src/MaksIT.Dapr/Services/DaprSecretService.cs
Normal file
@ -0,0 +1,82 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr secret store operations with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprSecretService {
|
||||
/// <summary>
|
||||
/// Gets a secret by name.
|
||||
/// </summary>
|
||||
Task<Result<IReadOnlyDictionary<string, string>>> GetAsync(
|
||||
string storeName,
|
||||
string key,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all secrets from a store.
|
||||
/// </summary>
|
||||
Task<Result<IReadOnlyDictionary<string, Dictionary<string, string>>>> GetBulkAsync(
|
||||
string storeName,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprSecretService"/>.
|
||||
/// </summary>
|
||||
public class DaprSecretService(
|
||||
ILogger<DaprSecretService> logger,
|
||||
DaprClient client
|
||||
) : IDaprSecretService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Secret error";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<IReadOnlyDictionary<string, string>>> GetAsync(
|
||||
string storeName,
|
||||
string key,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
|
||||
return Result<IReadOnlyDictionary<string, string>>.BadRequest(default!, "storeName and key are required.");
|
||||
|
||||
try {
|
||||
var secrets = await client.GetSecretAsync(storeName, key, metadata, cancellationToken);
|
||||
return Result<IReadOnlyDictionary<string, string>>.Ok(secrets);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<IReadOnlyDictionary<string, string>>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<IReadOnlyDictionary<string, Dictionary<string, string>>>> GetBulkAsync(
|
||||
string storeName,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName))
|
||||
return Result<IReadOnlyDictionary<string, Dictionary<string, string>>>.BadRequest(default!, "storeName is required.");
|
||||
|
||||
try {
|
||||
var secrets = await client.GetBulkSecretAsync(storeName, metadata, cancellationToken);
|
||||
return Result<IReadOnlyDictionary<string, Dictionary<string, string>>>.Ok(secrets);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<IReadOnlyDictionary<string, Dictionary<string, string>>>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
122
src/MaksIT.Dapr/Services/DaprSidecarService.cs
Normal file
122
src/MaksIT.Dapr/Services/DaprSidecarService.cs
Normal file
@ -0,0 +1,122 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr sidecar health and metadata with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprSidecarService {
|
||||
/// <summary>
|
||||
/// Checks sidecar health.
|
||||
/// </summary>
|
||||
Task<Result<bool>> CheckHealthAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks outbound health.
|
||||
/// </summary>
|
||||
Task<Result<bool>> CheckOutboundHealthAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Blocks until the sidecar is ready.
|
||||
/// </summary>
|
||||
Task<Result> WaitForSidecarAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets sidecar metadata.
|
||||
/// </summary>
|
||||
Task<Result<DaprMetadata>> GetMetadataAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Requests sidecar shutdown.
|
||||
/// </summary>
|
||||
Task<Result> ShutdownAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprSidecarService"/>.
|
||||
/// </summary>
|
||||
public class DaprSidecarService(
|
||||
ILogger<DaprSidecarService> logger,
|
||||
DaprClient client
|
||||
) : IDaprSidecarService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Sidecar error";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<bool>> CheckHealthAsync(CancellationToken cancellationToken = default) {
|
||||
try {
|
||||
var healthy = await client.CheckHealthAsync(cancellationToken);
|
||||
return Result<bool>.Ok(healthy);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<bool>.InternalServerError(false, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<bool>> CheckOutboundHealthAsync(CancellationToken cancellationToken = default) {
|
||||
try {
|
||||
var healthy = await client.CheckOutboundHealthAsync(cancellationToken);
|
||||
return Result<bool>.Ok(healthy);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<bool>.InternalServerError(false, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> WaitForSidecarAsync(CancellationToken cancellationToken = default) {
|
||||
try {
|
||||
await client.WaitForSidecarAsync(cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<DaprMetadata>> GetMetadataAsync(CancellationToken cancellationToken = default) {
|
||||
try {
|
||||
var metadata = await client.GetMetadataAsync(cancellationToken);
|
||||
return Result<DaprMetadata>.Ok(metadata);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result<DaprMetadata>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> ShutdownAsync(CancellationToken cancellationToken = default) {
|
||||
try {
|
||||
await client.ShutdownSidecarAsync(cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,93 +1,416 @@
|
||||
using Dapr.Client;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Grpc.Core;
|
||||
using Dapr.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dapr state store operations with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprStateStoreService {
|
||||
Task<Result> SetStateAsync<T>(string storeName, string key, T value);
|
||||
Task<Result<T?>> GetStateAsync<T>(string storeName, string key);
|
||||
Task<Result<(T? Value, string? ETag)>> GetStateAndETagAsync<T>(string storeName, string key, CancellationToken cancellationToken = default);
|
||||
Task<Result<bool>> TrySaveStateAsync<T>(string storeName, string key, T value, string? etag, CancellationToken cancellationToken = default);
|
||||
Task<Result> DeleteStateAsync(string storeName, string key);
|
||||
/// <summary>
|
||||
/// Saves <paramref name="value"/> under <paramref name="key"/> in <paramref name="storeName"/>.
|
||||
/// </summary>
|
||||
Task<Result> SetStateAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
T value,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets state for <paramref name="key"/>. Missing keys return <c>Ok(null)</c>; infra failures are unsuccessful.
|
||||
/// </summary>
|
||||
Task<Result<T?>> GetStateAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
ConsistencyMode? consistencyMode = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets state and ETag for optimistic concurrency. Missing keys return <c>Ok((null, null))</c>.
|
||||
/// </summary>
|
||||
Task<Result<(T? Value, string? ETag)>> GetStateAndETagAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
ConsistencyMode? consistencyMode = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts an ETag-conditional save. <see cref="Result{T}.Value"/> is <c>false</c> on conflict.
|
||||
/// </summary>
|
||||
Task<Result<bool>> TrySaveStateAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
T value,
|
||||
string? etag,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes <paramref name="key"/>. Missing keys are treated as success (idempotent).
|
||||
/// </summary>
|
||||
Task<Result> DeleteStateAsync(
|
||||
string storeName,
|
||||
string key,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts an ETag-conditional delete.
|
||||
/// </summary>
|
||||
Task<Result<bool>> TryDeleteStateAsync(
|
||||
string storeName,
|
||||
string key,
|
||||
string? etag,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets multiple keys from the store.
|
||||
/// </summary>
|
||||
Task<Result<IReadOnlyList<BulkStateItem>>> GetBulkStateAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<string> keys,
|
||||
int? parallelism = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Saves multiple items.
|
||||
/// </summary>
|
||||
Task<Result> SaveBulkStateAsync<T>(
|
||||
string storeName,
|
||||
IReadOnlyList<SaveStateItem<T>> items,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes multiple items.
|
||||
/// </summary>
|
||||
Task<Result> DeleteBulkStateAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<BulkDeleteStateItem> items,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Queries state with a JSON query document.
|
||||
/// </summary>
|
||||
Task<Result<StateQueryResponse<T>>> QueryStateAsync<T>(
|
||||
string storeName,
|
||||
string jsonQuery,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Executes a state transaction.
|
||||
/// </summary>
|
||||
Task<Result> ExecuteStateTransactionAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<StateTransactionRequest> operations,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprStateStoreService"/> using <see cref="DaprClient"/>.
|
||||
/// </summary>
|
||||
public class DaprStateStoreService : IDaprStateStoreService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Data provider error";
|
||||
private const string ErrorMessage = "MaksIT.Dapr - State store error";
|
||||
|
||||
private readonly DaprClient _client;
|
||||
private readonly ILogger<DaprStateStoreService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a state store service backed by <paramref name="client"/>.
|
||||
/// </summary>
|
||||
public DaprStateStoreService(ILogger<DaprStateStoreService> logger, DaprClient client) {
|
||||
_logger = logger;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public async Task<Result> SetStateAsync<T>(string storeName, string key, T value) {
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> SetStateAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
T value,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
|
||||
return Result.BadRequest("storeName and key are required.");
|
||||
|
||||
try {
|
||||
await _client.SaveStateAsync(storeName, key, value);
|
||||
await _client.SaveStateAsync(storeName, key, value, options, metadata, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result<T?>> GetStateAsync<T>(string storeName, string key) {
|
||||
try {
|
||||
var state = await _client.GetStateAsync<T?>(storeName, key);
|
||||
if (state is null)
|
||||
return Result<T?>.NotFound(default, $"State from the store {storeName} with the {key} not found.");
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<T?>> GetStateAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
ConsistencyMode? consistencyMode = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
|
||||
return Result<T?>.BadRequest(default, "storeName and key are required.");
|
||||
|
||||
try {
|
||||
var state = await _client.GetStateAsync<T?>(storeName, key, consistencyMode, metadata, cancellationToken);
|
||||
return Result<T?>.Ok(state);
|
||||
}
|
||||
catch (Exception ex) when (IsStateKeyNotFound(ex)) {
|
||||
return Result<T?>.Ok(default);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<T?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<(T? Value, string? ETag)>> GetStateAndETagAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
ConsistencyMode? consistencyMode = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
|
||||
return Result<(T? Value, string? ETag)>.BadRequest(default, "storeName and key are required.");
|
||||
|
||||
try {
|
||||
var (value, etag) = await _client.GetStateAndETagAsync<T?>(storeName, key, cancellationToken: cancellationToken);
|
||||
var (value, etag) = await _client.GetStateAndETagAsync<T?>(storeName, key, consistencyMode, metadata, cancellationToken);
|
||||
return Result<(T? Value, string? ETag)>.Ok((value, etag));
|
||||
}
|
||||
catch (Exception ex) when (IsStateKeyNotFound(ex)) {
|
||||
return Result<(T? Value, string? ETag)>.Ok((default, null));
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<(T? Value, string? ETag)>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<bool>> TrySaveStateAsync<T>(
|
||||
string storeName,
|
||||
string key,
|
||||
T value,
|
||||
string? etag,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
|
||||
return Result<bool>.BadRequest(false, "storeName and key are required.");
|
||||
|
||||
try {
|
||||
var saved = await _client.TrySaveStateAsync(storeName, key, value, etag ?? string.Empty, cancellationToken: cancellationToken);
|
||||
var saved = await _client.TrySaveStateAsync(storeName, key, value, etag ?? string.Empty, options, metadata, cancellationToken);
|
||||
return Result<bool>.Ok(saved);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<bool>.InternalServerError(false, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Result> DeleteStateAsync(string storeName, string key) {
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> DeleteStateAsync(
|
||||
string storeName,
|
||||
string key,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
|
||||
return Result.BadRequest("storeName and key are required.");
|
||||
|
||||
try {
|
||||
await _client.DeleteStateAsync(storeName, key);
|
||||
await _client.DeleteStateAsync(storeName, key, options, metadata, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (Exception ex) when (IsStateKeyNotFound(ex)) {
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<bool>> TryDeleteStateAsync(
|
||||
string storeName,
|
||||
string key,
|
||||
string? etag,
|
||||
StateOptions? options = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
|
||||
return Result<bool>.BadRequest(false, "storeName and key are required.");
|
||||
|
||||
try {
|
||||
var deleted = await _client.TryDeleteStateAsync(storeName, key, etag ?? string.Empty, options, metadata, cancellationToken);
|
||||
return Result<bool>.Ok(deleted);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<bool>.InternalServerError(false, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<IReadOnlyList<BulkStateItem>>> GetBulkStateAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<string> keys,
|
||||
int? parallelism = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || keys is null || keys.Count == 0)
|
||||
return Result<IReadOnlyList<BulkStateItem>>.BadRequest(default!, "storeName and keys are required.");
|
||||
|
||||
try {
|
||||
var items = await _client.GetBulkStateAsync(storeName, keys, parallelism, metadata, cancellationToken);
|
||||
return Result<IReadOnlyList<BulkStateItem>>.Ok(items);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<IReadOnlyList<BulkStateItem>>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> SaveBulkStateAsync<T>(
|
||||
string storeName,
|
||||
IReadOnlyList<SaveStateItem<T>> items,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || items is null || items.Count == 0)
|
||||
return Result.BadRequest("storeName and items are required.");
|
||||
|
||||
try {
|
||||
await _client.SaveBulkStateAsync(storeName, items, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> DeleteBulkStateAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<BulkDeleteStateItem> items,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || items is null || items.Count == 0)
|
||||
return Result.BadRequest("storeName and items are required.");
|
||||
|
||||
try {
|
||||
await _client.DeleteBulkStateAsync(storeName, items, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<StateQueryResponse<T>>> QueryStateAsync<T>(
|
||||
string storeName,
|
||||
string jsonQuery,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(jsonQuery))
|
||||
return Result<StateQueryResponse<T>>.BadRequest(default!, "storeName and jsonQuery are required.");
|
||||
|
||||
try {
|
||||
var response = await _client.QueryStateAsync<T>(storeName, jsonQuery, metadata, cancellationToken);
|
||||
return Result<StateQueryResponse<T>>.Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<StateQueryResponse<T>>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> ExecuteStateTransactionAsync(
|
||||
string storeName,
|
||||
IReadOnlyList<StateTransactionRequest> operations,
|
||||
IReadOnlyDictionary<string, string>? metadata = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || operations is null || operations.Count == 0)
|
||||
return Result.BadRequest("storeName and operations are required.");
|
||||
|
||||
try {
|
||||
await _client.ExecuteStateTransactionAsync(storeName, operations, metadata, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsStateKeyNotFound(Exception ex) {
|
||||
for (var current = ex; current is not null; current = current.InnerException) {
|
||||
if (current is RpcException rpc &&
|
||||
(rpc.StatusCode == StatusCode.NotFound || ContainsKeyNotFound(rpc.Status.Detail)))
|
||||
return true;
|
||||
|
||||
if (ContainsKeyNotFound(current.Message))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ContainsKeyNotFound(string? text) =>
|
||||
!string.IsNullOrEmpty(text) &&
|
||||
text.Contains("key not found", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
369
src/MaksIT.Dapr/Services/DaprWorkflowService.cs
Normal file
369
src/MaksIT.Dapr/Services/DaprWorkflowService.cs
Normal file
@ -0,0 +1,369 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Dapr.Workflow;
|
||||
using Dapr.Workflow.Client;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Schedules and manages Dapr workflow instances with <see cref="Result"/> outcomes.
|
||||
/// </summary>
|
||||
public interface IDaprWorkflowService {
|
||||
/// <summary>
|
||||
/// Schedules a new workflow instance. Returns the instance id.
|
||||
/// </summary>
|
||||
Task<Result<string>> ScheduleAsync(
|
||||
string workflowName,
|
||||
object? input = null,
|
||||
string? instanceId = null,
|
||||
DateTimeOffset? startTime = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current state of a workflow instance.
|
||||
/// </summary>
|
||||
Task<Result<WorkflowState>> GetStateAsync(string instanceId, bool getInputsAndOutputs = true, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the workflow has started.
|
||||
/// </summary>
|
||||
Task<Result<WorkflowState>> WaitForStartAsync(string instanceId, bool getInputsAndOutputs = true, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the workflow has completed.
|
||||
/// </summary>
|
||||
Task<Result<WorkflowState>> WaitForCompletionAsync(string instanceId, bool getInputsAndOutputs = true, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Raises an external event to a waiting workflow.
|
||||
/// </summary>
|
||||
Task<Result> RaiseEventAsync(string instanceId, string eventName, object? eventPayload = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Terminates a running workflow instance.
|
||||
/// </summary>
|
||||
Task<Result> TerminateAsync(string instanceId, object? output = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Suspends a running workflow instance.
|
||||
/// </summary>
|
||||
Task<Result> SuspendAsync(string instanceId, string? reason = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resumes a suspended workflow instance.
|
||||
/// </summary>
|
||||
Task<Result> ResumeAsync(string instanceId, string? reason = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Purges history for a completed workflow instance.
|
||||
/// </summary>
|
||||
Task<Result> PurgeAsync(string instanceId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists workflow instance IDs with optional pagination.
|
||||
/// </summary>
|
||||
Task<Result<WorkflowInstancePage>> ListInstanceIdsAsync(
|
||||
string? continuationToken = null,
|
||||
int? pageSize = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full execution history of a workflow instance.
|
||||
/// </summary>
|
||||
Task<Result<IReadOnlyList<WorkflowHistoryEvent>>> GetInstanceHistoryAsync(
|
||||
string instanceId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Reruns a workflow from a history event, returning the new instance id.
|
||||
/// </summary>
|
||||
Task<Result<string>> RerunFromEventAsync(
|
||||
string sourceInstanceId,
|
||||
uint eventId,
|
||||
RerunWorkflowFromEventOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprWorkflowService"/> using <see cref="IDaprWorkflowClient"/>.
|
||||
/// </summary>
|
||||
public class DaprWorkflowService : IDaprWorkflowService {
|
||||
private const string ErrorMessage = "MaksIT.Dapr - Workflow error";
|
||||
|
||||
private readonly IDaprWorkflowClient _client;
|
||||
private readonly ILogger<DaprWorkflowService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a workflow facade backed by <paramref name="client"/>.
|
||||
/// </summary>
|
||||
public DaprWorkflowService(ILogger<DaprWorkflowService> logger, IDaprWorkflowClient client) {
|
||||
_logger = logger;
|
||||
_client = client;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<string>> ScheduleAsync(
|
||||
string workflowName,
|
||||
object? input = null,
|
||||
string? instanceId = null,
|
||||
DateTimeOffset? startTime = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(workflowName))
|
||||
return Result<string>.BadRequest(default!, "workflowName is required.");
|
||||
|
||||
try {
|
||||
var id = await _client.ScheduleNewWorkflowAsync(workflowName, instanceId, input, startTime, cancellationToken);
|
||||
return Result<string>.Ok(id);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<string>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<WorkflowState>> GetStateAsync(
|
||||
string instanceId,
|
||||
bool getInputsAndOutputs = true,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return Result<WorkflowState>.BadRequest(default!, validation.Messages.ToArray());
|
||||
|
||||
try {
|
||||
var state = await _client.GetWorkflowStateAsync(instanceId, getInputsAndOutputs, cancellationToken);
|
||||
return Result<WorkflowState>.Ok(state);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<WorkflowState>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<WorkflowState>> WaitForStartAsync(
|
||||
string instanceId,
|
||||
bool getInputsAndOutputs = true,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return Result<WorkflowState>.BadRequest(default!, validation.Messages.ToArray());
|
||||
|
||||
try {
|
||||
var state = await _client.WaitForWorkflowStartAsync(instanceId, getInputsAndOutputs, cancellationToken);
|
||||
return Result<WorkflowState>.Ok(state);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<WorkflowState>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<WorkflowState>> WaitForCompletionAsync(
|
||||
string instanceId,
|
||||
bool getInputsAndOutputs = true,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return Result<WorkflowState>.BadRequest(default!, validation.Messages.ToArray());
|
||||
|
||||
try {
|
||||
var state = await _client.WaitForWorkflowCompletionAsync(instanceId, getInputsAndOutputs, cancellationToken);
|
||||
return Result<WorkflowState>.Ok(state);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<WorkflowState>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> RaiseEventAsync(
|
||||
string instanceId,
|
||||
string eventName,
|
||||
object? eventPayload = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(instanceId) || string.IsNullOrWhiteSpace(eventName))
|
||||
return Result.BadRequest("instanceId and eventName are required.");
|
||||
|
||||
try {
|
||||
await _client.RaiseEventAsync(instanceId, eventName, eventPayload, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> TerminateAsync(
|
||||
string instanceId,
|
||||
object? output = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return validation;
|
||||
|
||||
try {
|
||||
await _client.TerminateWorkflowAsync(instanceId, output, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> SuspendAsync(
|
||||
string instanceId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return validation;
|
||||
|
||||
try {
|
||||
await _client.SuspendWorkflowAsync(instanceId, reason, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> ResumeAsync(
|
||||
string instanceId,
|
||||
string? reason = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return validation;
|
||||
|
||||
try {
|
||||
await _client.ResumeWorkflowAsync(instanceId, reason, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> PurgeAsync(string instanceId, CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return validation;
|
||||
|
||||
try {
|
||||
await _client.PurgeInstanceAsync(instanceId, cancellationToken);
|
||||
return Result.Ok();
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<WorkflowInstancePage>> ListInstanceIdsAsync(
|
||||
string? continuationToken = null,
|
||||
int? pageSize = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
try {
|
||||
var page = await _client.ListInstanceIdsAsync(continuationToken, pageSize, cancellationToken);
|
||||
return Result<WorkflowInstancePage>.Ok(page);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<WorkflowInstancePage>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<IReadOnlyList<WorkflowHistoryEvent>>> GetInstanceHistoryAsync(
|
||||
string instanceId,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(instanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return Result<IReadOnlyList<WorkflowHistoryEvent>>.BadRequest(default!, validation.Messages.ToArray());
|
||||
|
||||
try {
|
||||
var history = await _client.GetInstanceHistoryAsync(instanceId, cancellationToken);
|
||||
return Result<IReadOnlyList<WorkflowHistoryEvent>>.Ok(history);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<IReadOnlyList<WorkflowHistoryEvent>>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<string>> RerunFromEventAsync(
|
||||
string sourceInstanceId,
|
||||
uint eventId,
|
||||
RerunWorkflowFromEventOptions? options = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var validation = ValidateInstanceId(sourceInstanceId);
|
||||
if (!validation.IsSuccess)
|
||||
return Result<string>.BadRequest(default!, validation.Messages.ToArray());
|
||||
|
||||
try {
|
||||
var id = await _client.RerunWorkflowFromEventAsync(sourceInstanceId, eventId, options, cancellationToken);
|
||||
return Result<string>.Ok(id);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, ErrorMessage);
|
||||
return Result<string>.InternalServerError(default!, [ErrorMessage, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
private static Result ValidateInstanceId(string instanceId) {
|
||||
if (string.IsNullOrWhiteSpace(instanceId))
|
||||
return Result.BadRequest("instanceId is required.");
|
||||
return Result.Ok();
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
namespace MaksIT.Dapr.Services;
|
||||
namespace MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
/// <summary>Stable id for this process/pod (lease holder).</summary>
|
||||
|
||||
/// <summary>
|
||||
/// Stable id for this process/pod (lease holder).
|
||||
/// </summary>
|
||||
public interface IDaprRuntimeInstanceId {
|
||||
/// <summary>
|
||||
/// Identifier used as lease <c>HolderId</c>.
|
||||
/// </summary>
|
||||
string InstanceId { get; }
|
||||
}
|
||||
|
||||
@ -9,6 +15,7 @@ public interface IDaprRuntimeInstanceId {
|
||||
/// Prefers <c>POD_NAME</c> in Kubernetes; otherwise host name + process id.
|
||||
/// </summary>
|
||||
public sealed class DaprRuntimeInstanceIdProvider : IDaprRuntimeInstanceId {
|
||||
/// <inheritdoc />
|
||||
public string InstanceId { get; } = Build();
|
||||
|
||||
private static string Build() {
|
||||
16
src/MaksIT.Dapr/Services/WorkLease/DaprWorkLease.cs
Normal file
16
src/MaksIT.Dapr/Services/WorkLease/DaprWorkLease.cs
Normal file
@ -0,0 +1,16 @@
|
||||
namespace MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Lease document stored in Dapr state for HA work coordination.
|
||||
/// </summary>
|
||||
/// <param name="HolderId">Runtime instance currently holding the lease.</param>
|
||||
/// <param name="AcquiredAtUtc">When this generation was acquired.</param>
|
||||
/// <param name="ExpiresAtUtc">When the lease expires if not renewed.</param>
|
||||
/// <param name="Generation">Monotonic fencing token; bumped on steal / re-acquire by another holder path.</param>
|
||||
public sealed record DaprWorkLease(
|
||||
string HolderId,
|
||||
DateTimeOffset AcquiredAtUtc,
|
||||
DateTimeOffset ExpiresAtUtc,
|
||||
long Generation = 0
|
||||
);
|
||||
234
src/MaksIT.Dapr/Services/WorkLease/DaprWorkLeaseHold.cs
Normal file
234
src/MaksIT.Dapr/Services/WorkLease/DaprWorkLeaseHold.cs
Normal file
@ -0,0 +1,234 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MaksIT.Results;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
/// <summary>
|
||||
/// Scoped lease hold: releases on dispose; optional background renew at ~½ TTL.
|
||||
/// Exposes <see cref="Generation"/> for fencing long exclusive work.
|
||||
/// </summary>
|
||||
public sealed class DaprWorkLeaseHold : IAsyncDisposable {
|
||||
private readonly IDaprWorkLeaseService _leases;
|
||||
private readonly string _storeName;
|
||||
private readonly string _workKey;
|
||||
private readonly string _holderId;
|
||||
private readonly TimeSpan _ttl;
|
||||
private readonly CancellationTokenSource _renewCts = new();
|
||||
private readonly Task? _renewLoop;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a hold after a successful acquire.
|
||||
/// </summary>
|
||||
public DaprWorkLeaseHold(
|
||||
IDaprWorkLeaseService leases,
|
||||
string storeName,
|
||||
string workKey,
|
||||
string holderId,
|
||||
TimeSpan ttl,
|
||||
long generation,
|
||||
bool autoRenew) {
|
||||
_leases = leases;
|
||||
_storeName = storeName;
|
||||
_workKey = workKey;
|
||||
_holderId = holderId;
|
||||
_ttl = ttl;
|
||||
Generation = generation;
|
||||
|
||||
if (autoRenew && ttl > TimeSpan.Zero)
|
||||
_renewLoop = RunRenewLoopAsync(_renewCts.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fencing generation observed at acquire time.
|
||||
/// </summary>
|
||||
public long Generation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Renews once. Returns <c>Ok(false)</c> when the lease is no longer held by this holder.
|
||||
/// </summary>
|
||||
public Task<Result<bool>> RenewAsync(CancellationToken cancellationToken = default) =>
|
||||
_leases.TryRenewAsync(_storeName, _workKey, _holderId, _ttl, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Returns <c>Ok(true)</c> when still held by this holder at the same generation; otherwise <c>Ok(false)</c> or Conflict.
|
||||
/// </summary>
|
||||
public async Task<Result<bool>> EnsureStillHeldAsync(CancellationToken cancellationToken = default) {
|
||||
var current = await _leases.GetAsync(_storeName, _workKey, cancellationToken).ConfigureAwait(false);
|
||||
if (!current.IsSuccess)
|
||||
return current.ToResultOfType<bool>(false);
|
||||
|
||||
var lease = current.Value;
|
||||
if (lease is null)
|
||||
return Result<bool>.Ok(false);
|
||||
|
||||
if (!string.Equals(lease.HolderId, _holderId, StringComparison.Ordinal) || lease.Generation != Generation)
|
||||
return Result<bool>.Conflict(false, "Lease generation or holder changed.");
|
||||
|
||||
if (lease.ExpiresAtUtc <= DateTimeOffset.UtcNow)
|
||||
return Result<bool>.Ok(false);
|
||||
|
||||
return Result<bool>.Ok(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync() {
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0)
|
||||
return;
|
||||
|
||||
try {
|
||||
_renewCts.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (_renewLoop is not null) {
|
||||
try {
|
||||
await _renewLoop.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
_renewCts.Dispose();
|
||||
await _leases.ReleaseAsync(_storeName, _workKey, _holderId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunRenewLoopAsync(CancellationToken cancellationToken) {
|
||||
var delay = TimeSpan.FromTicks(Math.Max(_ttl.Ticks / 2, TimeSpan.FromSeconds(1).Ticks));
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
try {
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
var renewed = await RenewAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (!renewed.IsSuccess || !renewed.Value)
|
||||
break;
|
||||
}
|
||||
catch (OperationCanceledException) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bootstrap helper: one replica runs work under a lease; others wait until ready.
|
||||
/// </summary>
|
||||
public static class DaprWorkLeaseBootstrap {
|
||||
/// <summary>
|
||||
/// Leader acquires the lease and runs <paramref name="bootstrap"/>; followers poll <paramref name="isReady"/> until true or cancel.
|
||||
/// </summary>
|
||||
public static async Task<Result> RunBootstrapUnderLeaseAsync(
|
||||
IDaprWorkLeaseService leases,
|
||||
string storeName,
|
||||
string workKey,
|
||||
string holderId,
|
||||
TimeSpan ttl,
|
||||
Func<CancellationToken, Task<Result>> bootstrap,
|
||||
Func<CancellationToken, Task<Result<bool>>> isReady,
|
||||
TimeSpan? followerPollInterval = null,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (bootstrap is null)
|
||||
return Result.BadRequest("bootstrap is required.");
|
||||
if (isReady is null)
|
||||
return Result.BadRequest("isReady is required.");
|
||||
|
||||
var hold = await leases.TryHoldAsync(storeName, workKey, holderId, ttl, autoRenew: true, cancellationToken).ConfigureAwait(false);
|
||||
if (!hold.IsSuccess)
|
||||
return hold.ToResult();
|
||||
|
||||
if (hold.Value is not null) {
|
||||
await using (hold.Value.ConfigureAwait(false)) {
|
||||
return await bootstrap(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var poll = followerPollInterval ?? TimeSpan.FromSeconds(2);
|
||||
while (!cancellationToken.IsCancellationRequested) {
|
||||
var ready = await isReady(cancellationToken).ConfigureAwait(false);
|
||||
if (!ready.IsSuccess)
|
||||
return ready.ToResult();
|
||||
if (ready.Value)
|
||||
return Result.Ok();
|
||||
|
||||
await Task.Delay(poll, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
throw new OperationCanceledException(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Background loop that runs exclusive work only while a named work lease is held.
|
||||
/// Failed work <see cref="Result"/> values are logged; the host is not crashed.
|
||||
/// </summary>
|
||||
public abstract class LeasedBackgroundService(
|
||||
IDaprWorkLeaseService leases,
|
||||
IDaprRuntimeInstanceId runtimeInstance,
|
||||
IDaprWorkLeaseOptions options,
|
||||
ILogger logger
|
||||
) : Microsoft.Extensions.Hosting.BackgroundService {
|
||||
/// <summary>
|
||||
/// Product lease key (not the Dapr store name).
|
||||
/// </summary>
|
||||
protected abstract string WorkKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Lease TTL while work runs.
|
||||
/// </summary>
|
||||
protected virtual TimeSpan LeaseTtl => TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Delay after a successful work cycle.
|
||||
/// </summary>
|
||||
protected virtual TimeSpan IdleDelay => TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Delay when the lease is busy / not acquired.
|
||||
/// </summary>
|
||||
protected virtual TimeSpan BusyBackoff => TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Exclusive work while the lease is held. Return unsuccessful <see cref="Result"/> to log and continue.
|
||||
/// </summary>
|
||||
protected abstract Task<Result> ExecuteLeasedAsync(DaprWorkLeaseHold hold, CancellationToken stoppingToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
|
||||
while (!stoppingToken.IsCancellationRequested) {
|
||||
try {
|
||||
var holdResult = await leases.TryHoldAsync(
|
||||
options.StoreName,
|
||||
WorkKey,
|
||||
runtimeInstance.InstanceId,
|
||||
LeaseTtl,
|
||||
autoRenew: true,
|
||||
stoppingToken).ConfigureAwait(false);
|
||||
|
||||
if (!holdResult.IsSuccess) {
|
||||
logger.LogError("LeasedBackgroundService acquire failed for {WorkKey}: {Messages}", WorkKey, string.Join("; ", holdResult.Messages));
|
||||
await Task.Delay(BusyBackoff, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (holdResult.Value is null) {
|
||||
await Task.Delay(BusyBackoff, stoppingToken).ConfigureAwait(false);
|
||||
continue;
|
||||
}
|
||||
|
||||
await using (holdResult.Value.ConfigureAwait(false)) {
|
||||
var work = await ExecuteLeasedAsync(holdResult.Value, stoppingToken).ConfigureAwait(false);
|
||||
if (!work.IsSuccess)
|
||||
logger.LogWarning("LeasedBackgroundService work failed for {WorkKey}: {Messages}", WorkKey, string.Join("; ", work.Messages));
|
||||
}
|
||||
|
||||
await Task.Delay(IdleDelay, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/MaksIT.Dapr/Services/WorkLease/DaprWorkLeaseOptions.cs
Normal file
20
src/MaksIT.Dapr/Services/WorkLease/DaprWorkLeaseOptions.cs
Normal file
@ -0,0 +1,20 @@
|
||||
namespace MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Default Dapr state component name for work leases.
|
||||
/// </summary>
|
||||
public interface IDaprWorkLeaseOptions {
|
||||
/// <summary>
|
||||
/// Dapr state store Component name used when callers omit <c>storeName</c>.
|
||||
/// </summary>
|
||||
string StoreName { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprWorkLeaseOptions"/>.
|
||||
/// </summary>
|
||||
public sealed class DaprWorkLeaseOptions : IDaprWorkLeaseOptions {
|
||||
/// <inheritdoc />
|
||||
public required string StoreName { get; init; }
|
||||
}
|
||||
@ -1,29 +1,53 @@
|
||||
using MaksIT.Results;
|
||||
|
||||
|
||||
namespace MaksIT.Dapr.Services;
|
||||
|
||||
public sealed record DaprWorkLease(
|
||||
string HolderId,
|
||||
DateTimeOffset AcquiredAtUtc,
|
||||
DateTimeOffset ExpiresAtUtc
|
||||
);
|
||||
namespace MaksIT.Dapr.Services.WorkLease;
|
||||
|
||||
/// <summary>
|
||||
/// HA work coordination via Dapr state store (broker-agnostic).
|
||||
/// Keys are product-defined; store name comes from the Dapr Component.
|
||||
/// HA work coordination via Dapr state (broker-agnostic).
|
||||
/// Keys are product-defined; store name comes from the Dapr Component or <see cref="IDaprWorkLeaseOptions"/>.
|
||||
/// </summary>
|
||||
public interface IDaprWorkLeaseStore {
|
||||
public interface IDaprWorkLeaseService {
|
||||
/// <summary>
|
||||
/// Tries to acquire or take over an expired lease for <paramref name="holderId"/>.
|
||||
/// </summary>
|
||||
Task<Result<bool>> TryAcquireAsync(string storeName, string workKey, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extends an existing lease when <paramref name="holderId"/> still holds it.
|
||||
/// </summary>
|
||||
Task<Result<bool>> TryRenewAsync(string storeName, string workKey, string holderId, TimeSpan ttl, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Releases the lease when held by <paramref name="holderId"/>.
|
||||
/// </summary>
|
||||
Task<Result> ReleaseAsync(string storeName, string workKey, string holderId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the current lease document, or <c>null</c> when missing.
|
||||
/// </summary>
|
||||
Task<Result<DaprWorkLease?>> GetAsync(string storeName, string workKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Acquires a scoped hold with optional auto-renew. <c>Ok(null)</c> when not acquired; unsuccessful on infra errors.
|
||||
/// </summary>
|
||||
Task<Result<DaprWorkLeaseHold?>> TryHoldAsync(
|
||||
string storeName,
|
||||
string workKey,
|
||||
string holderId,
|
||||
TimeSpan ttl,
|
||||
bool autoRenew = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class DaprWorkLeaseStore(
|
||||
/// <summary>
|
||||
/// Default <see cref="IDaprWorkLeaseService"/> using ETag concurrency on <see cref="IDaprStateStoreService"/>.
|
||||
/// </summary>
|
||||
public sealed class DaprWorkLeaseService(
|
||||
IDaprStateStoreService stateStore
|
||||
) : IDaprWorkLeaseStore {
|
||||
) : IDaprWorkLeaseService {
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<bool>> TryAcquireAsync(
|
||||
string storeName,
|
||||
string workKey,
|
||||
@ -34,9 +58,12 @@ public sealed class DaprWorkLeaseStore(
|
||||
if (!validation.IsSuccess)
|
||||
return validation.ToResultOfType<bool>(false);
|
||||
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(
|
||||
storeName,
|
||||
workKey,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!existing.IsSuccess)
|
||||
return existing.ToResult().ToResultOfType<bool>(false);
|
||||
return existing.ToResultOfType<bool>(false);
|
||||
|
||||
var (lease, etag) = existing.Value;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
@ -44,15 +71,26 @@ public sealed class DaprWorkLeaseStore(
|
||||
if (lease is not null && lease.ExpiresAtUtc > now && !string.Equals(lease.HolderId, holderId, StringComparison.Ordinal))
|
||||
return Result<bool>.Ok(false);
|
||||
|
||||
var next = new DaprWorkLease(holderId, now, now.Add(ttl));
|
||||
// First write: etag may be null/empty when key missing.
|
||||
var saved = await stateStore.TrySaveStateAsync(storeName, workKey, next, etag, cancellationToken).ConfigureAwait(false);
|
||||
var generation = lease is null
|
||||
? 1L
|
||||
: string.Equals(lease.HolderId, holderId, StringComparison.Ordinal)
|
||||
? lease.Generation
|
||||
: lease.Generation + 1;
|
||||
|
||||
var next = new DaprWorkLease(holderId, now, now.Add(ttl), generation);
|
||||
var saved = await stateStore.TrySaveStateAsync(
|
||||
storeName,
|
||||
workKey,
|
||||
next,
|
||||
etag,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!saved.IsSuccess)
|
||||
return saved;
|
||||
|
||||
return Result<bool>.Ok(saved.Value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<bool>> TryRenewAsync(
|
||||
string storeName,
|
||||
string workKey,
|
||||
@ -63,9 +101,12 @@ public sealed class DaprWorkLeaseStore(
|
||||
if (!validation.IsSuccess)
|
||||
return validation.ToResultOfType<bool>(false);
|
||||
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(
|
||||
storeName,
|
||||
workKey,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!existing.IsSuccess)
|
||||
return existing.ToResult().ToResultOfType<bool>(false);
|
||||
return existing.ToResultOfType<bool>(false);
|
||||
|
||||
var (lease, etag) = existing.Value;
|
||||
if (lease is null || !string.Equals(lease.HolderId, holderId, StringComparison.Ordinal))
|
||||
@ -73,13 +114,19 @@ public sealed class DaprWorkLeaseStore(
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var next = lease with { ExpiresAtUtc = now.Add(ttl) };
|
||||
var saved = await stateStore.TrySaveStateAsync(storeName, workKey, next, etag, cancellationToken).ConfigureAwait(false);
|
||||
var saved = await stateStore.TrySaveStateAsync(
|
||||
storeName,
|
||||
workKey,
|
||||
next,
|
||||
etag,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!saved.IsSuccess)
|
||||
return saved;
|
||||
|
||||
return Result<bool>.Ok(saved.Value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result> ReleaseAsync(
|
||||
string storeName,
|
||||
string workKey,
|
||||
@ -88,7 +135,10 @@ public sealed class DaprWorkLeaseStore(
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey) || string.IsNullOrWhiteSpace(holderId))
|
||||
return Result.BadRequest("storeName, workKey, and holderId are required.");
|
||||
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(
|
||||
storeName,
|
||||
workKey,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!existing.IsSuccess)
|
||||
return existing.ToResult();
|
||||
|
||||
@ -99,23 +149,49 @@ public sealed class DaprWorkLeaseStore(
|
||||
if (!string.Equals(lease.HolderId, holderId, StringComparison.Ordinal))
|
||||
return Result.Conflict("Lease is held by another instance.");
|
||||
|
||||
return await stateStore.DeleteStateAsync(storeName, workKey).ConfigureAwait(false);
|
||||
return await stateStore.DeleteStateAsync(storeName, workKey, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<DaprWorkLease?>> GetAsync(
|
||||
string storeName,
|
||||
string workKey,
|
||||
CancellationToken cancellationToken = default) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey))
|
||||
return Result<DaprWorkLease?>.BadRequest(null, "storeName and workKey are required.");
|
||||
return Result<DaprWorkLease?>.BadRequest(default, "storeName and workKey are required.");
|
||||
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
|
||||
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(
|
||||
storeName,
|
||||
workKey,
|
||||
cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (!existing.IsSuccess)
|
||||
return existing.ToResult().ToResultOfType<DaprWorkLease?>(null);
|
||||
return existing.ToResultOfType((DaprWorkLease?)null);
|
||||
|
||||
return Result<DaprWorkLease?>.Ok(existing.Value.Value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Result<DaprWorkLeaseHold?>> TryHoldAsync(
|
||||
string storeName,
|
||||
string workKey,
|
||||
string holderId,
|
||||
TimeSpan ttl,
|
||||
bool autoRenew = true,
|
||||
CancellationToken cancellationToken = default) {
|
||||
var acquired = await TryAcquireAsync(storeName, workKey, holderId, ttl, cancellationToken).ConfigureAwait(false);
|
||||
if (!acquired.IsSuccess)
|
||||
return acquired.ToResultOfType((DaprWorkLeaseHold?)null);
|
||||
if (!acquired.Value)
|
||||
return Result<DaprWorkLeaseHold?>.Ok(null);
|
||||
|
||||
var current = await GetAsync(storeName, workKey, cancellationToken).ConfigureAwait(false);
|
||||
if (!current.IsSuccess)
|
||||
return current.ToResultOfType((DaprWorkLeaseHold?)null);
|
||||
|
||||
var generation = current.Value?.Generation ?? 0;
|
||||
return Result<DaprWorkLeaseHold?>.Ok(new DaprWorkLeaseHold(this, storeName, workKey, holderId, ttl, generation, autoRenew));
|
||||
}
|
||||
|
||||
private static Result Validate(string storeName, string workKey, string holderId, TimeSpan ttl) {
|
||||
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey) || string.IsNullOrWhiteSpace(holderId))
|
||||
return Result.BadRequest("storeName, workKey, and holderId are required.");
|
||||
@ -1,3 +0,0 @@
|
||||
@echo off
|
||||
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %*
|
||||
pause
|
||||
@ -80,8 +80,9 @@ else {
|
||||
}
|
||||
}
|
||||
|
||||
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false
|
||||
if (-not $pluginSucceeded) {
|
||||
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot
|
||||
# Exact $true only: polluted arrays (CLI stdout + $false) are truthy under -not.
|
||||
if ($pluginSucceeded -ne $true) {
|
||||
$releaseHadPluginFailures = $true
|
||||
break
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
"stageLabel": "test",
|
||||
"enabled": true,
|
||||
"project": "..\\..\\..\\src\\MaksIT.Dapr.Tests",
|
||||
"resultsDir": "..\\..\\..\\testResults"
|
||||
"resultsDir": "..\\..\\..\\test-results"
|
||||
},
|
||||
{
|
||||
"name": "QualityGate",
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
Plugin-driven test and coverage engine entry script.
|
||||
#>
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
|
||||
|
||||
@ -29,8 +31,9 @@ if ($configuredPlugins.Count -eq 0) {
|
||||
$testHadPluginFailures = $false
|
||||
|
||||
foreach ($plugin in $configuredPlugins) {
|
||||
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir -ContinueOnError:$false
|
||||
if (-not $pluginSucceeded) {
|
||||
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir
|
||||
# Exact $true only: polluted arrays (CLI stdout + $false) are truthy under -not.
|
||||
if ($pluginSucceeded -ne $true) {
|
||||
$testHadPluginFailures = $true
|
||||
break
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ function Test-IsEngineRuntimeModuleName {
|
||||
[string]$ModuleName
|
||||
)
|
||||
|
||||
# Engine runtime under modules/ only — never dual-homed under plugins/.
|
||||
# Host engine runtime under modules/ (and optional modules/Extensions/) — never dual-homed under plugins/.
|
||||
$engineNames = [System.Collections.Generic.HashSet[string]]::new(
|
||||
[string[]]@(
|
||||
'ChangelogSupport',
|
||||
@ -34,11 +34,7 @@ function Test-IsEngineRuntimeModuleName {
|
||||
'EngineContext',
|
||||
'PluginSupport',
|
||||
'ReleaseSupport',
|
||||
'TestSupport',
|
||||
'DeployConfig',
|
||||
'EngineContextSupport',
|
||||
'OrchestratorSupport',
|
||||
'PluginPathSupport'
|
||||
'TestSupport'
|
||||
),
|
||||
[System.StringComparer]::OrdinalIgnoreCase
|
||||
)
|
||||
@ -46,6 +42,34 @@ function Test-IsEngineRuntimeModuleName {
|
||||
return $engineNames.Contains($ModuleName)
|
||||
}
|
||||
|
||||
function Get-PluginDependencyGroupDirectories {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$PluginsRoot
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $PluginsRoot -PathType Container)) {
|
||||
return @()
|
||||
}
|
||||
|
||||
# Prefer Shared (helpers), then stock host groups; any other plugins/{Group}/ is discovered.
|
||||
$preferred = @('Shared', 'Platform', 'DotNet', 'Npm')
|
||||
$dirs = [System.Collections.Generic.List[string]]::new()
|
||||
foreach ($name in $preferred) {
|
||||
$path = Join-Path $PluginsRoot $name
|
||||
if (Test-Path -LiteralPath $path -PathType Container) {
|
||||
$dirs.Add($path)
|
||||
}
|
||||
}
|
||||
|
||||
Get-ChildItem -LiteralPath $PluginsRoot -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -notin $preferred } |
|
||||
Sort-Object Name |
|
||||
ForEach-Object { $dirs.Add($_.FullName) }
|
||||
|
||||
return @($dirs)
|
||||
}
|
||||
|
||||
function Import-PluginDependency {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
@ -66,16 +90,16 @@ function Import-PluginDependency {
|
||||
$candidatePaths = [System.Collections.Generic.List[string]]::new()
|
||||
|
||||
if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) {
|
||||
# Engine runtime: modules/ only (no plugins/ fallback).
|
||||
# Engine runtime: modules/ only (no plugins/ fallback). Optional Extensions/ for layered hosts.
|
||||
$candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1"))
|
||||
$candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1"))
|
||||
$extensionsDir = Join-Path $modulesDir 'Extensions'
|
||||
$candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1"))
|
||||
}
|
||||
else {
|
||||
# Plugin helpers: plugins/ only (no modules/ legacy shadow).
|
||||
foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) {
|
||||
$candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1"))
|
||||
# Plugin helpers: plugins/{Group}/ only (no modules/ legacy shadow). Groups are discovered.
|
||||
foreach ($groupDir in Get-PluginDependencyGroupDirectories -PluginsRoot $pluginsRoot) {
|
||||
$candidatePaths.Add((Join-Path $groupDir "$ModuleName.psm1"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -344,8 +368,8 @@ function Get-RegistryCredentialsFromRuntime {
|
||||
|
||||
.DESCRIPTION
|
||||
Looks up the environment variable named by SecretName. The value must be
|
||||
Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login
|
||||
and image-pull secret creation — never pass the password itself as a parameter.
|
||||
Base64(UTF8('username:password')). Used by registry login and image-pull
|
||||
secret creation — never pass the password itself as a parameter.
|
||||
|
||||
.PARAMETER SecretName
|
||||
Logical secret name (environment variable name), not a password or token.
|
||||
@ -671,10 +695,7 @@ function Invoke-ConfiguredPlugin {
|
||||
[psobject]$SharedSettings,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$EngineDirectory,
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[bool]$ContinueOnError = $false
|
||||
[string]$EngineDirectory
|
||||
)
|
||||
|
||||
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
|
||||
@ -716,12 +737,16 @@ function Invoke-ConfiguredPlugin {
|
||||
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
|
||||
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..."
|
||||
|
||||
# Sink plugin success-stream output to the host so it cannot pollute this
|
||||
# function's return value. Otherwise `return $false` after CLI stdout becomes
|
||||
# @("helm-line…", $false), which is truthy under `if (-not $result)` and
|
||||
# causes RELEASE COMPLETE / exit 0 after a failed plugin.
|
||||
try {
|
||||
$moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
|
||||
$invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
|
||||
$pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
|
||||
|
||||
& $invokeCommand -Settings $pluginSettings
|
||||
& $invokeCommand -Settings $pluginSettings | ForEach-Object { Write-Host $_ }
|
||||
Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
|
||||
return $true
|
||||
}
|
||||
|
||||
@ -1,6 +1,16 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
Runs native CLIs (dotnet, git, helm, …) and keeps $LASTEXITCODE intact.
|
||||
By default throws on non-zero exit so callers cannot forget to check.
|
||||
Pass -ThrowOnError:$false when you need the exit code / output yourself
|
||||
(e.g. TestRunner Success objects, logging full container build output first).
|
||||
|
||||
Test hooks: Set-ExternalCommandTestHandler / Set-ExternalCommandAvailability
|
||||
let Pester stub CLIs without touching PATH.
|
||||
#>
|
||||
|
||||
$script:ExternalCommandTestHandler = $null
|
||||
$script:ExternalCommandAvailability = @{}
|
||||
|
||||
@ -40,7 +50,10 @@ function Invoke-ExternalCommand {
|
||||
|
||||
[string]$InputObject,
|
||||
|
||||
[switch]$MergeErrorOutput
|
||||
[switch]$MergeErrorOutput,
|
||||
|
||||
# Default true: fail fast. Soft callers (tests, nested loggers) pass $false.
|
||||
[bool]$ThrowOnError = $true
|
||||
)
|
||||
|
||||
$previousLocation = $null
|
||||
@ -51,6 +64,7 @@ function Invoke-ExternalCommand {
|
||||
|
||||
try {
|
||||
$effectiveWorkingDirectory = (Get-Location).Path
|
||||
$output = @()
|
||||
|
||||
if ($null -ne $script:ExternalCommandTestHandler) {
|
||||
$handlerResult = & $script:ExternalCommandTestHandler `
|
||||
@ -62,31 +76,46 @@ function Invoke-ExternalCommand {
|
||||
|
||||
$global:LASTEXITCODE = [int]$handlerResult.ExitCode
|
||||
if ($null -eq $handlerResult.Output) {
|
||||
return @()
|
||||
$output = @()
|
||||
}
|
||||
|
||||
if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) {
|
||||
return @($handlerResult.Output)
|
||||
elseif ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) {
|
||||
$output = @($handlerResult.Output)
|
||||
}
|
||||
else {
|
||||
$output = @([string]$handlerResult.Output)
|
||||
}
|
||||
|
||||
return @([string]$handlerResult.Output)
|
||||
}
|
||||
|
||||
if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) {
|
||||
throw "External command '$Name' is marked unavailable."
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($InputObject)) {
|
||||
$output = $InputObject | & $Name @ArgumentList 2>&1
|
||||
}
|
||||
elseif ($MergeErrorOutput) {
|
||||
$output = & $Name @ArgumentList 2>&1
|
||||
}
|
||||
else {
|
||||
$output = & $Name @ArgumentList
|
||||
if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) {
|
||||
throw "External command '$Name' is marked unavailable."
|
||||
}
|
||||
|
||||
if (-not [string]::IsNullOrWhiteSpace($InputObject)) {
|
||||
$raw = $InputObject | & $Name @ArgumentList 2>&1
|
||||
}
|
||||
elseif ($MergeErrorOutput) {
|
||||
$raw = & $Name @ArgumentList 2>&1
|
||||
}
|
||||
else {
|
||||
$raw = & $Name @ArgumentList
|
||||
}
|
||||
|
||||
$output = @($raw)
|
||||
}
|
||||
|
||||
return @($output)
|
||||
$exitCode = [int]$global:LASTEXITCODE
|
||||
if ($ThrowOnError -and $exitCode -ne 0) {
|
||||
$preview = ($output | ForEach-Object {
|
||||
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() } else { [string]$_ }
|
||||
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 8) -join ' '
|
||||
if ([string]::IsNullOrWhiteSpace($preview)) {
|
||||
throw "External command '$Name' failed with exit code $exitCode."
|
||||
}
|
||||
|
||||
throw "External command '$Name' failed with exit code $exitCode. $preview"
|
||||
}
|
||||
|
||||
return $output
|
||||
}
|
||||
finally {
|
||||
if ($null -ne $previousLocation) {
|
||||
|
||||
@ -53,21 +53,18 @@ function Invoke-GitInternal {
|
||||
|
||||
if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) {
|
||||
$srcDir = Split-Path $PSScriptRoot -Parent
|
||||
$externalCandidates = @(
|
||||
(Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'),
|
||||
(Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1')
|
||||
)
|
||||
foreach ($externalModule in $externalCandidates) {
|
||||
if (Test-Path -LiteralPath $externalModule -PathType Leaf) {
|
||||
Import-Module $externalModule -Global
|
||||
break
|
||||
}
|
||||
$externalModule = Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'
|
||||
if (Test-Path -LiteralPath $externalModule -PathType Leaf) {
|
||||
Import-Module $externalModule -Global
|
||||
}
|
||||
elseif (Test-Path -LiteralPath (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -PathType Leaf) {
|
||||
Import-Module (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -Global
|
||||
}
|
||||
}
|
||||
|
||||
if ($CaptureOutput) {
|
||||
if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) {
|
||||
$output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput
|
||||
$output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput -ThrowOnError:$false
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) {
|
||||
Write-Error "$ErrorMessage (exit code: $exitCode)"
|
||||
@ -96,7 +93,7 @@ function Invoke-GitInternal {
|
||||
}
|
||||
|
||||
if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) {
|
||||
Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput | Out-Null
|
||||
Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput -ThrowOnError:$false | Out-Null
|
||||
}
|
||||
else {
|
||||
& git @Arguments
|
||||
|
||||
@ -19,10 +19,8 @@ function Import-ExternalCommandSupportInternal {
|
||||
return
|
||||
}
|
||||
|
||||
$srcDir = Split-Path $PSScriptRoot -Parent
|
||||
$candidates = @(
|
||||
(Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'),
|
||||
(Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1')
|
||||
(Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1')
|
||||
)
|
||||
foreach ($modulePath in $candidates) {
|
||||
if (Test-Path -LiteralPath $modulePath -PathType Leaf) {
|
||||
@ -175,10 +173,10 @@ function Invoke-TestsWithCoverage {
|
||||
|
||||
Import-ExternalCommandSupportInternal
|
||||
if ($Silent) {
|
||||
$null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput
|
||||
$null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput -ThrowOnError:$false
|
||||
}
|
||||
else {
|
||||
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default
|
||||
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -ThrowOnError:$false | Out-Default
|
||||
}
|
||||
|
||||
$testExitCode = $LASTEXITCODE
|
||||
@ -356,10 +354,10 @@ function Invoke-NpmJestTestsWithCoverage {
|
||||
$npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text')
|
||||
Import-ExternalCommandSupportInternal
|
||||
if ($Silent) {
|
||||
$null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput
|
||||
$null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput -ThrowOnError:$false
|
||||
}
|
||||
else {
|
||||
Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default
|
||||
Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -ThrowOnError:$false | Out-Default
|
||||
}
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
Pester test plugin for the RepoUtils test engine.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs the community Pester suite and publishes normalized coverage metrics on the
|
||||
Runs the RepoUtils Pester suite and publishes normalized coverage metrics on the
|
||||
shared engine context for QualityGate.
|
||||
#>
|
||||
|
||||
|
||||
@ -1,439 +0,0 @@
|
||||
#requires -Version 7.0
|
||||
#requires -PSEdition Core
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Refreshes a local maksit-repoutils copy from GitHub.
|
||||
|
||||
.DESCRIPTION
|
||||
This script clones the configured repository into a temporary directory,
|
||||
refreshes the parent directory of this script, preserves existing
|
||||
scriptSettings.json files in subfolders, and copies the cloned source contents into that parent directory.
|
||||
|
||||
All configuration is stored in scriptSettings.json.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh -File .\Update-RepoUtils.ps1
|
||||
|
||||
.NOTES
|
||||
CONFIGURATION (scriptSettings.json):
|
||||
- dryRun: If true, logs the planned update without modifying files
|
||||
- repository.url: Git repository to clone
|
||||
- repository.sourceSubdirectory: Folder copied into the target directory
|
||||
- repository.preserveFileName: Existing file name to preserve in subfolders
|
||||
- repository.cloneDepth: Depth used for git clone
|
||||
- repository.skippedRelativeDirectories: Relative directories to exclude from phase-two refresh (preserve dest)
|
||||
- repository.omittedRelativeDirectories: Relative directories to delete from dest and never copy from source
|
||||
(product repos: ["tests"] — RepoUtils self-tests stay only in maksit-repoutils / enterprise)
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$ContinueAfterSelfUpdate,
|
||||
[string]$TargetDirectoryOverride,
|
||||
[string]$ClonedSourceDirectoryOverride,
|
||||
[string]$TemporaryRootOverride
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$srcDir = Split-Path (Split-Path $scriptDir -Parent) -Parent
|
||||
$modulesDir = Join-Path $srcDir 'modules'
|
||||
|
||||
# Refresh the src directory that contains modules, engines, plugins, and tools.
|
||||
$targetDirectory = if ([string]::IsNullOrWhiteSpace($TargetDirectoryOverride)) {
|
||||
$srcDir
|
||||
}
|
||||
else {
|
||||
[System.IO.Path]::GetFullPath($TargetDirectoryOverride)
|
||||
}
|
||||
$currentScriptPath = [System.IO.Path]::GetFullPath($MyInvocation.MyCommand.Path)
|
||||
$selfUpdateDirectory = [System.IO.Path]::Combine('tools', 'Update-RepoUtils')
|
||||
|
||||
function ConvertTo-NormalizedRelativePath {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$normalizedPath = $Path.Replace('/', [System.IO.Path]::DirectorySeparatorChar).Replace('\', [System.IO.Path]::DirectorySeparatorChar)
|
||||
return $normalizedPath.TrimStart('.', [System.IO.Path]::DirectorySeparatorChar).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
|
||||
}
|
||||
|
||||
function Test-IsInRelativeDirectory {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$RelativePath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string[]]$Directories
|
||||
)
|
||||
|
||||
$normalizedRelativePath = ConvertTo-NormalizedRelativePath -Path $RelativePath
|
||||
foreach ($directory in $Directories) {
|
||||
$normalizedDirectory = ConvertTo-NormalizedRelativePath -Path $directory
|
||||
if ([string]::IsNullOrWhiteSpace($normalizedDirectory)) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
$normalizedRelativePath.Equals($normalizedDirectory, [System.StringComparison]::OrdinalIgnoreCase) -or
|
||||
$normalizedRelativePath.StartsWith($normalizedDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
) {
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Get-ReleaseDeployPreserveFiles {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetDirectory
|
||||
)
|
||||
|
||||
return @()
|
||||
}
|
||||
|
||||
function Add-PreservedFileBackup {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.Collections.IList]$PreservedFiles,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[System.IO.FileInfo]$File,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TargetDirectory,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$TemporaryRoot,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[bool]$DryRun
|
||||
)
|
||||
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($TargetDirectory, $File.FullName)
|
||||
foreach ($existing in $PreservedFiles) {
|
||||
if ($existing.RelativePath.Equals($relativePath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
$backupPath = Join-Path $TemporaryRoot ("preserved-" + ($relativePath -replace '[\\/:*?""<>|]', '_'))
|
||||
$PreservedFiles.Add([pscustomobject]@{
|
||||
RelativePath = $relativePath
|
||||
BackupPath = $backupPath
|
||||
}) | Out-Null
|
||||
|
||||
if (-not $DryRun) {
|
||||
$backupDirectory = Split-Path -Parent $backupPath
|
||||
if (-not (Test-Path -Path $backupDirectory -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $backupDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $File.FullName -Destination $backupPath -Force
|
||||
}
|
||||
}
|
||||
|
||||
#region Import Modules
|
||||
|
||||
$scriptConfigModulePath = Join-Path $modulesDir "ScriptConfig.psm1"
|
||||
if (-not (Test-Path $scriptConfigModulePath)) {
|
||||
Write-Error "ScriptConfig module not found at: $scriptConfigModulePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$loggingModulePath = Join-Path $modulesDir "Logging.psm1"
|
||||
if (-not (Test-Path $loggingModulePath)) {
|
||||
Write-Error "Logging module not found at: $loggingModulePath"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Import-Module $scriptConfigModulePath -Force
|
||||
Import-Module $loggingModulePath -Force
|
||||
|
||||
#endregion
|
||||
|
||||
#region Load Settings
|
||||
|
||||
$settings = Get-ScriptSettings -ScriptDir $scriptDir
|
||||
|
||||
#endregion
|
||||
|
||||
#region Configuration
|
||||
|
||||
$repositoryUrl = $settings.repository.url
|
||||
$dryRun = if ($null -ne $settings.dryRun) { [bool]$settings.dryRun } else { $false }
|
||||
$sourceSubdirectory = if ($settings.repository.sourceSubdirectory) { $settings.repository.sourceSubdirectory } else { 'src' }
|
||||
$preserveFileName = if ($settings.repository.preserveFileName) { $settings.repository.preserveFileName } else { 'scriptSettings.json' }
|
||||
$cloneDepth = if ($settings.repository.cloneDepth) { [int]$settings.repository.cloneDepth } else { 1 }
|
||||
[string[]]$skippedRelativeDirectories = if ($settings.repository.skippedRelativeDirectories) {
|
||||
@(
|
||||
$settings.repository.skippedRelativeDirectories |
|
||||
ForEach-Object {
|
||||
ConvertTo-NormalizedRelativePath -Path ([string]$_)
|
||||
}
|
||||
)
|
||||
}
|
||||
else {
|
||||
@(
|
||||
[System.IO.Path]::Combine('engines', 'release', 'custom'),
|
||||
[System.IO.Path]::Combine('engines', 'test', 'custom')
|
||||
)
|
||||
}
|
||||
[string[]]$omittedRelativeDirectories = if ($settings.repository.omittedRelativeDirectories) {
|
||||
@(
|
||||
$settings.repository.omittedRelativeDirectories |
|
||||
ForEach-Object {
|
||||
ConvertTo-NormalizedRelativePath -Path ([string]$_)
|
||||
} |
|
||||
Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
|
||||
)
|
||||
}
|
||||
else {
|
||||
@()
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate CLI Dependencies
|
||||
|
||||
Assert-Command git
|
||||
Assert-Command pwsh
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($repositoryUrl)) {
|
||||
Write-Error "repository.url is required in scriptSettings.json."
|
||||
exit 1
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Main
|
||||
|
||||
Write-Log -Level "INFO" -Message "========================================"
|
||||
Write-Log -Level "INFO" -Message "Update RepoUtils Script"
|
||||
Write-Log -Level "INFO" -Message "========================================"
|
||||
Write-Log -Level "INFO" -Message "Target directory: $targetDirectory"
|
||||
Write-Log -Level "INFO" -Message "Dry run: $dryRun"
|
||||
|
||||
$ownsTemporaryRoot = [string]::IsNullOrWhiteSpace($TemporaryRootOverride)
|
||||
$temporaryRoot = if ($ownsTemporaryRoot) {
|
||||
Join-Path ([System.IO.Path]::GetTempPath()) ("maksit-repoutils-update-" + [System.Guid]::NewGuid().ToString('N'))
|
||||
}
|
||||
else {
|
||||
[System.IO.Path]::GetFullPath($TemporaryRootOverride)
|
||||
}
|
||||
|
||||
try {
|
||||
$clonedSourceDirectory = if ([string]::IsNullOrWhiteSpace($ClonedSourceDirectoryOverride)) {
|
||||
Write-LogStep "Cloning latest repository snapshot..."
|
||||
& git clone --depth $cloneDepth $repositoryUrl $temporaryRoot
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "git clone failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Repository cloned"
|
||||
|
||||
Join-Path $temporaryRoot $sourceSubdirectory
|
||||
}
|
||||
else {
|
||||
[System.IO.Path]::GetFullPath($ClonedSourceDirectoryOverride)
|
||||
}
|
||||
|
||||
if (-not (Test-Path -Path $clonedSourceDirectory -PathType Container)) {
|
||||
throw "The cloned repository does not contain the expected source directory: $clonedSourceDirectory"
|
||||
}
|
||||
|
||||
if (-not $ContinueAfterSelfUpdate) {
|
||||
if ($dryRun) {
|
||||
Write-LogStep "Dry run self-update summary"
|
||||
Write-Log -Level "INFO" -Message "Would refresh shared modules and $selfUpdateDirectory before relaunching the updater"
|
||||
}
|
||||
else {
|
||||
Write-LogStep "Refreshing updater files..."
|
||||
$selfUpdateFiles = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File |
|
||||
Where-Object {
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName)
|
||||
$isRootFile = -not $relativePath.Contains([System.IO.Path]::DirectorySeparatorChar)
|
||||
$isUpdaterFile = $relativePath.StartsWith($selfUpdateDirectory + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
|
||||
$_.Name -ne $preserveFileName -and
|
||||
($isRootFile -or $isUpdaterFile)
|
||||
}
|
||||
|
||||
foreach ($sourceFile in $selfUpdateFiles) {
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName)
|
||||
$destinationPath = Join-Path $targetDirectory $relativePath
|
||||
$destinationDirectory = Split-Path -Parent $destinationPath
|
||||
if (-not (Test-Path -Path $destinationDirectory -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message "Updater files refreshed"
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
Write-LogStep "Dry run bootstrap completed"
|
||||
Write-Log -Level "INFO" -Message "Continuing with phase two in the current process because no files were changed"
|
||||
}
|
||||
else {
|
||||
Write-LogStep "Relaunching the updated updater..."
|
||||
& pwsh -File $currentScriptPath `
|
||||
-ContinueAfterSelfUpdate `
|
||||
-TargetDirectoryOverride $targetDirectory `
|
||||
-ClonedSourceDirectoryOverride $clonedSourceDirectory `
|
||||
-TemporaryRootOverride $temporaryRoot
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Relaunched updater failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message "Bootstrap phase completed"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
$preservedFiles = [System.Collections.ArrayList]@()
|
||||
[string[]]$updatePhaseSkippedDirectories = @($skippedRelativeDirectories) + $selfUpdateDirectory
|
||||
$existingPreservedFiles = Get-ChildItem -Path $targetDirectory -Recurse -File -Filter $preserveFileName -ErrorAction SilentlyContinue
|
||||
if ($existingPreservedFiles) {
|
||||
foreach ($file in $existingPreservedFiles) {
|
||||
Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Preserved $($existingPreservedFiles.Count) existing $preserveFileName file(s)"
|
||||
}
|
||||
else {
|
||||
Write-Log -Level "WARN" -Message "No existing $preserveFileName files found in subfolders"
|
||||
}
|
||||
|
||||
$releaseDeployPreserveFiles = @(Get-ReleaseDeployPreserveFiles -TargetDirectory $targetDirectory)
|
||||
if ($releaseDeployPreserveFiles.Count -gt 0) {
|
||||
foreach ($file in $releaseDeployPreserveFiles) {
|
||||
Add-PreservedFileBackup -PreservedFiles $preservedFiles -File $file -TargetDirectory $targetDirectory -TemporaryRoot $temporaryRoot -DryRun $dryRun
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Preserved $($releaseDeployPreserveFiles.Count) release deploy file(s)"
|
||||
}
|
||||
|
||||
$preservedRelativePaths = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||
foreach ($preservedFile in $preservedFiles) {
|
||||
[void]$preservedRelativePaths.Add($preservedFile.RelativePath)
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
Write-LogStep "Dry run summary"
|
||||
Write-Log -Level "INFO" -Message "Would remove all files under target except preserved $preserveFileName and release deploy files"
|
||||
Write-Log -Level "INFO" -Message "Would skip phase-two refresh for: $($updatePhaseSkippedDirectories -join ', ')"
|
||||
if ($omittedRelativeDirectories.Count -gt 0) {
|
||||
Write-Log -Level "INFO" -Message "Would omit (delete dest + skip copy): $($omittedRelativeDirectories -join ', ')"
|
||||
}
|
||||
Write-Log -Level "INFO" -Message "Would copy refreshed files from: $clonedSourceDirectory"
|
||||
if ($preservedFiles.Count -gt 0) {
|
||||
$preservedList = ($preservedFiles | ForEach-Object { $_.RelativePath }) -join ", "
|
||||
Write-Log -Level "INFO" -Message "Would restore preserved files: $preservedList"
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Dry run completed. No files were modified."
|
||||
return
|
||||
}
|
||||
|
||||
Write-LogStep "Cleaning target directory..."
|
||||
$filesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -File |
|
||||
Where-Object {
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $_.FullName)
|
||||
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
|
||||
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
|
||||
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
|
||||
|
||||
$_.Name -ne $preserveFileName -and
|
||||
-not $preservedRelativePaths.Contains($relativePath) -and
|
||||
(-not $isInSkippedDirectory -or $isInOmittedDirectory)
|
||||
}
|
||||
|
||||
foreach ($file in $filesToRemove) {
|
||||
Remove-Item -Path $file.FullName -Force
|
||||
}
|
||||
|
||||
$directoriesToRemove = Get-ChildItem -Path $targetDirectory -Recurse -Force -Directory |
|
||||
Sort-Object { $_.FullName.Length } -Descending
|
||||
|
||||
foreach ($directory in $directoriesToRemove) {
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($targetDirectory, $directory.FullName)
|
||||
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
|
||||
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
|
||||
if ((Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories) -and
|
||||
-not $isInOmittedDirectory) {
|
||||
continue
|
||||
}
|
||||
|
||||
$remainingItems = Get-ChildItem -Path $directory.FullName -Force -ErrorAction SilentlyContinue
|
||||
if (-not $remainingItems) {
|
||||
Remove-Item -Path $directory.FullName -Force
|
||||
}
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Target directory cleaned"
|
||||
|
||||
Write-LogStep "Copying refreshed source files..."
|
||||
$sourceFilesToCopy = Get-ChildItem -Path $clonedSourceDirectory -Recurse -Force -File |
|
||||
Where-Object {
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $_.FullName)
|
||||
$isInSkippedDirectory = Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $updatePhaseSkippedDirectories
|
||||
$isInOmittedDirectory = ($omittedRelativeDirectories.Count -gt 0) -and
|
||||
(Test-IsInRelativeDirectory -RelativePath $relativePath -Directories $omittedRelativeDirectories)
|
||||
|
||||
-not $isInSkippedDirectory -and -not $isInOmittedDirectory
|
||||
}
|
||||
|
||||
foreach ($sourceFile in $sourceFilesToCopy) {
|
||||
$relativePath = [System.IO.Path]::GetRelativePath($clonedSourceDirectory, $sourceFile.FullName)
|
||||
$destinationPath = Join-Path $targetDirectory $relativePath
|
||||
$destinationDirectory = Split-Path -Parent $destinationPath
|
||||
if (-not (Test-Path -Path $destinationDirectory -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $sourceFile.FullName -Destination $destinationPath -Force
|
||||
}
|
||||
|
||||
foreach ($skippedDirectory in $updatePhaseSkippedDirectories) {
|
||||
$skippedSourcePath = Join-Path $clonedSourceDirectory $skippedDirectory
|
||||
if (Test-Path -Path $skippedSourcePath) {
|
||||
Write-Log -Level "INFO" -Message "Skipped refresh for $skippedDirectory"
|
||||
}
|
||||
}
|
||||
foreach ($omittedDirectory in $omittedRelativeDirectories) {
|
||||
Write-Log -Level "INFO" -Message "Omitted (not shipped): $omittedDirectory"
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Source files copied"
|
||||
|
||||
if ($preservedFiles.Count -gt 0) {
|
||||
foreach ($preservedFile in $preservedFiles) {
|
||||
if (-not (Test-Path -Path $preservedFile.BackupPath -PathType Leaf)) {
|
||||
continue
|
||||
}
|
||||
|
||||
$restorePath = Join-Path $targetDirectory $preservedFile.RelativePath
|
||||
$restoreDirectory = Split-Path -Parent $restorePath
|
||||
if (-not (Test-Path -Path $restoreDirectory -PathType Container)) {
|
||||
New-Item -ItemType Directory -Path $restoreDirectory -Force | Out-Null
|
||||
}
|
||||
|
||||
Copy-Item -Path $preservedFile.BackupPath -Destination $restorePath -Force
|
||||
}
|
||||
Write-Log -Level "OK" -Message "Preserved files restored ($($preservedFiles.Count))"
|
||||
}
|
||||
|
||||
Write-Log -Level "OK" -Message "========================================"
|
||||
Write-Log -Level "OK" -Message "Update completed successfully!"
|
||||
Write-Log -Level "OK" -Message "========================================"
|
||||
}
|
||||
finally {
|
||||
if ($ownsTemporaryRoot -and (Test-Path -Path $temporaryRoot)) {
|
||||
Remove-Item -Path $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@ -1,19 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft-07/schema",
|
||||
"title": "Update RepoUtils Script Settings",
|
||||
"description": "Configuration for the Update-RepoUtils utility.",
|
||||
"dryRun": false,
|
||||
"repository": {
|
||||
"url": "https://github.com/MAKS-IT-COM/maksit-repoutils.git",
|
||||
"sourceSubdirectory": "src",
|
||||
"preserveFileName": "scriptSettings.json",
|
||||
"cloneDepth": 1,
|
||||
"skippedRelativeDirectories": [
|
||||
"engines/release/custom",
|
||||
"engines/test/custom"
|
||||
],
|
||||
"omittedRelativeDirectories": [
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user