Migration Guide
February 17, 2026 · View on GitHub
This guide helps you migrate between Duroxide versions and handle orchestration versioning.
Orchestration Versioning
Duroxide supports versioning to handle code evolution while maintaining compatibility with running instances.
When to Version
You need to version your orchestration when:
- Adding/removing activities: Changes the execution flow
- Reordering operations: Affects correlation IDs
- Changing conditional logic: Alters execution paths
- Modifying data structures: Input/output format changes
You DON'T need to version when:
- Fixing bugs in activities: Activities are stateless
- Improving activity performance: No behavior change
- Adding logging: Using
ctx.trace_*is replay-safe - Refactoring activity internals: Interface remains the same
Versioning Strategy
// Version 1.0.0
let orchestration_v1 = |ctx: OrchestrationContext, input: String| async move {
let result = ctx.schedule_activity("ProcessV1", input).await?;
Ok(result)
};
// Version 2.0.0 - Added validation step
let orchestration_v2 = |ctx: OrchestrationContext, input: String| async move {
// New validation step
let validated = ctx.schedule_activity("Validate", &input).await?;
let result = ctx.schedule_activity("ProcessV2", validated).await?;
Ok(result)
};
// Register both versions
let orchestrations = OrchestrationRegistry::builder()
.register_versioned("MyOrchestration", "1.0.0", orchestration_v1)
.register_versioned("MyOrchestration", "2.0.0", orchestration_v2)
.with_version_policy(VersionPolicy::Latest) // New instances use latest
.build();
Version Policies
- Latest (default): New instances use the latest registered version
- Exact: Must specify exact version when starting
- Compatible: Use semantic versioning rules
Handling Running Instances
When you deploy a new version:
- Running instances continue with their version: Pinned at start
- New instances use the latest version: Based on policy
- ContinueAsNew can change versions: Explicitly specify
// Migrate running instance to new version via ContinueAsNew
ctx.continue_as_new_versioned("2.0.0", new_input);
Breaking Changes Between Versions
Duroxide 0.1.0 → 0.2.0 (Hypothetical)
API Changes
-
Activity Registration:
// Old (0.1.0) .register("MyActivity", |ctx: ActivityContext, input: String| async move { Ok(result) }) // New (0.2.0) - Explicit error type .register("MyActivity", |ctx: ActivityContext, input: String| async move -> Result<String, ActivityError> { Ok(result) }) -
Orchestration Context:
// Old (0.1.0) ctx.new_guid() // Removed // New (0.2.0) ctx.system_new_guid().await // Async system activity -
Runtime Creation:
// Old (0.1.0) Runtime::start(activities, orchestrations).await // New (0.2.0) - Explicit store Runtime::start_with_store(store, activities, orchestrations).await
Migration Steps
-
Update Dependencies:
[dependencies] duroxide = "0.2" -
Update Activity Signatures:
- Add explicit error types
- Update return types if changed
-
Update Orchestration Code:
- Replace deprecated methods
- Update to new async APIs
-
Test Thoroughly:
- Run existing tests
- Test with production-like data
- Verify determinism
Data Migration
Handling Input/Output Format Changes
When changing data structures:
-
Support both formats temporarily:
#[derive(Serialize, Deserialize)] #[serde(untagged)] enum InputCompat { V1(InputV1), V2(InputV2), } let orchestration = |ctx: OrchestrationContext, input_json: String| async move { let input: InputCompat = serde_json::from_str(&input_json)?; match input { InputCompat::V1(v1) => { // Handle old format let v2 = migrate_v1_to_v2(v1); process_v2(ctx, v2).await } InputCompat::V2(v2) => { // Handle new format process_v2(ctx, v2).await } } }; -
Gradual migration:
- Deploy version supporting both formats
- Migrate data at your pace
- Remove old format support later
Storage Provider Migration
When switching providers:
// 1. Export from old provider
let old_store = InMemoryHistoryStore::new();
let instances = old_store.list_instances().await;
for instance in instances {
let history = old_store.read(&instance).await;
// Save history to new provider
}
// 2. Import to new provider
let new_store = SqliteProvider::new("sqlite:./data.db", None).await?;
for (instance, history) in saved_data {
// Recreate instance in new store
new_store.create_instance(&instance).await?;
new_store.append(&instance, history).await?;
}
// 3. Switch runtime to new provider
let rt = Runtime::start_with_store(Arc::new(new_store), activities, orchestrations).await;
Best Practices for Versioning
-
Semantic Versioning: Use major.minor.patch
- Major: Breaking changes
- Minor: New features, backward compatible
- Patch: Bug fixes
-
Deployment Strategy:
- Deploy new version alongside old
- Monitor both versions
- Gradually migrate instances
- Remove old version when safe
-
Testing Strategy:
#[test] async fn test_version_compatibility() { // Test that v1 instances complete successfully let v1_result = run_with_version("1.0.0", v1_input).await; // Test that v2 instances work with new features let v2_result = run_with_version("2.0.0", v2_input).await; // Test migration path let migrated = migrate_v1_to_v2(v1_result); assert_eq!(migrated, expected); } -
Documentation:
- Document what changed
- Provide migration examples
- List breaking changes clearly
- Include compatibility matrix
Rollback Strategy
If issues arise after deployment:
- Leave running instances: They continue with their pinned version
- Revert new instances: Change version policy or registration
- Monitor and fix: Address issues without affecting running work
// Emergency rollback configuration
let orchestrations = OrchestrationRegistry::builder()
.register_versioned("MyOrchestration", "1.0.0", orchestration_v1)
.register_versioned("MyOrchestration", "2.0.0", orchestration_v2)
.with_version_policy(VersionPolicy::Exact("1.0.0")) // Force v1 for new instances
.build();
Draining Stuck Orchestrations After Upgrade
If after a full upgrade some orchestrations remain pinned to an old duroxide version that no
running node supports, they will sit in the queue indefinitely. To clear them, temporarily
widen supported_replay_versions in RuntimeOptions:
RuntimeOptions {
supported_replay_versions: Some(SemverRange::new(
semver::Version::new(0, 0, 0),
semver::Version::new(99, 0, 0),
)),
max_attempts: 5,
..Default::default()
}
The wide range causes the provider filter to pass for all items. When the provider fetches
an item whose history contains unknown event types (from a newer duroxide version),
deserialization fails at the provider level, returning a permanent error. Each fetch cycle
increments the item's attempt_count. The item remains in the queue but is effectively
drained — it never reaches the runtime's replay engine because the provider cannot
deserialize its history. Compatible items whose history deserializes successfully are
processed normally. Revert to the default after draining.
See Versioning Best Practices for details.
Future Compatibility
To make future migrations easier:
- Use typed inputs/outputs with serde
- Version your APIs from the start
- Keep orchestrations simple - complex logic in activities
- Document assumptions and invariants
- Test with multiple versions in CI/CD
Session Affinity Notes
Sessions are backward-compatible by design:
- Existing
schedule_activitycalls are unaffected (session_id = None) - Old
ActivityScheduledevents withoutsession_iddeserialize withsession_id = Nonevia#[serde(default)] - Provider schema migration: add
session_idcolumn toworker_queue, createsessionstable - No changes required to existing orchestration or activity code
Getting Help
For migration assistance: