Skip to content

feat: spawn_sibling_with() to auto-inherit parent_id from spawning child #87

@deepjoy

Description

@deepjoy

Problem

When a child task needs to spawn peer tasks under the same parent (flat hierarchy), it must manually extract and thread the parent's task ID:

impl TypedExecutor<ScanL1DirTask> for ScanL1DirExecutor {
    async fn execute(
        &self,
        task: ScanL1DirTask,
        ctx: DomainTaskContext<'_, Scanner>,
    ) -> Result<(), TaskError> {
        // ... scan directory, discover subdirectories ...

        // Must manually read orchestrator ID from own record
        let orchestrator_id = ctx.record().parent_id.unwrap();

        for subdir in child_dirs {
            ctx.domain::<Scanner>()
                .submit_with(ScanL1DirTask {
                    bucket: task.bucket.clone(),
                    dir_prefix: subdir,
                    scan_start_ns: task.scan_start_ns,
                    scope: task.scope.clone(),
                })
                .parent(orchestrator_id)   // ← manual threading
                .await?;
        }

        Ok(())
    }
}

This pattern has two issues:

  1. Boilerplate and fragility. Every child executor that spawns peers must remember to read ctx.record().parent_id and pass it via .parent(id). If a developer forgets the .parent(id) call, the new task becomes a root task instead of a sibling — silently breaking the orchestrator's child tracking. finalize() fires too early (doesn't wait for the orphaned task) and the progress reporter misses it. This is a subtle bug with no compile-time or runtime error.

  2. Unwrap on parent_id. The child has to unwrap() the parent_id Option, which panics if the task is somehow submitted without a parent (e.g. during testing or if the orchestrator pattern is refactored). Defensive code adds a TaskError fallback, but the real fix is not requiring manual access at all.

Concrete use case

We're building a BFS filesystem scanner. An orchestrator (ScanL1Task) spawns a root directory task (ScanL1DirTask) via spawn_child_with(). Each directory task scans one directory, then spawns sibling tasks for each subdirectory — all as direct children of the orchestrator (flat hierarchy, not nested). At 238K directories, every directory task repeats this manual parent-threading pattern.

The flat hierarchy is intentional: the orchestrator's finalize() runs when all directories are done (not when just the root dir is done), and fail_fast(false) means individual directory failures don't cancel the scan. Nesting children under each dir task would create a tree where a mid-level failure cascades and finalize semantics change.

Proposal

Add ctx.spawn_sibling_with(task) that auto-inherits parent_id from the spawning task's own record:

// Before (manual):
let orchestrator_id = ctx.record().parent_id.unwrap();
ctx.domain::<Scanner>()
    .submit_with(task)
    .parent(orchestrator_id)
    .await?;

// After:
ctx.spawn_sibling_with(task).await?;

Semantics:

  • Sets parent_id on the new task to self.record().parent_id (same parent as the spawning task)
  • Returns error if the spawning task has no parent_id (instead of silently creating a root task)
  • Returns SubmitOutcome like other submit methods
  • Should support the builder pattern for priority/tags/fail_fast: ctx.spawn_sibling_with(task).priority(Priority::BACKGROUND).await?

This mirrors the existing spawn_child_with() / submit_with().parent() relationship:

Method parent_id on new task
submit_with(task) None (root task)
submit_with(task).parent(id) Explicit ID
ctx.spawn_child_with(task) Current task's ID
ctx.spawn_sibling_with(task) Current task's parent_id (proposed)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions