Skip to content

Latest commit

 

History

History
323 lines (245 loc) · 9.73 KB

File metadata and controls

323 lines (245 loc) · 9.73 KB

Migration Guide

This guide helps you migrate between Duroxide versions and handle orchestration versioning.

Reserved sub:: instance-id marker (Unreleased)

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.

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:

  1. Adding/removing activities: Changes the execution flow
  2. Reordering operations: Affects correlation IDs
  3. Changing conditional logic: Alters execution paths
  4. Modifying data structures: Input/output format changes

You DON'T need to version when:

  1. Fixing bugs in activities: Activities are stateless
  2. Improving activity performance: No behavior change
  3. Adding logging: Using ctx.trace_* is replay-safe
  4. 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

  1. Latest (default): New instances use the latest registered version
  2. Exact: Must specify exact version when starting
  3. Compatible: Use semantic versioning rules

Handling Running Instances

When you deploy a new version:

  1. Running instances continue with their version: Pinned at start
  2. New instances use the latest version: Based on policy
  3. 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

  1. 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) 
    })
  2. Orchestration Context:

    // Old (0.1.0)
    ctx.new_guid() // Removed
    
    // New (0.2.0)
    ctx.system_new_guid().await // Async system activity
  3. 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

  1. Update Dependencies:

    [dependencies]
    duroxide = "0.2"
  2. Update Activity Signatures:

    • Add explicit error types
    • Update return types if changed
  3. Update Orchestration Code:

    • Replace deprecated methods
    • Update to new async APIs
  4. Test Thoroughly:

    • Run existing tests
    • Test with production-like data
    • Verify determinism

Data Migration

Handling Input/Output Format Changes

When changing data structures:

  1. 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
            }
        }
    };
  2. 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

  1. Semantic Versioning: Use major.minor.patch

    • Major: Breaking changes
    • Minor: New features, backward compatible
    • Patch: Bug fixes
  2. Deployment Strategy:

    • Deploy new version alongside old
    • Monitor both versions
    • Gradually migrate instances
    • Remove old version when safe
  3. 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);
    }
  4. Documentation:

    • Document what changed
    • Provide migration examples
    • List breaking changes clearly
    • Include compatibility matrix

Rollback Strategy

If issues arise after deployment:

  1. Leave running instances: They continue with their pinned version
  2. Revert new instances: Change version policy or registration
  3. 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:

  1. Use typed inputs/outputs with serde
  2. Version your APIs from the start
  3. Keep orchestrations simple - complex logic in activities
  4. Document assumptions and invariants
  5. Test with multiple versions in CI/CD

Session Affinity Notes

Sessions are backward-compatible by design:

  • Existing schedule_activity calls are unaffected (session_id = None)
  • Old ActivityScheduled events without session_id deserialize with session_id = None via #[serde(default)]
  • Provider schema migration: add session_id column to worker_queue, create sessions table
  • No changes required to existing orchestration or activity code

Getting Help

For migration assistance:

  1. Review the changelog for detailed changes
  2. Check examples for updated patterns
  3. Run tests to verify compatibility
  4. Open an issue for migration problems