| 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
- API returns immediately (status: Pending)
- Background work happens asynchronously
- 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:
[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
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
public class CreateJobOperation : Operation<string, ApiResult<Job>, JobQueueState>
{
public override PollingSetup Polling => new PollingSetup
{
Operation = "GetJob",
WaitTimeInMs = 100,
MaxRetryCount = 100
};
public override ExpectedOutcomes Apply(string jobId, JobQueueState state)
{
}
}
How Polling Works
- Execute
CreateJob → Returns Pending
- Wait
WaitTimeInMs
- Execute
GetJob → Check if terminal
- If not terminal, repeat from step 2
- If terminal, continue to next operation
- 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():
var stateProfile = new StateProfile(new JobQueueState());
var createResponse = await client.CreateJob("job123");
(bool isValid, string message, stateProfile) =
spec.Allows(spec.GetOperation("CreateJob"), "job123", createResponse, stateProfile);
Assert.IsTrue(isValid, message);
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);
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:
.Triggers(AsyncOperation.Create<JobQueueState>(
isTerminal: s => s.Jobs[jobId] == JobStatus.Completed,
transition: next => next.Jobs[jobId] = JobStatus.Completed
))
Multiple Possible Outcomes
When background work can end in different states:
.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,
next => next.Jobs[jobId] = JobStatus.Cancelled
}
))
All three outcomes are valid until observation tells us which actually occurred.
Complete Example: Job Queue
[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>();
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"
}
}
));
});
spec.Operation<, ApiResult<Job>>(, (jobId, state) =>
{
(!state.Jobs.TryGetValue(jobId, job))
Expect.That<ApiResult<Job>>(r => r.IsNotFound).SameState();
Expect.That<ApiResult<Job>>(
r => r.IsSuccess &&
r.Data.Status == job.Status &&
r.Data.Result == job.Result)
.SameState();
});
Common Patterns
Polling Until Specific State
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
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
- Keep isTerminal simple: Should be a quick check on state
- Cover all outcomes: Include both success and failure transitions
- Set reasonable poll intervals: Not too fast (hammers API), not too slow (slow tests)
- Always set MaxRetryCount: Prevents infinite loops on stuck jobs
- Test liveness: Ensure background work actually completes
Next Steps
- Patterns: Response-dependent state, request derivations
- Troubleshooting: Debug async test failures