accordant-async-operations

$npx mdskill add microsoft/accordant/accordant-async-operations

Some operations don't complete when the API call returns. You call an endpoint, it returns "Pending," and background work finishes later. Accordant models this with **step functions** and handles testing with **polling**.

SKILL.md

.github/skills/accordant-async-operationsView on GitHub ↗
---
name: accordant-async-operations
description: How to model background work, step functions, and polling - use this skill when testing async workflows, job queues, or operations that complete in the background
---

# Async Operations in Accordant

Some operations don't complete when the API call returns. You call an endpoint, it returns "Pending," and background work finishes later. Accordant models this with **step functions** and handles testing with **polling**.

## The Pattern

1. API returns immediately (status: Pending)
2. Background work happens asynchronously
3. Later API calls observe the completed state

```
PUT /api/jobs/job123 → { status: "Pending" }
// ... background processing ...
GET /api/jobs/job123 → { status: "Completed" }
```

## Modeling Background Work

Use `.Triggers()` with `AsyncOperation.Create`:

```csharp
[State]
public partial class JobQueueState
{
    public Dictionary<string, JobStatus> Jobs { get; set; } = new();
}

public enum JobStatus { Pending, Completed, Failed }

spec.Operation<string, ApiResult<Job>>("CreateJob", (jobId, state) =>
{
    if (state.Jobs.ContainsKey(jobId))
        return Expect.That<ApiResult<Job>>(r => r.IsConflict).SameState();

    return Expect.That<ApiResult<Job>>(r => r.IsSuccess && r.Data.Status == JobStatus.Pending)
           .ThenState<JobQueueState>(next => next.Jobs[jobId] = JobStatus.Pending)
           .Triggers(AsyncOperation.Create<JobQueueState>(
               isTerminal: s => s.Jobs[jobId] != JobStatus.Pending,
               transitions: new Action<JobQueueState>[] {
                   next => next.Jobs[jobId] = JobStatus.Completed,
                   next => next.Jobs[jobId] = JobStatus.Failed
               }
           ));
});
```

### Key Parameters

- **`isTerminal`**: Predicate returning true when background work is done
- **`transitions`**: Array of possible outcomes (non-determinism — job could complete or fail)

## How State Tracking Works

After `CreateJob` returns, the system could be in **either** state:
- `Jobs["job123"] = Pending` (still processing)
- `Jobs["job123"] = Completed` (already done)
- `Jobs["job123"] = Failed` (already failed)

Accordant tracks **all possibilities** until you observe a response that narrows it down.

### Observation Narrows Down State

```csharp
spec.Operation<string, ApiResult<Job>>("GetJob", (jobId, state) =>
{
    if (!state.Jobs.TryGetValue(jobId, out var status))
        return Expect.That<ApiResult<Job>>(r => r.IsNotFound).SameState();

    return Expect.That<ApiResult<Job>>(r => r.IsSuccess && r.Data.Status == status)
           .SameState();
});
```

