Compare commits

...

2 Commits
v2.1.0 ... main

Author SHA1 Message Date
Maksym Sadovnychyy
8db977da95 (chore): RepoUtils sync and dependency updates 2026-08-14 20:54:34 +02:00
Maksym Sadovnychyy
bff14420f5 (feature): add actors, workflows, and client facades 2026-08-01 19:27:59 +02:00
47 changed files with 3891 additions and 910 deletions

5
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"recommendations": [
"anysphere.csharp"
]
}

13
.vscode/settings.json vendored Normal file
View 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"
}
}
}

View File

@ -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**.

View File

@ -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
View File

@ -1,206 +1,381 @@
# MaksIT.Dapr
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-41.3%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-25%25-yellow)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-53.1%25-yellowgreen)
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-41.9%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-40.7%25-yellowgreen)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-59.5%25-yellowgreen)
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 apps 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`.

View 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);
}
}

View 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

View File

@ -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 { }));
}
}

View 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));
}
}

View File

@ -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));
}
}

View 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);
}
}

View File

@ -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);
}
}

View 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);
}
}

View File

@ -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.*" />

View 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));
}
}

View File

@ -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&lt;T&gt;()</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>();
}
}

View File

@ -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();
}

View File

@ -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>

View File

@ -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),
};
}

View 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();
}
}

View 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()]);
}
}
}

View 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()]);
}
}
}

View 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

View 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];
}
}

View 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

View 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()]);
}
}
}

View File

@ -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()]);
}
}
}

View 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()]);
}
}
}

View 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()]);
}
}
}

View File

@ -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);
}

View 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();
}
}

View File

@ -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() {

View 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
);

View 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;
}
}
}
}

View 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; }
}

View File

@ -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.");

View File

@ -1,3 +0,0 @@
@echo off
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0tools\Update-RepoUtils\Update-RepoUtils.ps1" %*
pause

View File

@ -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
}

View File

@ -16,7 +16,7 @@
"stageLabel": "test",
"enabled": true,
"project": "..\\..\\..\\src\\MaksIT.Dapr.Tests",
"resultsDir": "..\\..\\..\\testResults"
"resultsDir": "..\\..\\..\\test-results"
},
{
"name": "QualityGate",

View File

@ -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
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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

View File

@ -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) {

View File

@ -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.
#>

View File

@ -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

View File

@ -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"
]
}
}