<< All versions

Skill v1.0.0

Trusted Publisher100/100
microsoft/accordant/async-operations
──Details
PublishedOctober 1, 2026 at 05:01 AM
Content Hashsha256:bec26fff829be616...
Git SHA
──Files
Files (1 file, 8.9 KB)
SKILL.md8.9 KBactive
SKILL.md · 295 lines · 8.9 KB

version: "1.0.0" 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
All versionsv1.0.1 →