When GetJob returns `{ status: "Completed" }`:
- States where status = Pending → Eliminated (doesn't match)
- States where status = Completed → Kept (matches!)
- States where status = Failed → Eliminated (doesn't match)

## Setting Up Polling

For test execution, configure polling to wait for background work:

### Using PollingSetup

```csharp
public class CreateJobOperation : Operation<string, ApiResult<Job>, JobQueueState>
{
    public override PollingSetup Polling => new PollingSetup
    {
        Operation = "GetJob",      // Which operation to poll
        WaitTimeInMs = 100,        // Delay between polls
        MaxRetryCount = 100        // Maximum attempts (liveness check)
    };
    
    public override ExpectedOutcomes Apply(string jobId, JobQueueState state)
    {
        // ... Apply logic with .Triggers() ...
    }
}
```

### How Polling Works

1. Execute `CreateJob` → Returns Pending
2. Wait `WaitTimeInMs`
3. Execute `GetJob` → Check if terminal
4. If not terminal, repeat from step 2
5. If terminal, continue to next operation
6. If `MaxRetryCount` exceeded → Liveness failure

## Liveness Testing

`MaxRetryCount` acts as a liveness check. If background work never completes:

```
Liveness violation: job still Pending after 100 retries
```

This catches "stuck" systems — a job that stays Pending forever is wrong, even if no individual response violates the spec.

## Manual Polling

If writing tests manually instead of using `spec.RunTests()`:

```csharp
var stateProfile = new StateProfile(new JobQueueState());

// Execute CreateJob
var createResponse = await client.CreateJob("job123");
(bool isValid, string message, stateProfile) = 
    spec.Allows(spec.GetOperation("CreateJob"), "job123", createResponse, stateProfile);
Assert.IsTrue(isValid, message);

// Poll until complete
for (int i = 0; i < 100; i++)
{
    await Task.Delay(100);
    var getResponse = await client.GetJob("job123");
    (isValid, message, stateProfile) = 
        spec.Allows(spec.GetOperation("GetJob"), "job123", getResponse, stateProfile);
    Assert.IsTrue(isValid, message);
    
    // Check if all possible states are terminal
    bool allTerminal = stateProfile.StatesAndStepFunctions
        .All(ssf => ((JobQueueState)ssf.State).Jobs["job123"] != JobStatus.Pending);
    
    if (allTerminal) break;
}
```

## Single Transition (Simpler Case)

When only one outcome is possible:

```csharp
.Triggers(AsyncOperation.Create<JobQueueState>(
    isTerminal: s => s.Jobs[jobId] == JobStatus.Completed,
    transition: next => next.Jobs[jobId] = JobStatus.Completed  // Single transition
))
```

## Multiple Possible Outcomes

When background work can end in different states:

```csharp
.Triggers(AsyncOperation.Create<JobQueueState>(
    isTerminal: s => s.Jobs[jobId] != JobStatus.Pending,
    transitions: new Action<JobQueueState>[] {
        next => next.Jobs[jobId] = JobStatus.Completed,   // Success
        next => next.Jobs[jobId] = JobStatus.Failed,      // Failure
        next => next.Jobs[jobId] = JobStatus.Cancelled    // Cancelled
    }
))
```

All three outcomes are valid until observation tells us which actually occurred.

## Complete Example: Job Queue

```csharp
[State]
public partial class JobQueueState
{
    public Dictionary<string, JobInfo> Jobs { get; set; } = new();
}

[State]
public partial class JobInfo
{
    public JobStatus Status { get; set; }
    public string? Result { get; set; }
}

public enum JobStatus { Pending, Running, Completed, Failed }

var spec = new Spec<JobQueueState>();

// Create Job - triggers background processing
spec.Operation<CreateJobRequest, ApiResult<Job>>("CreateJob", (request, state) =>
{
    if (state.Jobs.ContainsKey(request.JobId))
        return Expect.That<ApiResult<Job>>(r => r.IsConflict).SameState();

    return Expect.That<ApiResult<Job>>(r => r.IsSuccess && r.Data.Status == JobStatus.Pending)
           .ThenState<JobQueueState>(next => next.Jobs[request.JobId] = new JobInfo 
           { 
               Status = JobStatus.Pending 
           })
           .Triggers(AsyncOperation.Create<JobQueueState>(
               isTerminal: s => s.Jobs[request.JobId].Status != JobStatus.Pending,
               transitions: new Action<JobQueueState>[] {
                   next => next.Jobs[request.JobId] = new JobInfo 
                   { 
                       Status = JobStatus.Completed, 
                       Result = "Success" 
                   },
                   next => next.Jobs[request.JobId] = new JobInfo 
                   { 
                       Status = JobStatus.Failed, 
                       Result = "Error occurred" 
                   }
               }
           ));
});

// Get Job - observes current state
spec.Operation<string, ApiResult<Job>>("GetJob", (jobId, state) =>
{
    if (!state.Jobs.TryGetValue(jobId, out var job))
        return Expect.That<ApiResult<Job>>(r => r.IsNotFound).SameState();

    return Expect.That<ApiResult<Job>>(
               r => r.IsSuccess && 
                    r.Data.Status == job.Status && 
                    r.Data.Result == job.Result)
           .SameState();
});
```

## Common Patterns

### Polling Until Specific State

```csharp
// Wait for Completed specifically
for (int i = 0; i < maxRetries; i++)
{
    var response = await client.GetJob(jobId);
    if (response.Data.Status == JobStatus.Completed)
        break;
    if (response.Data.Status == JobStatus.Failed)
        throw new Exception($"Job failed: {response.Data.Error}");
    await Task.Delay(pollInterval);
}
```

### Timeout with Validation

```csharp
var timeout = TimeSpan.FromSeconds(30);
var start = DateTime.UtcNow;

while (DateTime.UtcNow - start < timeout)
{
    var response = await client.GetJob(jobId);
    spec.Allows(getJobOp, jobId, response, stateProfile);
    
    if (IsTerminal(response))
        return response;
    
    await Task.Delay(100);
}
throw new TimeoutException("Job did not complete");
```

## Best Practices

1. **Keep isTerminal simple**: Should be a quick check on state
2. **Cover all outcomes**: Include both success and failure transitions
3. **Set reasonable poll intervals**: Not too fast (hammers API), not too slow (slow tests)
4. **Always set MaxRetryCount**: Prevents infinite loops on stuck jobs
5. **Test liveness**: Ensure background work actually completes

## Next Steps

- **Patterns**: Response-dependent state, request derivations
- **Troubleshooting**: Debug async test failures

More from microsoft/accordant

SkillDescription
accordant-concurrencyHow to test for race conditions using concurrent tests and linearizability - use this skill when testing concurrent operations or finding race condition bugs
accordant-cross-languageTesting systems not written in .NET - trace-based conformance testing, JSON test plan export, and executing from any language. Use when the user's system is in Python, Go, Java, Rust, C++, or any non-.NET language, or when they ask about trace validation or exporting test cases.
accordant-operationsHow to define operations with Apply and Execute methods - use this skill when creating spec operations or understanding the Apply/Execute pattern
accordant-overviewWhat Accordant is, when to use model-based testing, and core concepts - use this skill when starting with Accordant or explaining its purpose
accordant-patternsCommon patterns in Accordant - response-dependent state, request derivations, error handling, and HTTP integration
accordant-quickrefQuick reference and cheatsheet for Accordant API - use this skill for quick syntax lookup or code snippets
accordant-stateHow to design state models in Accordant - use this skill when defining what your system tracks between operations
accordant-test-generationHow Accordant generates and runs tests - use this skill when configuring test generation, understanding state graphs, or running tests
accordant-troubleshootingCommon mistakes and debugging tips for Accordant specs - use this skill when tests fail unexpectedly or specs don't behave as expected