Compare commits

..

No commits in common. "main" and "v2.1.0" have entirely different histories.
main ... v2.1.0

48 changed files with 922 additions and 3932 deletions

View File

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

13
.vscode/settings.json vendored
View File

@ -1,13 +0,0 @@
{
"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,68 +5,14 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.2.2] - 2026-08-16
### Changed
- **Tests:** migrate to **xunit.v3** **4.0.0** + **Microsoft Testing Platform** only (`src/global.json` `test.runner`; no VSTest / **coverlet.collector** / **Microsoft.NET.Test.Sdk** / **xunit.runner.visualstudio**). Use **coverlet.MTP**; **TestRunner** always uses `--coverlet`.
- Package version **2.2.2**.
## [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 ## [2.1.0] - 2026-07-30
### Added ### Added
- **HA work leases via Dapr state:** `IDaprWorkLeaseService` / `DaprWorkLeaseService` (acquire, renew, release, get) using ETag concurrency — broker-agnostic (NATS KV / Postgres / other state Component). - **HA work leases via Dapr state:** `IDaprWorkLeaseStore` / `DaprWorkLeaseStore` (acquire, renew, release, get) using ETag concurrency — broker-agnostic (NATS KV / Postgres / other state Component).
- **State ETag API:** `GetStateAndETagAsync` and `TrySaveStateAsync` on `IDaprStateStoreService`. - **State ETag API:** `GetStateAndETagAsync` and `TrySaveStateAsync` on `IDaprStateStoreService`.
- **Runtime instance id:** `IDaprRuntimeInstanceId` / `DaprRuntimeInstanceIdProvider` (`POD_NAME` in Kubernetes). - **Runtime instance id:** `IDaprRuntimeInstanceId` / `DaprRuntimeInstanceIdProvider` (`POD_NAME` in Kubernetes).
- **Pub/sub worker helpers:** `IDaprPubSubWorkHandler<T>`, `DaprPubSubAcceptOutcome` / `DaprPubSubAcceptResult`, `DaprPubSubAck` (HTTP ACK/NAK mapping). - **Pub/sub worker helpers:** `IDaprPubSubWorkHandler<T>`, `DaprPubSubAcceptOutcome` / `DaprPubSubAcceptResult`, `DaprPubSubAck` (HTTP ACK/NAK mapping).
- DI: `RegisterWorkLeases()` registers state store + work-lease service + instance id. - DI: `RegisterWorkLeases()` registers state store + lease store + instance id.
### Changed ### Changed
- Package version **2.1.0**. - Package version **2.1.0**.

View File

@ -14,7 +14,7 @@ Thank you for your interest in contributing to `MaksIT.Dapr`.
### Prerequisites ### Prerequisites
- .NET 10 SDK - .NET 8 SDK or later
- Git - Git
- PowerShell 7+ (recommended for utility scripts) - PowerShell 7+ (recommended for utility scripts)
@ -27,21 +27,9 @@ dotnet build MaksIT.Dapr.slnx
### Test ### 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`.
Tests run under **Microsoft Testing Platform** (`src/global.json` `test.runner`) with **xunit.v3** and **coverlet.MTP**.
Quick local run:
```bash ```bash
cd src cd src
dotnet test MaksIT.Dapr.slnx dotnet test MaksIT.Dapr.Tests
``` ```
## Commit Message Format ## Commit Message Format
@ -59,14 +47,7 @@ Use:
| `(feature):` | New feature or enhancement | | `(feature):` | New feature or enhancement |
| `(bugfix):` | Bug fix | | `(bugfix):` | Bug fix |
| `(refactor):` | Refactoring without behavior change | | `(refactor):` | Refactoring without behavior change |
| `(perf):` | Performance improvement | | `(chore):` | Maintenance tasks (dependencies, tooling, docs) |
| `(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 ### Guidelines
@ -76,11 +57,10 @@ Use:
## Pull Request Checklist ## Pull Request Checklist
1. Ensure build and tests pass (RepoUtils test engine when possible). 1. Ensure build and tests pass.
2. Update `README.md` if behavior or usage changed. 2. Update `README.md` if behavior or usage changed.
3. Update `CHANGELOG.md` under the target version. 3. Update `CHANGELOG.md` under the target version.
4. If coverage changed, refresh README shields.io badges via the test engine. 4. Keep changes scoped and explain rationale in the PR description.
5. Keep changes scoped and explain rationale in the PR description.
## Versioning ## Versioning
@ -112,7 +92,7 @@ Builds, tests, packs, and publishes to NuGet and GitHub release flows.
pwsh -File .\utils\engines\release\Invoke-ReleasePackage.ps1 pwsh -File .\utils\engines\release\Invoke-ReleasePackage.ps1
``` ```
Or double-click `utils\Invoke-ReleasePackage.bat`. Or run `utils\Invoke-ReleasePackage-Single.bat` (or `-HA.bat` when Helm deploy is wired).
Prerequisites: Prerequisites:
@ -122,8 +102,6 @@ Prerequisites:
Configuration: `utils/engines/release/scriptSettings.json` 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 ### Update repo utilities
Refreshes `utils/` from maksit-repoutils while preserving local `scriptSettings.json` files. Refreshes `utils/` from maksit-repoutils while preserving local `scriptSettings.json` files.

437
README.md
View File

@ -1,379 +1,204 @@
# MaksIT.Dapr # MaksIT.Dapr
![Line Coverage](https://img.shields.io/badge/Line%20Coverage-41.8%25-yellowgreen) ![Line Coverage](https://img.shields.io/badge/Line%20Coverage-41.3%25-yellowgreen)
![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-40.9%25-yellowgreen) ![Branch Coverage](https://img.shields.io/badge/Branch%20Coverage-25%25-yellow)
![Method Coverage](https://img.shields.io/badge/Method%20Coverage-58.9%25-yellowgreen) ![Method Coverage](https://img.shields.io/badge/Method%20Coverage-53.1%25-yellowgreen)
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. 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.
## Table of Contents ## Table of Contents
- [Overview](#overview) - [MaksIT.Dapr](#maksitdapr)
- [Result conventions](#result-conventions) - [Table of Contents](#table-of-contents)
- [Getting Started](#getting-started) - [Overview](#overview)
- [Installation](#installation) - [Features](#features)
- [Registering services](#registering-services) - [Getting Started](#getting-started)
- [Services](#services) - [Installation](#installation)
- [Pub/Sub — `IDaprPubSubService`](#pubsub--idaprpubsubservice) - [Usage](#usage)
- [State store — `IDaprStateStoreService`](#state-store--idaprstatestoreservice) - [Registering Dapr Services](#registering-dapr-services)
- [Service invocation — `IDaprInvocationService`](#service-invocation--idaprinvocationservice) - [Injecting and Using Dapr Services](#injecting-and-using-dapr-services)
- [Bindings — `IDaprBindingService`](#bindings--idaprbindingservice) - [Contributing](#contributing)
- [Secrets — `IDaprSecretService`](#secrets--idaprsecretservice) - [Contact](#contact)
- [Configuration — `IDaprConfigurationService`](#configuration--idaprconfigurationservice) - [License](#license)
- [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 ## Overview
`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()`. `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.
| Need | Register | Inject | ## Features
|------|----------|--------|
| 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` |
`RegisterDaprClientFacades()` registers all `DaprClient`-backed rows above (not actors, workflows, or work leases). - **Pub-Sub Integration**: Uses Dapr's pub-sub component for seamless event-driven communication.
- **State Management**: Efficient, distributed state handling across microservices.
## 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 ## Getting Started
Ensure that you have the following installed: Ensure that you have the following installed:
- [.NET 10 SDK](https://dotnet.microsoft.com/download) - [.NET 8.0 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)
- [Docker](https://www.docker.com/get-started) (optional; used by RepoUtils Linux test validation)
## Installation ## Installation
To install MaksIT.Core, add the package to your project via NuGet:
```powershell ```powershell
dotnet add package MaksIT.Dapr dotnet add package MaksIT.Dapr
``` ```
Or in your `.csproj`: Or manually add it to your .csproj file:
```xml ```powershell
<PackageReference Include="MaksIT.Dapr" Version="2.2.2" /> <PackageReference Include="MaksIT.Dapr" Version="1.0.0" />
``` ```
## Registering services ## 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`.
```csharp ```csharp
using MaksIT.Dapr.Extensions; using MaksIT.Dapr.Extensions;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// All DaprClient-backed facades (pub/sub, state, invocation, binding, secrets, …) // Register Dapr services
builder.Services.RegisterDaprClientFacades(); builder.Services.RegisterPublisher();
// Or register individually: RegisterPubSub(), RegisterStateStore(), … builder.Services.RegisterStateStore();
// HA coordination (leases via Dapr state Component — e.g. persistent NATS KV) // HA coordination (leases via Dapr state Component — e.g. persistent NATS KV)
builder.Services.RegisterWorkLeases("maksit-cicd-state"); // storeName for LeasedBackgroundService builder.Services.RegisterWorkLeases();
builder.Services.RegisterActors(options => {
options.Actors.RegisterActor<MyActor>();
});
builder.Services.RegisterWorkflows();
var app = builder.Build(); var app = builder.Build();
// After authentication/authorization middleware // Set up Dapr subscriber middleware (optional)
// Only after Webapi Authorization services
app.RegisterSubscriber(); app.RegisterSubscriber();
app.RegisterActorsHandlers();
``` ```
## Services 2. **Use Controller as a Dapr Subscriber**:
### Pub/Sub — `IDaprPubSubService` To designate a controller as a Dapr subscriber, annotate it with the `[Topic("pubsubName", "name")]` attribute:
**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 ```csharp
var result = await pubSub.PublishEventAsync("my-pubsub", "my-topic", command, cancellationToken: ct); using Dapr;
if (!result.IsSuccess) { /* handle */ }
[Topic("my-pubsub", "my-topic")] [Topic("my-pubsub", "my-topic")]
[HttpPost("/my-endpoint")] public class MyController : ControllerBase
public IActionResult Receive([FromBody] MyCommand payload) => {
Result.Ok().ToActionResult(); // ACK; ServiceUnavailable → retry; BadRequest → drop [HttpPost("/my-endpoint")]
public IActionResult ReceiveMessage([FromBody] MyCommand payload)
{
// Handle message
return Ok();
}
}
``` ```
### State store — `IDaprStateStoreService` ### Injecting and Using Dapr Services
**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). With the services registered, you can inject `IDaprPublisherService` and `IDaprStateStoreService` into controllers or other services as needed:
**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 ```csharp
var save = await stateStore.SetStateAsync("my-store", "my-key", "my-value", cancellationToken: ct); using MaksIT.Dapr;
var get = await stateStore.GetStateAsync<string>("my-store", "my-key", cancellationToken: ct);
if (!get.IsSuccess) { /* infra failure */ } public class MyService
var value = get.Value; // null when key is missing {
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
}
}
}
``` ```
### Service invocation — `IDaprInvocationService` This setup enables your ASP.NET Core application to utilize Dapr's pub-sub, state management, and other building blocks with minimal boilerplate.
**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. ### HA work leases (multi-replica)
**When not to:** broadcast or decoupled events → [pub/sub](#pubsub--idaprpubsubservice); calling non-Dapr external APIs → [bindings](#bindings--idaprbindingservice) or a normal `HttpClient`. 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.
| | |
|--|--|
| **DI** | `RegisterInvocation()` |
| **API** | `InvokeAsync` overloads (void / request / response / both) — HTTP **POST** via `CreateInvokableHttpClient` |
```csharp ```csharp
var result = await invocation.InvokeAsync<OrderRequest, OrderResponse>( builder.Services.RegisterWorkLeases();
appId: "orders",
methodName: "create",
data: request,
cancellationToken: ct);
```
### Bindings — `IDaprBindingService` // inject IDaprWorkLeaseStore + IDaprRuntimeInstanceId
var acquired = await leases.TryAcquireAsync(
**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", storeName: "maksit-cicd-state",
workKey: "bootstrap", workKey: "bootstrap",
holderId: runtimeInstance.InstanceId, holderId: runtimeInstance.InstanceId,
ttl: TimeSpan.FromMinutes(5), ttl: TimeSpan.FromMinutes(5),
autoRenew: true, ct);
cancellationToken: ct)).Value;
if (hold is null) if (acquired is { IsSuccess: true, Value: true }) {
return; // another replica holds the lease try { /* exclusive work */ }
finally { await leases.ReleaseAsync("maksit-cicd-state", "bootstrap", runtimeInstance.InstanceId, CancellationToken.None); }
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);
``` ```
For recurring exclusive sweeps, subclass `LeasedBackgroundService` and implement `ExecuteLeasedAsync` (uses `IDaprWorkLeaseOptions.StoreName` from `RegisterWorkLeases(storeName)`). Before lease races on cold start, call `IDaprSidecarService.WaitForSidecarAsync`. Pub/sub workers: implement `IDaprPubSubWorkHandler<T>` and map with `DaprPubSubAck.ToActionResult` (2xx ACK, 503 Busy → redelivery).
### 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 ## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for build/test (RepoUtils), commit format, and release scripts. 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.
## Contact ## 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) - **Email**: [maksym.sadovnychyy@gmail.com](mailto:maksym.sadovnychyy@gmail.com)
## License ## License

View File

@ -1,66 +0,0 @@
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

@ -1,92 +0,0 @@
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,12 +1,11 @@
using Microsoft.Extensions.Logging;
using Dapr.Client; using Dapr.Client;
using Moq;
using MaksIT.Dapr.Services; using MaksIT.Dapr.Services;
using Microsoft.Extensions.Logging;
using Moq;
namespace MaksIT.Dapr.Tests; namespace MaksIT.Dapr.Tests;
public class DaprPubSubServiceTests { public class DaprPublisherServiceTests {
[Fact] [Fact]
public async Task PublishEventAsync_ReturnsOk_WhenPublishSucceeds() { public async Task PublishEventAsync_ReturnsOk_WhenPublishSucceeds() {
var clientMock = new Mock<DaprClient>(); var clientMock = new Mock<DaprClient>();
@ -18,8 +17,8 @@ public class DaprPubSubServiceTests {
It.IsAny<CancellationToken>())) It.IsAny<CancellationToken>()))
.Returns(Task.CompletedTask); .Returns(Task.CompletedTask);
var service = new DaprPubSubService( var service = new DaprPublisherService(
Mock.Of<ILogger<DaprPubSubService>>(), Mock.Of<ILogger<DaprPublisherService>>(),
clientMock.Object); clientMock.Object);
object payload = new { Name = "payload" }; object payload = new { Name = "payload" };
@ -39,8 +38,8 @@ public class DaprPubSubServiceTests {
It.IsAny<CancellationToken>())) It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("publish failed")); .ThrowsAsync(new InvalidOperationException("publish failed"));
var service = new DaprPubSubService( var service = new DaprPublisherService(
Mock.Of<ILogger<DaprPubSubService>>(), Mock.Of<ILogger<DaprPublisherService>>(),
clientMock.Object); clientMock.Object);
object payload = new { Name = "payload" }; object payload = new { Name = "payload" };
@ -48,34 +47,4 @@ public class DaprPubSubServiceTests {
Assert.False(result.IsSuccess); 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

@ -1,13 +0,0 @@
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,9 +1,7 @@
using Microsoft.Extensions.Logging;
using Grpc.Core;
using Dapr.Client; using Dapr.Client;
using Moq;
using MaksIT.Dapr.Services; using MaksIT.Dapr.Services;
using Microsoft.Extensions.Logging;
using Moq;
namespace MaksIT.Dapr.Tests; namespace MaksIT.Dapr.Tests;
@ -30,17 +28,6 @@ public class DaprStateStoreServiceTests {
Assert.True(result.IsSuccess); 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] [Fact]
public async Task GetStateAsync_ReturnsOk_WhenStateExists() { public async Task GetStateAsync_ReturnsOk_WhenStateExists() {
var clientMock = new Mock<DaprClient>(); var clientMock = new Mock<DaprClient>();
@ -64,7 +51,7 @@ public class DaprStateStoreServiceTests {
} }
[Fact] [Fact]
public async Task GetStateAsync_ReturnsOkNull_WhenStateIsNull() { public async Task GetStateAsync_ReturnsNotFound_WhenStateIsNull() {
var clientMock = new Mock<DaprClient>(); var clientMock = new Mock<DaprClient>();
clientMock clientMock
.Setup(x => x.GetStateAsync<string?>( .Setup(x => x.GetStateAsync<string?>(
@ -81,7 +68,7 @@ public class DaprStateStoreServiceTests {
var result = await service.GetStateAsync<string>("store", "key"); var result = await service.GetStateAsync<string>("store", "key");
Assert.True(result.IsSuccess); Assert.False(result.IsSuccess);
Assert.Null(result.Value); Assert.Null(result.Value);
} }
@ -105,138 +92,4 @@ public class DaprStateStoreServiceTests {
Assert.False(result.IsSuccess); 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

@ -1,486 +0,0 @@
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

@ -0,0 +1,82 @@
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

@ -1,70 +0,0 @@
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,16 +5,17 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<IsPackable>false</IsPackable> <IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
<UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner>
<NoWarn>$(NoWarn);xUnit1051</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.MTP" Version="10.0.1" /> <PackageReference Include="coverlet.collector" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageReference Include="Moq" Version="4.*" /> <PackageReference Include="Moq" Version="4.*" />
<PackageReference Include="xunit.v3" Version="4.0.0" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
<PackageReference Include="xunit.v3" Version="3.*" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -1,113 +0,0 @@
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,160 +1,36 @@
using Microsoft.Extensions.DependencyInjection; using MaksIT.Dapr.Services;
using Dapr.Actors.Client; using Microsoft.Extensions.DependencyInjection;
using Dapr.Actors.Runtime;
using Dapr.Client;
using Dapr.Workflow;
using MaksIT.Dapr.Services;
using MaksIT.Dapr.Services.WorkLease;
namespace MaksIT.Dapr.Extensions; namespace MaksIT.Dapr.Extensions;
/// <summary>
/// DI registration helpers for MaksIT.Dapr services.
/// </summary>
public static class ServiceCollectionExtensions { public static class ServiceCollectionExtensions {
private static bool _isDaprClientRegistered;
private static void AddDaprClientOnce(this IServiceCollection services) { private static void AddDaprClientOnce(this IServiceCollection services) {
if (services.Any(d => d.ServiceType == typeof(DaprClient))) if (_isDaprClientRegistered)
return; return;
services.AddDaprClient(); services.AddDaprClient();
_isDaprClientRegistered = true;
} }
/// <summary> public static void RegisterPublisher(this IServiceCollection services) {
/// Registers <see cref="IDaprPubSubService"/> and a <see cref="DaprClient"/> when missing.
/// </summary>
public static void RegisterPubSub(this IServiceCollection services) {
services.AddDaprClientOnce(); services.AddDaprClientOnce();
services.AddSingleton<IDaprPubSubService, DaprPubSubService>(); services.AddSingleton<IDaprPublisherService, DaprPublisherService>();
} }
/// <summary>
/// Registers <see cref="IDaprStateStoreService"/> and a <see cref="DaprClient"/> when missing.
/// </summary>
public static void RegisterStateStore(this IServiceCollection services) { public static void RegisterStateStore(this IServiceCollection services) {
services.AddDaprClientOnce(); services.AddDaprClientOnce();
services.AddSingleton<IDaprStateStoreService, DaprStateStoreService>(); services.AddSingleton<IDaprStateStoreService, DaprStateStoreService>();
} }
/// <summary> /// <summary>
/// Registers <see cref="IDaprInvocationService"/>. /// Registers Dapr state store plus HA work-lease coordination and runtime instance id.
/// </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> /// </summary>
public static void RegisterWorkLeases(this IServiceCollection services) { public static void RegisterWorkLeases(this IServiceCollection services) {
services.RegisterStateStore(); services.RegisterStateStore();
services.AddSingleton<IDaprRuntimeInstanceId, DaprRuntimeInstanceIdProvider>(); services.AddSingleton<IDaprRuntimeInstanceId, DaprRuntimeInstanceIdProvider>();
services.AddSingleton<IDaprWorkLeaseService, DaprWorkLeaseService>(); services.AddSingleton<IDaprWorkLeaseStore, DaprWorkLeaseStore>();
}
/// <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,11 +1,7 @@
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
namespace MaksIT.Dapr.Extensions; namespace MaksIT.Dapr.Extensions;
/// <summary>
/// ASP.NET Core pipeline helpers for Dapr pub/sub and actors.
/// </summary>
public static class WebApplicationExtensions { public static class WebApplicationExtensions {
/// <summary> /// <summary>
@ -28,17 +24,9 @@ public static class WebApplicationExtensions {
/// </list> /// </list>
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="app">The application builder.</param> /// <param name="app"></param>
public static void RegisterSubscriber(this WebApplication app) { public static void RegisterSubscriber(this WebApplication app) {
app.UseCloudEvents(); app.UseCloudEvents();
app.MapSubscribeHandler(); 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,18 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- NuGet package metadata --> <!-- NuGet package metadata -->
<PackageId>MaksIT.Dapr</PackageId> <PackageId>MaksIT.Dapr</PackageId>
<Version>2.2.2</Version> <Version>2.1.0</Version>
<Authors>Maksym Sadovnychyy</Authors> <Authors>Maksym Sadovnychyy</Authors>
<Company>MAKS-IT</Company> <Company>MAKS-IT</Company>
<Product>MaksIT.Dapr</Product> <Product>MaksIT.Dapr</Product>
<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> <Description>MaksIT.Dapr is a facade library for Dapr.</Description>
<PackageTags>dotnet;dapr;</PackageTags> <PackageTags>dotnet;dapr;</PackageTags>
<RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-core-dapr</RepositoryUrl> <RepositoryUrl>https://github.com/MAKS-IT-COM/maksit-core-dapr</RepositoryUrl>
<License>MIT</License> <License>MIT</License>
@ -22,17 +21,21 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapr.Actors.AspNetCore" Version="1.18.5" /> <PackageReference Include="Dapr.Actors.AspNetCore" Version="1.18.4" />
<PackageReference Include="Dapr.AspNetCore" Version="1.18.5" /> <PackageReference Include="Dapr.AspNetCore" Version="1.18.4" />
<PackageReference Include="Dapr.Workflow" Version="1.18.5" /> <PackageReference Include="Dapr.Workflow" Version="1.18.4" />
<PackageReference Include="MaksIT.Core" Version="1.6.9" /> <PackageReference Include="MaksIT.Core" Version="1.6.8" />
<PackageReference Include="MaksIT.Results" Version="2.0.4" /> <PackageReference Include="MaksIT.Results" Version="2.0.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" /> <None Include="..\..\LICENSE.md" Pack="true" PackagePath="\" Link="LICENSE.md" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" /> <None Include="..\..\README.md" Pack="true" PackagePath="\" Link="README.md" />
<None Include="..\..\CHANGELOG.md" Pack="true" PackagePath="\" Link="CHANGELOG.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> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,45 @@
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

@ -1,203 +0,0 @@
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

@ -1,88 +0,0 @@
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

@ -1,107 +0,0 @@
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

@ -1,96 +0,0 @@
#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

@ -1,155 +0,0 @@
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

@ -1,97 +0,0 @@
#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

@ -1,145 +0,0 @@
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

@ -0,0 +1,37 @@
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

@ -1,13 +1,7 @@
namespace MaksIT.Dapr.Services.WorkLease; namespace MaksIT.Dapr.Services;
/// <summary>Stable id for this process/pod (lease holder).</summary>
/// <summary>
/// Stable id for this process/pod (lease holder).
/// </summary>
public interface IDaprRuntimeInstanceId { public interface IDaprRuntimeInstanceId {
/// <summary>
/// Identifier used as lease <c>HolderId</c>.
/// </summary>
string InstanceId { get; } string InstanceId { get; }
} }
@ -15,7 +9,6 @@ public interface IDaprRuntimeInstanceId {
/// Prefers <c>POD_NAME</c> in Kubernetes; otherwise host name + process id. /// Prefers <c>POD_NAME</c> in Kubernetes; otherwise host name + process id.
/// </summary> /// </summary>
public sealed class DaprRuntimeInstanceIdProvider : IDaprRuntimeInstanceId { public sealed class DaprRuntimeInstanceIdProvider : IDaprRuntimeInstanceId {
/// <inheritdoc />
public string InstanceId { get; } = Build(); public string InstanceId { get; } = Build();
private static string Build() { private static string Build() {

View File

@ -1,82 +0,0 @@
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

@ -1,122 +0,0 @@
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,416 +1,93 @@
using Microsoft.Extensions.Logging; using Dapr.Client;
using Grpc.Core;
using Dapr.Client;
using MaksIT.Results;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.Results;
using Microsoft.Extensions.Logging;
namespace MaksIT.Dapr.Services; namespace MaksIT.Dapr.Services;
/// <summary>
/// Dapr state store operations with <see cref="Result"/> outcomes.
/// </summary>
public interface IDaprStateStoreService { public interface IDaprStateStoreService {
/// <summary> Task<Result> SetStateAsync<T>(string storeName, string key, T value);
/// Saves <paramref name="value"/> under <paramref name="key"/> in <paramref name="storeName"/>. Task<Result<T?>> GetStateAsync<T>(string storeName, string key);
/// </summary> Task<Result<(T? Value, string? ETag)>> GetStateAndETagAsync<T>(string storeName, string key, CancellationToken cancellationToken = default);
Task<Result> SetStateAsync<T>( Task<Result<bool>> TrySaveStateAsync<T>(string storeName, string key, T value, string? etag, CancellationToken cancellationToken = default);
string storeName, Task<Result> DeleteStateAsync(string storeName, string key);
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 { public class DaprStateStoreService : IDaprStateStoreService {
private const string ErrorMessage = "MaksIT.Dapr - State store error"; private const string ErrorMessage = "MaksIT.Dapr - Data provider error";
private readonly DaprClient _client; private readonly DaprClient _client;
private readonly ILogger<DaprStateStoreService> _logger; private readonly ILogger<DaprStateStoreService> _logger;
/// <summary>
/// Creates a state store service backed by <paramref name="client"/>.
/// </summary>
public DaprStateStoreService(ILogger<DaprStateStoreService> logger, DaprClient client) { public DaprStateStoreService(ILogger<DaprStateStoreService> logger, DaprClient client) {
_logger = logger; _logger = logger;
_client = client; _client = client;
} }
/// <inheritdoc /> public async Task<Result> SetStateAsync<T>(string storeName, string key, T value) {
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 { try {
await _client.SaveStateAsync(storeName, key, value, options, metadata, cancellationToken); await _client.SaveStateAsync(storeName, key, value);
return Result.Ok(); return Result.Ok();
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
_logger.LogError(ex, ErrorMessage); _logger.LogError(ex, ErrorMessage);
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]); return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]);
} }
} }
/// <inheritdoc /> public async Task<Result<T?>> GetStateAsync<T>(string storeName, string key) {
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 { try {
var state = await _client.GetStateAsync<T?>(storeName, key, consistencyMode, metadata, cancellationToken); 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.");
return Result<T?>.Ok(state); return Result<T?>.Ok(state);
} }
catch (Exception ex) when (IsStateKeyNotFound(ex)) {
return Result<T?>.Ok(default);
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
_logger.LogError(ex, ErrorMessage); _logger.LogError(ex, ErrorMessage);
return Result<T?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]); return Result<T?>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
} }
} }
/// <inheritdoc />
public async Task<Result<(T? Value, string? ETag)>> GetStateAndETagAsync<T>( public async Task<Result<(T? Value, string? ETag)>> GetStateAndETagAsync<T>(
string storeName, string storeName,
string key, string key,
ConsistencyMode? consistencyMode = null,
IReadOnlyDictionary<string, string>? metadata = null,
CancellationToken cancellationToken = default) { CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
return Result<(T? Value, string? ETag)>.BadRequest(default, "storeName and key are required.");
try { try {
var (value, etag) = await _client.GetStateAndETagAsync<T?>(storeName, key, consistencyMode, metadata, cancellationToken); var (value, etag) = await _client.GetStateAndETagAsync<T?>(storeName, key, cancellationToken: cancellationToken);
return Result<(T? Value, string? ETag)>.Ok((value, etag)); 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) { catch (Exception ex) {
_logger.LogError(ex, ErrorMessage); _logger.LogError(ex, ErrorMessage);
return Result<(T? Value, string? ETag)>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]); return Result<(T? Value, string? ETag)>.InternalServerError(default, [ErrorMessage, .. ex.ExtractMessages()]);
} }
} }
/// <inheritdoc />
public async Task<Result<bool>> TrySaveStateAsync<T>( public async Task<Result<bool>> TrySaveStateAsync<T>(
string storeName, string storeName,
string key, string key,
T value, T value,
string? etag, string? etag,
StateOptions? options = null,
IReadOnlyDictionary<string, string>? metadata = null,
CancellationToken cancellationToken = default) { CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(key))
return Result<bool>.BadRequest(false, "storeName and key are required.");
try { try {
var saved = await _client.TrySaveStateAsync(storeName, key, value, etag ?? string.Empty, options, metadata, cancellationToken); var saved = await _client.TrySaveStateAsync(storeName, key, value, etag ?? string.Empty, cancellationToken: cancellationToken);
return Result<bool>.Ok(saved); return Result<bool>.Ok(saved);
} }
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
_logger.LogError(ex, ErrorMessage); _logger.LogError(ex, ErrorMessage);
return Result<bool>.InternalServerError(false, [ErrorMessage, .. ex.ExtractMessages()]); return Result<bool>.InternalServerError(false, [ErrorMessage, .. ex.ExtractMessages()]);
} }
} }
/// <inheritdoc /> public async Task<Result> DeleteStateAsync(string storeName, string key) {
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 { try {
await _client.DeleteStateAsync(storeName, key, options, metadata, cancellationToken); await _client.DeleteStateAsync(storeName, key);
return Result.Ok(); return Result.Ok();
} }
catch (Exception ex) when (IsStateKeyNotFound(ex)) {
return Result.Ok();
}
catch (OperationCanceledException) {
throw;
}
catch (Exception ex) { catch (Exception ex) {
_logger.LogError(ex, ErrorMessage); _logger.LogError(ex, ErrorMessage);
return Result.InternalServerError([ErrorMessage, .. ex.ExtractMessages()]); 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

@ -1,53 +1,29 @@
using MaksIT.Results; using MaksIT.Results;
namespace MaksIT.Dapr.Services.WorkLease; namespace MaksIT.Dapr.Services;
public sealed record DaprWorkLease(
string HolderId,
DateTimeOffset AcquiredAtUtc,
DateTimeOffset ExpiresAtUtc
);
/// <summary> /// <summary>
/// HA work coordination via Dapr state (broker-agnostic). /// HA work coordination via Dapr state store (broker-agnostic).
/// Keys are product-defined; store name comes from the Dapr Component or <see cref="IDaprWorkLeaseOptions"/>. /// Keys are product-defined; store name comes from the Dapr Component.
/// </summary> /// </summary>
public interface IDaprWorkLeaseService { public interface IDaprWorkLeaseStore {
/// <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); 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); 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); 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); 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);
} }
/// <summary> public sealed class DaprWorkLeaseStore(
/// Default <see cref="IDaprWorkLeaseService"/> using ETag concurrency on <see cref="IDaprStateStoreService"/>.
/// </summary>
public sealed class DaprWorkLeaseService(
IDaprStateStoreService stateStore IDaprStateStoreService stateStore
) : IDaprWorkLeaseService { ) : IDaprWorkLeaseStore {
/// <inheritdoc />
public async Task<Result<bool>> TryAcquireAsync( public async Task<Result<bool>> TryAcquireAsync(
string storeName, string storeName,
string workKey, string workKey,
@ -58,12 +34,9 @@ public sealed class DaprWorkLeaseService(
if (!validation.IsSuccess) if (!validation.IsSuccess)
return validation.ToResultOfType<bool>(false); return validation.ToResultOfType<bool>(false);
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>( var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
storeName,
workKey,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!existing.IsSuccess) if (!existing.IsSuccess)
return existing.ToResultOfType<bool>(false); return existing.ToResult().ToResultOfType<bool>(false);
var (lease, etag) = existing.Value; var (lease, etag) = existing.Value;
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
@ -71,26 +44,15 @@ public sealed class DaprWorkLeaseService(
if (lease is not null && lease.ExpiresAtUtc > now && !string.Equals(lease.HolderId, holderId, StringComparison.Ordinal)) if (lease is not null && lease.ExpiresAtUtc > now && !string.Equals(lease.HolderId, holderId, StringComparison.Ordinal))
return Result<bool>.Ok(false); return Result<bool>.Ok(false);
var generation = lease is null var next = new DaprWorkLease(holderId, now, now.Add(ttl));
? 1L // First write: etag may be null/empty when key missing.
: string.Equals(lease.HolderId, holderId, StringComparison.Ordinal) var saved = await stateStore.TrySaveStateAsync(storeName, workKey, next, etag, cancellationToken).ConfigureAwait(false);
? 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) if (!saved.IsSuccess)
return saved; return saved;
return Result<bool>.Ok(saved.Value); return Result<bool>.Ok(saved.Value);
} }
/// <inheritdoc />
public async Task<Result<bool>> TryRenewAsync( public async Task<Result<bool>> TryRenewAsync(
string storeName, string storeName,
string workKey, string workKey,
@ -101,12 +63,9 @@ public sealed class DaprWorkLeaseService(
if (!validation.IsSuccess) if (!validation.IsSuccess)
return validation.ToResultOfType<bool>(false); return validation.ToResultOfType<bool>(false);
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>( var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
storeName,
workKey,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!existing.IsSuccess) if (!existing.IsSuccess)
return existing.ToResultOfType<bool>(false); return existing.ToResult().ToResultOfType<bool>(false);
var (lease, etag) = existing.Value; var (lease, etag) = existing.Value;
if (lease is null || !string.Equals(lease.HolderId, holderId, StringComparison.Ordinal)) if (lease is null || !string.Equals(lease.HolderId, holderId, StringComparison.Ordinal))
@ -114,19 +73,13 @@ public sealed class DaprWorkLeaseService(
var now = DateTimeOffset.UtcNow; var now = DateTimeOffset.UtcNow;
var next = lease with { ExpiresAtUtc = now.Add(ttl) }; var next = lease with { ExpiresAtUtc = now.Add(ttl) };
var saved = await stateStore.TrySaveStateAsync( var saved = await stateStore.TrySaveStateAsync(storeName, workKey, next, etag, cancellationToken).ConfigureAwait(false);
storeName,
workKey,
next,
etag,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!saved.IsSuccess) if (!saved.IsSuccess)
return saved; return saved;
return Result<bool>.Ok(saved.Value); return Result<bool>.Ok(saved.Value);
} }
/// <inheritdoc />
public async Task<Result> ReleaseAsync( public async Task<Result> ReleaseAsync(
string storeName, string storeName,
string workKey, string workKey,
@ -135,10 +88,7 @@ public sealed class DaprWorkLeaseService(
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey) || string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey) || string.IsNullOrWhiteSpace(holderId))
return Result.BadRequest("storeName, workKey, and holderId are required."); return Result.BadRequest("storeName, workKey, and holderId are required.");
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>( var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
storeName,
workKey,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!existing.IsSuccess) if (!existing.IsSuccess)
return existing.ToResult(); return existing.ToResult();
@ -149,49 +99,23 @@ public sealed class DaprWorkLeaseService(
if (!string.Equals(lease.HolderId, holderId, StringComparison.Ordinal)) if (!string.Equals(lease.HolderId, holderId, StringComparison.Ordinal))
return Result.Conflict("Lease is held by another instance."); return Result.Conflict("Lease is held by another instance.");
return await stateStore.DeleteStateAsync(storeName, workKey, cancellationToken: cancellationToken).ConfigureAwait(false); return await stateStore.DeleteStateAsync(storeName, workKey).ConfigureAwait(false);
} }
/// <inheritdoc />
public async Task<Result<DaprWorkLease?>> GetAsync( public async Task<Result<DaprWorkLease?>> GetAsync(
string storeName, string storeName,
string workKey, string workKey,
CancellationToken cancellationToken = default) { CancellationToken cancellationToken = default) {
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey)) if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey))
return Result<DaprWorkLease?>.BadRequest(default, "storeName and workKey are required."); return Result<DaprWorkLease?>.BadRequest(null, "storeName and workKey are required.");
var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>( var existing = await stateStore.GetStateAndETagAsync<DaprWorkLease>(storeName, workKey, cancellationToken).ConfigureAwait(false);
storeName,
workKey,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (!existing.IsSuccess) if (!existing.IsSuccess)
return existing.ToResultOfType((DaprWorkLease?)null); return existing.ToResult().ToResultOfType<DaprWorkLease?>(null);
return Result<DaprWorkLease?>.Ok(existing.Value.Value); 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) { private static Result Validate(string storeName, string workKey, string holderId, TimeSpan ttl) {
if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey) || string.IsNullOrWhiteSpace(holderId)) if (string.IsNullOrWhiteSpace(storeName) || string.IsNullOrWhiteSpace(workKey) || string.IsNullOrWhiteSpace(holderId))
return Result.BadRequest("storeName, workKey, and holderId are required."); return Result.BadRequest("storeName, workKey, and holderId are required.");

View File

@ -1,369 +0,0 @@
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,16 +0,0 @@
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

@ -1,234 +0,0 @@
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

@ -1,20 +0,0 @@
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,5 +0,0 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

View File

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

View File

@ -80,9 +80,8 @@ else {
} }
} }
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $sharedPluginSettings -EngineDirectory $PSScriptRoot -ContinueOnError:$false
# Exact $true only: polluted arrays (CLI stdout + $false) are truthy under -not. if (-not $pluginSucceeded) {
if ($pluginSucceeded -ne $true) {
$releaseHadPluginFailures = $true $releaseHadPluginFailures = $true
break break
} }

View File

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

View File

@ -6,8 +6,6 @@
Plugin-driven test and coverage engine entry script. Plugin-driven test and coverage engine entry script.
#> #>
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path $srcDir = (Resolve-Path (Join-Path $scriptDir '..\..')).Path
@ -31,9 +29,8 @@ if ($configuredPlugins.Count -eq 0) {
$testHadPluginFailures = $false $testHadPluginFailures = $false
foreach ($plugin in $configuredPlugins) { foreach ($plugin in $configuredPlugins) {
$pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir $pluginSucceeded = Invoke-ConfiguredPlugin -Plugin $plugin -SharedSettings $engineContext -EngineDirectory $scriptDir -ContinueOnError:$false
# Exact $true only: polluted arrays (CLI stdout + $false) are truthy under -not. if (-not $pluginSucceeded) {
if ($pluginSucceeded -ne $true) {
$testHadPluginFailures = $true $testHadPluginFailures = $true
break break
} }

View File

@ -22,7 +22,7 @@ function Test-IsEngineRuntimeModuleName {
[string]$ModuleName [string]$ModuleName
) )
# Host engine runtime under modules/ (and optional modules/Extensions/) — never dual-homed under plugins/. # Engine runtime under modules/ only — never dual-homed under plugins/.
$engineNames = [System.Collections.Generic.HashSet[string]]::new( $engineNames = [System.Collections.Generic.HashSet[string]]::new(
[string[]]@( [string[]]@(
'ChangelogSupport', 'ChangelogSupport',
@ -34,7 +34,11 @@ function Test-IsEngineRuntimeModuleName {
'EngineContext', 'EngineContext',
'PluginSupport', 'PluginSupport',
'ReleaseSupport', 'ReleaseSupport',
'TestSupport' 'TestSupport',
'DeployConfig',
'EngineContextSupport',
'OrchestratorSupport',
'PluginPathSupport'
), ),
[System.StringComparer]::OrdinalIgnoreCase [System.StringComparer]::OrdinalIgnoreCase
) )
@ -42,34 +46,6 @@ function Test-IsEngineRuntimeModuleName {
return $engineNames.Contains($ModuleName) 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 { function Import-PluginDependency {
param( param(
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
@ -90,16 +66,16 @@ function Import-PluginDependency {
$candidatePaths = [System.Collections.Generic.List[string]]::new() $candidatePaths = [System.Collections.Generic.List[string]]::new()
if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) { if (Test-IsEngineRuntimeModuleName -ModuleName $ModuleName) {
# Engine runtime: modules/ only (no plugins/ fallback). Optional Extensions/ for layered hosts. # Engine runtime: modules/ only (no plugins/ fallback).
$candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path $modulesDir "$ModuleName.psm1"))
$candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path $engineModuleDir "$ModuleName.psm1"))
$extensionsDir = Join-Path $modulesDir 'Extensions' $extensionsDir = Join-Path $modulesDir 'Extensions'
$candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path $extensionsDir "$ModuleName.psm1"))
} }
else { else {
# Plugin helpers: plugins/{Group}/ only (no modules/ legacy shadow). Groups are discovered. # Plugin helpers: plugins/ only (no modules/ legacy shadow).
foreach ($groupDir in Get-PluginDependencyGroupDirectories -PluginsRoot $pluginsRoot) { foreach ($group in @('Shared', 'Platform', 'DotNet', 'Npm', 'Helm', 'Docker', 'Podman')) {
$candidatePaths.Add((Join-Path $groupDir "$ModuleName.psm1")) $candidatePaths.Add((Join-Path (Join-Path $pluginsRoot $group) "$ModuleName.psm1"))
} }
} }
@ -368,8 +344,8 @@ function Get-RegistryCredentialsFromRuntime {
.DESCRIPTION .DESCRIPTION
Looks up the environment variable named by SecretName. The value must be Looks up the environment variable named by SecretName. The value must be
Base64(UTF8('username:password')). Used by registry login and image-pull Base64(UTF8('username:password')). Used by Docker/Podman/Helm registry login
secret creation never pass the password itself as a parameter. and image-pull secret creation never pass the password itself as a parameter.
.PARAMETER SecretName .PARAMETER SecretName
Logical secret name (environment variable name), not a password or token. Logical secret name (environment variable name), not a password or token.
@ -695,7 +671,10 @@ function Invoke-ConfiguredPlugin {
[psobject]$SharedSettings, [psobject]$SharedSettings,
[Parameter(Mandatory = $true)] [Parameter(Mandatory = $true)]
[string]$EngineDirectory [string]$EngineDirectory,
[Parameter(Mandatory = $false)]
[bool]$ContinueOnError = $false
) )
if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) { if (-not (Test-PluginRunnable -Plugin $Plugin -SharedSettings $SharedSettings -EngineDirectory $EngineDirectory -WriteLogs:$true)) {
@ -737,16 +716,12 @@ function Invoke-ConfiguredPlugin {
$pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory $pluginModulePath = Resolve-PluginModulePath -Plugin $Plugin -EngineDirectory $EngineDirectory
Write-Log -Level "STEP" -Message "Running plugin '$($Plugin.name)'..." 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 { try {
$moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop $moduleInfo = Import-Module $pluginModulePath -Force -PassThru -ErrorAction Stop
$invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop $invokeCommand = Get-Command -Name "Invoke-Plugin" -Module $moduleInfo.Name -ErrorAction Stop
$pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings $pluginSettings = New-PluginInvocationSettings -Plugin $Plugin -SharedSettings $SharedSettings
& $invokeCommand -Settings $pluginSettings | ForEach-Object { Write-Host $_ } & $invokeCommand -Settings $pluginSettings
Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed." Write-Log -Level "OK" -Message " Plugin '$($Plugin.name)' completed."
return $true return $true
} }

View File

@ -1,16 +1,6 @@
#requires -Version 7.0 #requires -Version 7.0
#requires -PSEdition Core #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:ExternalCommandTestHandler = $null
$script:ExternalCommandAvailability = @{} $script:ExternalCommandAvailability = @{}
@ -50,10 +40,7 @@ function Invoke-ExternalCommand {
[string]$InputObject, [string]$InputObject,
[switch]$MergeErrorOutput, [switch]$MergeErrorOutput
# Default true: fail fast. Soft callers (tests, nested loggers) pass $false.
[bool]$ThrowOnError = $true
) )
$previousLocation = $null $previousLocation = $null
@ -64,7 +51,6 @@ function Invoke-ExternalCommand {
try { try {
$effectiveWorkingDirectory = (Get-Location).Path $effectiveWorkingDirectory = (Get-Location).Path
$output = @()
if ($null -ne $script:ExternalCommandTestHandler) { if ($null -ne $script:ExternalCommandTestHandler) {
$handlerResult = & $script:ExternalCommandTestHandler ` $handlerResult = & $script:ExternalCommandTestHandler `
@ -76,46 +62,31 @@ function Invoke-ExternalCommand {
$global:LASTEXITCODE = [int]$handlerResult.ExitCode $global:LASTEXITCODE = [int]$handlerResult.ExitCode
if ($null -eq $handlerResult.Output) { if ($null -eq $handlerResult.Output) {
$output = @() return @()
} }
elseif ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) {
$output = @($handlerResult.Output) if ($handlerResult.Output -is [System.Collections.IEnumerable] -and -not ($handlerResult.Output -is [string])) {
} return @($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 { else {
if ($script:ExternalCommandAvailability.ContainsKey($Name) -and -not $script:ExternalCommandAvailability[$Name]) { $output = & $Name @ArgumentList
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)
} }
$exitCode = [int]$global:LASTEXITCODE return @($output)
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 { finally {
if ($null -ne $previousLocation) { if ($null -ne $previousLocation) {

View File

@ -53,18 +53,21 @@ function Invoke-GitInternal {
if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) { if (-not (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path $PSScriptRoot -Parent $srcDir = Split-Path $PSScriptRoot -Parent
$externalModule = Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1' $externalCandidates = @(
if (Test-Path -LiteralPath $externalModule -PathType Leaf) { (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'),
Import-Module $externalModule -Global (Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1')
} )
elseif (Test-Path -LiteralPath (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -PathType Leaf) { foreach ($externalModule in $externalCandidates) {
Import-Module (Join-Path $srcDir 'modules' 'ExternalCommandSupport.psm1') -Global if (Test-Path -LiteralPath $externalModule -PathType Leaf) {
Import-Module $externalModule -Global
break
}
} }
} }
if ($CaptureOutput) { if ($CaptureOutput) {
if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) {
$output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput -ThrowOnError:$false $output = Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput
$exitCode = $LASTEXITCODE $exitCode = $LASTEXITCODE
if ($exitCode -ne 0) { if ($exitCode -ne 0) {
Write-Error "$ErrorMessage (exit code: $exitCode)" Write-Error "$ErrorMessage (exit code: $exitCode)"
@ -93,7 +96,7 @@ function Invoke-GitInternal {
} }
if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) { if (Get-Command Invoke-ExternalCommand -ErrorAction SilentlyContinue) {
Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput -ThrowOnError:$false | Out-Null Invoke-ExternalCommand -Name git -ArgumentList $Arguments -MergeErrorOutput | Out-Null
} }
else { else {
& git @Arguments & git @Arguments

View File

@ -7,7 +7,7 @@
.DESCRIPTION .DESCRIPTION
Provides the Invoke-TestsWithCoverage function for running .NET tests Provides the Invoke-TestsWithCoverage function for running .NET tests
with Microsoft.Testing.Platform and coverlet.MTP code coverage. with Coverlet code coverage collection and parsing results.
.NOTES .NOTES
Author: MaksIT Author: MaksIT
@ -19,8 +19,10 @@ function Import-ExternalCommandSupportInternal {
return return
} }
$srcDir = Split-Path $PSScriptRoot -Parent
$candidates = @( $candidates = @(
(Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1') (Join-Path $PSScriptRoot 'ExternalCommandSupport.psm1'),
(Join-Path $srcDir 'plugins' 'Shared' 'ExternalCommandSupport.psm1')
) )
foreach ($modulePath in $candidates) { foreach ($modulePath in $candidates) {
if (Test-Path -LiteralPath $modulePath -PathType Leaf) { if (Test-Path -LiteralPath $modulePath -PathType Leaf) {
@ -65,20 +67,6 @@ function Write-TestRunnerLogInternal {
Write-Host $Message -ForegroundColor Gray Write-Host $Message -ForegroundColor Gray
} }
function Get-CoberturaCoverageFiles {
param(
[Parameter(Mandatory = $true)]
[string]$ResultsDirectory
)
# coverlet.MTP: [prefix.]coverage.cobertura[.timestamp].xml
$files = @(
Get-ChildItem -Path $ResultsDirectory -Recurse -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like '*coverage.cobertura*.xml' }
)
return @($files | Sort-Object FullName -Unique)
}
function Invoke-TestsWithCoverage { function Invoke-TestsWithCoverage {
<# <#
.SYNOPSIS .SYNOPSIS
@ -169,7 +157,7 @@ function Invoke-TestsWithCoverage {
New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null New-Item -ItemType Directory -Path $ResultsDir -Force | Out-Null
if (-not $Silent) { if (-not $Silent) {
Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage (Microsoft.Testing.Platform / coverlet.MTP)..." Write-TestRunnerLogInternal -Level "STEP" -Message "Running tests with code coverage..."
foreach ($d in $resolvedProjectDirs) { foreach ($d in $resolvedProjectDirs) {
Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d" Write-TestRunnerLogInternal -Level "INFO" -Message "Test Project: $d"
} }
@ -178,23 +166,19 @@ function Invoke-TestsWithCoverage {
foreach ($TestProjectDir in $resolvedProjectDirs) { foreach ($TestProjectDir in $resolvedProjectDirs) {
Push-Location $TestProjectDir Push-Location $TestProjectDir
try { try {
$projectName = [System.IO.Path]::GetFileName($TestProjectDir)
$dotnetArgs = @( $dotnetArgs = @(
"test" "test"
"--collect:XPlat Code Coverage"
"--results-directory", $ResultsDir "--results-directory", $ResultsDir
"--verbosity", $(if ($Silent) { "quiet" } else { "normal" }) "--verbosity", $(if ($Silent) { "quiet" } else { "normal" })
"--coverlet"
"--coverlet-output-format", "cobertura"
"--coverlet-file-prefix", $projectName
"--coverlet-include", "[MaksIT.*]*"
) )
Import-ExternalCommandSupportInternal Import-ExternalCommandSupportInternal
if ($Silent) { if ($Silent) {
$null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput -ThrowOnError:$false $null = Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -MergeErrorOutput
} }
else { else {
Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs -ThrowOnError:$false | Out-Default Invoke-ExternalCommand -Name dotnet -ArgumentList $dotnetArgs | Out-Default
} }
$testExitCode = $LASTEXITCODE $testExitCode = $LASTEXITCODE
@ -210,7 +194,7 @@ function Invoke-TestsWithCoverage {
} }
} }
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDir) $coverageFiles = @(Get-ChildItem -Path $ResultsDir -Filter "coverage.cobertura.xml" -Recurse | Sort-Object FullName)
if ($coverageFiles.Count -eq 0) { if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{ return [PSCustomObject]@{
@ -372,10 +356,10 @@ function Invoke-NpmJestTestsWithCoverage {
$npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text') $npmArgs = @('run', $TestScript, '--', '--coverage', '--coverageReporters=json-summary', '--coverageReporters=text')
Import-ExternalCommandSupportInternal Import-ExternalCommandSupportInternal
if ($Silent) { if ($Silent) {
$null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput -ThrowOnError:$false $null = Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -MergeErrorOutput
} }
else { else {
Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs -ThrowOnError:$false | Out-Default Invoke-ExternalCommand -Name npm -ArgumentList $npmArgs | Out-Default
} }
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
@ -473,7 +457,7 @@ function Get-DotNetCoverageFromResultsDirectory {
[switch]$Silent [switch]$Silent
) )
$coverageFiles = @(Get-CoberturaCoverageFiles -ResultsDirectory $ResultsDirectory) $coverageFiles = @(Get-ChildItem -Path $ResultsDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName)
if ($coverageFiles.Count -eq 0) { if ($coverageFiles.Count -eq 0) {
return [PSCustomObject]@{ return [PSCustomObject]@{
Success = $false Success = $false
@ -584,7 +568,7 @@ function Get-CoverageFromResultsDirectory {
} }
} }
$hasDotNet = @(Get-CoberturaCoverageFiles -ResultsDirectory $resolvedDirectory).Count -gt 0 $hasDotNet = @(Get-ChildItem -Path $resolvedDirectory -Filter 'coverage.cobertura.xml' -Recurse -ErrorAction SilentlyContinue).Count -gt 0
$jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json' $jestSummary = Join-Path $resolvedDirectory 'coverage-summary.json'
$hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf $hasNpm = Test-Path -LiteralPath $jestSummary -PathType Leaf

View File

@ -6,7 +6,7 @@
Pester test plugin for the RepoUtils test engine. Pester test plugin for the RepoUtils test engine.
.DESCRIPTION .DESCRIPTION
Runs the RepoUtils Pester suite and publishes normalized coverage metrics on the Runs the community Pester suite and publishes normalized coverage metrics on the
shared engine context for QualityGate. shared engine context for QualityGate.
#> #>

View File

@ -0,0 +1,439 @@
#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

@ -0,0 +1,19 @@
{
"$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"
]
}
}