Skip to main content

accordant-async-operations

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

Quellinformationen

Repository
microsoft/accordant
Letzte Quellaktivität
8. Juni 2026 um 19:07
Erkannte Sprache von SKILL.md
Englisch
Sterne
68
Forks
9

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
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
Auf GitHub ansehen