.NET: defer checkpoint cleanup until workflow restoration succeeds - #7939
Conversation
FoundryJsonCheckpointStore pruned a checkpoint's ancestry inside RetrieveCheckpointAsync, but a restore can still fail after that read: the stored JSON has yet to be deserialized, matched against the workflow being resumed, and imported. A failed restore therefore deleted the state the caller could otherwise have recovered from, and retrying only eroded the ancestry further. Cleanup now happens once the restore is known to have succeeded. ICheckpointRestorationObserver is a new opt-in interface a store can implement alongside ICheckpointStore<T>; the internal ICheckpointManager forwards to it, and InProcessRunner raises it only after the workflow match, the state imports and the executor notifications have all completed. RetrieveCheckpointAsync becomes a pure read. The notification cannot fail a restore that already succeeded, so exceptions from it are recorded on the active span rather than rethrown. Because a store may have deleted checkpoints even when it then threw, the runner re-reads the checkpoint index afterwards unless the store declined the notification outright, so Checkpoints never lists a checkpoint that is no longer retrievable. The pruning logic itself is unchanged: sibling branches and checkpoints committed after the resumed one are still retained.
There was a problem hiding this comment.
Pull request overview
Defers checkpoint pruning until workflow restoration succeeds, preserving recoverable ancestry after failed restores.
Changes:
- Adds an opt-in restoration observer contract.
- Notifies stores after state restoration and refreshes checkpoint indexes.
- Moves Foundry pruning from retrieval to post-restoration, with expanded tests.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
CheckpointRestorationObserverTests.cs |
Tests restoration notification behavior. |
FoundryJsonCheckpointStoreTests.cs |
Tests deferred pruning and retention. |
InProcessRunner.cs |
Sends post-restore notifications and refreshes indexes. |
CheckpointManager.cs |
Forwards restoration notifications. |
InMemoryCheckpointManager.cs |
Implements a no-op notification. |
ICheckpointRestorationObserver.cs |
Defines the opt-in observer API. |
ICheckpointManager.cs |
Adds the internal notification contract. |
CheckpointManagerImpl.cs |
Dispatches notifications to supporting stores. |
FoundryJsonCheckpointStore.cs |
Moves pruning to successful restoration. |
Suppressed comments (1)
dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointRestorationObserverTests.cs:185
- Two consecutive
<summary>elements document the same method, producing duplicate summary nodes in generated XML documentation. Consolidate them into a single summary.
/// <summary>
/// Builds the workflow the tests checkpoint and resume. The echo executor starts the run, so it is instantiated
/// and therefore takes part in the restore, which is what lets a test fail the restore from inside an executor.
/// </summary>
/// <summary>
💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.
RetrieveIndexAsync returns an IEnumerable a store is free to enumerate lazily, so the post-restore re-read could fail partway through AddRange, after the existing list had already been cleared. The surrounding catch then suppressed the failure while leaving Checkpoints empty or half rebuilt, which is the opposite of the fallback it documents. The sequence is now materialized before the list is touched. Also covers the state-import failure the issue asks for directly, by naming an executor the workflow does not contain in the restored runner state, rather than relying on the executor callback as a proxy. The two duplicated summary elements on BuildWorkflow are merged into one.
|
Pushed 833c73e addressing all three review points, including the suppressed one.
While rechecking I also found and fixed a portability break in the new test: |
…reporting The test depends on the checkpoint still serializing the runner's instantiated executors. If that property is ever renamed the rewrite silently becomes a no-op, so the flag is now asserted rather than the failure being inferred from a downstream assertion, matching how CheckpointVersionToleranceTests tracks its own mutation.
|
This matches the option-A shape discussed on the issue (post-restore notification, prune moves out of the read path) — glad to see it picked up. The separate opt-in interface is the right call over a One thing that stood out while reading: the "observer threw but already deleted some entries" case forcing an index re-read is a subtle one — good that there's a dedicated test for it. |
Three things main changed under this branch, none of which conflicted: Declare ICheckpointRestorationObserver in the Workflows public API baseline for all five target frameworks. microsoft#7935 added the public API analyzers, so a new public type now fails the build with RS0016 unless it is listed. Only the interface needs an entry: the manager members are on internal types or are explicit implementations of an internal interface, and Foundry.Hosting is not a released package. Rewrite the restore-observer tests in xUnit. microsoft#7938 removed FluentAssertions from the tree over licensing, and this file was the last one still importing it, so it no longer compiled. Acquire the Foundry state store inside the prune try block. Reaching for the binding outside it meant a store the binding could not hand over threw past the catch that reports a refused prune, into the runner's blanket suppression of everything this observer throws. That contradicted both the method's own docs and the interface contract that an implementation reports its failures, and it was a silence this branch introduced: the same failure used to surface loudly from RetrieveCheckpointAsync.
|
Pushed Public API baseline. #7935 added the public API analyzers, so FluentAssertions. #7938 removed it from the tree over licensing and rewrote the other tests in this project. A silence this branch introduced. One thing worth a decision rather than my assumption: the new interface is |
Motivation & Context
FoundryJsonCheckpointStore.RetrieveCheckpointAsyncprunes a checkpoint's ancestry as part of reading it. Reading is not the same as resuming: after that call,CheckpointManagerImpl.LookupCheckpointAsyncstill has to deserialize the stored JSON,InProcessRunner.RestoreCheckpointCoreAsyncstill has to check the checkpoint against the workflow being resumed, and the runner still has to import workflow, executor and edge state.If any of those fail, the ancestors are already gone. An incompatible workflow, corrupt checkpoint data, or a failing state import therefore destroys exactly the state the caller could otherwise have fallen back to, and retrying the restore erodes the ancestry a little further each time. Explicitly restoring an older checkpoint has the same effect.
Description & Review Guide
What are the major changes?
Cleanup moves to a point where the restore is known to have succeeded.
ICheckpointRestorationObserveris a new opt-in interface inMicrosoft.Agents.AI.Workflows.Checkpointingthat a store implements alongsideICheckpointStore<TStoreObject>. It is a separate interface rather than a member onICheckpointStore<TStoreObject>because that interface is public and the package multi-targetsnetstandard2.0andnet472, so a default interface method is not available and a required member would break every external implementer. A store that does not implement it is simply never notified.The internal
ICheckpointManagergains a matching member, implemented byCheckpointManagerImpl<T>,InMemoryCheckpointManager, and the publicCheckpointManagerfacade as an explicit interface implementation, so no public surface changes there.InProcessRunner.RestoreCheckpointCoreAsyncraises the notification only after the workflow match, the three state imports and the executorOnCheckpointRestoredAsynccallbacks have all completed.FoundryJsonCheckpointStoreimplements the interface and moves its existingPruneObsoleteCheckpointsAsynccall there;RetrieveCheckpointAsyncbecomes a pure read.Two details worth a look. First, housekeeping must not fail a restore that already succeeded, so exceptions from the notification are recorded on the active span and not rethrown.
ActivityExtensions.CaptureExceptionis deliberately not used, because it also sets the span status toErrorand the restore did not fail. Second, a store that threw may still have deleted checkpoints before throwing, so the runner re-reads the checkpoint index unless the store declined the notification outright. Without that,Checkpointswould keep offering checkpoints that no longer exist.The pruning logic itself is untouched: sibling branches and checkpoints committed after the resumed one are retained exactly as before.
What are the impacts of these changes?
Reading a checkpoint no longer deletes anything, so a failed restore leaves the ancestry recoverable. Release builds of
Microsoft.Agents.AI.WorkflowsandMicrosoft.Agents.AI.Foundry.Hostingare clean across all target frameworks with no warnings and no Package Validation diagnostics.Microsoft.Agents.AI.Workflows.UnitTestsis 776/776 andMicrosoft.Agents.AI.Foundry.Hosting.UnitTestsis 453/453 on net10.0, anddotnet format --verify-no-changesreports no changes.Two behavioral notes for the record.
ICheckpointRestorationObservercarries[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]even though its neighbourICheckpointStore<TStoreObject>does not, on the grounds that it is new API in a released package and can graduate later; that does mean a store author implementing both needs an MAAI001 suppression for one and not the other. And an external caller invokingFoundryJsonCheckpointStore.RetrieveCheckpointAsyncdirectly no longer gets the implicit prune. Every in-repo resume path goes throughInProcessRunner.RestoreCheckpointCoreAsync, so no first-party consumer loses pruning, and pruning was never part of the read contract.What is the test coverage?
The four cases the issue asks for, each asserting the store was not notified and so had no cue to clean up: an incompatible workflow, malformed checkpoint data, a failure raised from an executor during the restore, and restoring a non-latest checkpoint. Alongside those: a success case, a case where the observer throws and the restore still succeeds, and two cases where the observer really does delete the ancestry, covering the index re-read. On the store side,
RetrieveCheckpointAsyncis asserted to delete nothing, including across repeated reads, and the existing pruning tests were moved to the new entry point so their coverage is preserved.I confirmed the tests fail against the old ordering: moving the notification back to before the validation makes 5 of the 8 new runner tests fail, and defaulting the index re-read flag to
falsefails the prune-then-throw test.One gap, stated rather than implied: no test fails from inside the
ImportStateAsynccalls themselves. The executor-callback and malformed-data cases are the nearest proxies.What do you want reviewers to focus on?
Whether a separate opt-in interface is the contract shape you want, and the naming.
OnRestorationCompletedAsyncis deliberately notOnCheckpointRestoredAsync, sinceExecutor.OnCheckpointRestoredAsyncalready exists and runs during the restore where it can still fail it, whereas this one runs after and cannot. Renaming is cheap if you would rather it read differently.Related Issue
Fixes #7796
Contribution Checklist
breaking changelabel (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.