This guide helps you migrate between Duroxide versions and handle orchestration versioning.
The sub:: marker is now reserved for runtime-generated sub-orchestration instance ids.
Client::start_orchestration and Client::start_orchestration_versioned reject root
instance ids that:
- start with
sub::, or - contain the
::sub::infix.
Such ids return ClientError::InvalidInput. Ordinary uses of :: in instance ids remain
valid (e.g. tenant-7::order-42); only the sub:: marker is reserved.
This prevents a root instance id from pre-occupying an auto-generated child id. Child
sub-orchestration ids take the form {parent}::sub::{event_id} on the first parent
execution and {parent}::sub::{execution_id}_{event_id} after continue_as_new.
Before upgrading client code, audit your root instance-id scheme for the reserved marker:
# Reject — start with `sub::` or contain `::sub::`
sub::job-1
tenant-7::sub::order-42
# Accept — ordinary `::` is fine
tenant-7::order-42
order-2026-06-09
Rename any root instance ids that use the reserved marker before upgrading.
Duroxide supports versioning to handle code evolution while maintaining compatibility with running instances.
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
// 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();- Latest (default): New instances use the latest registered version
- Exact: Must specify exact version when starting
- Compatible: Use semantic versioning rules
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);-
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
-
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
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
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;-
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
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();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.
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
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
For migration assistance: