Skip to content

Detached start (schedule_orchestration) is dropped when the same turn ends in continue_as_new #78

Open

Description

@waldemort-auto

Summary

When an orchestration starts a detached orchestration with ctx.schedule_orchestration(...) and then calls ctx.continue_as_new(...) in the same turn, the detached orchestration is never started. There is no error or warning. Starting it and then completing in the same turn works.

Observed on main at 36b789d (0.1.32 plus #74), rustc 1.97.1, SQLite provider. Reproduces 4 of 4 runs.

Repro

Save as tests/detached_start_continue_as_new.rs and run cargo test --all-features --test detached_start_continue_as_new:

#![allow(clippy::unwrap_used)]

use duroxide::runtime::registry::ActivityRegistry;
use duroxide::runtime::{self, OrchestrationStatus};
use duroxide::{Client, OrchestrationContext, OrchestrationRegistry};
use std::time::Duration;
mod common;

async fn child_status(client: &Client, instance: &str) -> OrchestrationStatus {
    // Give the child time to be started and to finish.
    for _ in 0..50 {
        let status = client.get_orchestration_status(instance).await.unwrap();
        if matches!(status, OrchestrationStatus::Completed { .. }) {
            return status;
        }
        tokio::time::sleep(Duration::from_millis(100)).await;
    }
    client.get_orchestration_status(instance).await.unwrap()
}

async fn run(parent_name: &str, child_instance: &str) -> OrchestrationStatus {
    let (store, _temp_dir) = common::create_sqlite_store_disk().await;
    let activities = ActivityRegistry::builder().build();

    let child = |_ctx: OrchestrationContext, input: String| async move { Ok(format!("child:{input}")) };

    // Control: detached start, then complete in the same turn.
    let start_then_complete = |ctx: OrchestrationContext, _input: String| async move {
        ctx.schedule_orchestration("Child", "child-complete", "x");
        Ok("done".to_string())
    };

    // Bug: detached start, then continue_as_new in the same turn.
    let start_then_continue = |ctx: OrchestrationContext, input: String| async move {
        if input == "first" {
            ctx.schedule_orchestration("Child", "child-can", "x");
            return ctx.continue_as_new("second").await;
        }
        Ok("done".to_string())
    };

    let orchestrations = OrchestrationRegistry::builder()
        .register("Child", child)
        .register("StartThenComplete", start_then_complete)
        .register("StartThenContinue", start_then_continue)
        .build();

    let rt = runtime::Runtime::start_with_store(store.clone(), activities, orchestrations).await;
    let client = Client::new(store.clone());
    client.start_orchestration("parent", parent_name, "first").await.unwrap();
    match client.wait_for_orchestration("parent", Duration::from_secs(10)).await.unwrap() {
        OrchestrationStatus::Completed { output, .. } => assert_eq!(output, "done"),
        other => panic!("parent did not complete: {other:?}"),
    }
    let status = child_status(&client, child_instance).await;
    rt.shutdown(None).await;
    status
}

#[tokio::test]
async fn detached_start_then_complete_starts_the_child() {
    let status = run("StartThenComplete", "child-complete").await;
    assert!(matches!(status, OrchestrationStatus::Completed { .. }), "child status: {status:?}");
}

#[tokio::test]
async fn detached_start_then_continue_as_new_starts_the_child() {
    let status = run("StartThenContinue", "child-can").await;
    assert!(matches!(status, OrchestrationStatus::Completed { .. }), "child status: {status:?}");
}

Result:

test detached_start_then_complete_starts_the_child ... ok
thread 'detached_start_then_continue_as_new_starts_the_child' panicked at tests/detached_start_continue_as_new.rs:71:5:
child status: NotFound
test result: FAILED. 1 passed; 1 failed

The parent completes normally on its second execution. The child instance never exists.

Cause

In src/runtime/execution.rs, detached starts become WorkItem::StartOrchestration items in three cases:

  • in a turn that continues (TurnResult::Continue, around line 147);
  • through enqueue_detached_from_pending(turn.pending_actions()) when the turn completes (line 231) or fails (line 266).

The helper's comment at line 51 reads "only honor detached starts at terminal". The TurnResult::ContinueAsNew branch (line 300) does not call it: it pushes only WorkItem::ContinueAsNew (line 360). So a detached start queued in that turn is dropped, while the turn still completes.

Suggested fix

Call enqueue_detached_from_pending(turn.pending_actions()) in the ContinueAsNew branch as well, before the ContinueAsNew item is pushed. Add the test above, or a variant of it, to the suite.

Impact

An eternal or long-running orchestration that starts independent work and continues as new in the same turn loses that work silently. A typical case is a monitor that kicks off a job, then rolls its history over.

The Node binding is affected the same way. ctx.startOrchestration(...) returns in the same turn, so a start followed directly by continueAsNew() has no blocking yield in between. The workaround is to ensure an activity or timer is awaited between the start and the continue-as-new.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions