Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Latest commit

 

History

History
705 lines (582 loc) · 23.9 KB

File metadata and controls

705 lines (582 loc) · 23.9 KB

Workflow Engine v3: Design Plan

Overview

Transform the workflow engine from an event-handler model to a Temporal-like model where:

  • Workflows are sequential code that can suspend at side effects
  • Reducers are fire-and-forget side effects (no return value, workflow continues)
  • Procedures are side effects that return values (workflow suspends until callback)

Core Constructs

Construct Syntax Suspends? Behavior
Reducer reducer!(fn(args)) No Fire-and-forget, same transaction
Procedure procedure!(fn(args)).await Yes Schedules, suspends, resumes via callback
Timer timer!(Timer::Variant, duration).await Yes Suspends until timer fires
Signal signal!(Signal::Variant(payload)).await Yes Suspends until signal received
Select select! { ... }.await Yes Suspends until first event matches
Spawn spawn!(workflow, init).await Yes Spawns child, suspends until complete
Subscribe subscribe!(Signal::Variant(filter)) No Registers filtered signal subscription

Syntax Examples

Basic Workflow

#[workflow]
fn buff(init: BuffInit) -> WorkflowResult<BuffResult> {
    let mut stacks = 1u32;
    let duration = init.duration_secs.secs();

    loop {
        select! {
            timer!(BuffTimer::Expire, duration) => break,
            signal!(BuffSignal::Refresh) => continue,
            signal!(BuffSignal::Stack(n)) => { stacks += n; continue; },
            signal!(BuffSignal::Dispel) => break,
        }.await;
    }

    Ok(BuffResult { final_stacks: stacks })
}

Parent-Child

#[workflow]
fn child_task(init: ChildInit) -> WorkflowResult<i32> {
    timer!(ChildTimer::Complete, init.delay_secs.secs()).await;
    Ok(init.result_value)
}

#[workflow]
fn parent(task_count: u32) -> WorkflowResult<i32> {
    let mut total = 0;

    for i in 0..task_count {
        let result = spawn!(child_task, ChildInit {
            task_name: format!("task_{}", i),
            delay_secs: 1,
            result_value: 10 * (i as i32 + 1),
        }).await;
        total += result;
    }

    Ok(total)
}

Procedures and Reducers

#[workflow]
fn combat(player_id: u64, enemy_id: u64) -> WorkflowResult<CombatOutcome> {
    let player = procedure!(get_combatant_stats(player_id)).await;
    let enemy = procedure!(get_combatant_stats(enemy_id)).await;

    let mut player_hp = player.health;
    let mut enemy_hp = enemy.health;

    loop {
        select! {
            timer!(CombatTimer::TurnTimeout, 30.secs()) => {
                reducer!(spawn_effect(player_id, "defend"));
            },
            signal!(CombatSignal::Attack) => {
                let calc = procedure!(calculate_damage(player_id, enemy_id)).await;
                reducer!(apply_damage(enemy_id, calc.damage));
                reducer!(spawn_effect(enemy_id, if calc.is_crit { "crit" } else { "hit" }));
                enemy_hp -= calc.damage;
            },
            signal!(CombatSignal::Defend) => {
                reducer!(spawn_effect(player_id, "defend"));
            },
            signal!(CombatSignal::Flee) => {
                return Ok(CombatOutcome::Fled);
            },
        }.await;

        let enemy_calc = procedure!(calculate_damage(enemy_id, player_id)).await;
        reducer!(apply_damage(player_id, enemy_calc.damage));
        player_hp -= enemy_calc.damage;

        timer!(CombatTimer::AnimDelay, 300.millis()).await;

        if enemy_hp <= 0 { return Ok(CombatOutcome::Victory); }
        if player_hp <= 0 { return Ok(CombatOutcome::Defeat); }
    }
}

Filtered Subscriptions

#[workflow]
fn quest(init: QuestInit) -> WorkflowResult<QuestOutcome> {
    let objectives = procedure!(get_quest_objectives(init.quest_id)).await;
    let mut progress: HashMap<ObjectiveId, u32> = HashMap::new();

    // Register only the specific events we care about
    for obj in &objectives {
        match obj {
            Objective::Kill { enemy_type, .. } => {
                subscribe!(QuestSignal::EnemyKilled(enemy_type));
            },
            Objective::Collect { item_id, .. } => {
                subscribe!(QuestSignal::ItemCollected(item_id));
            },
            Objective::Location { location_id, .. } => {
                subscribe!(QuestSignal::LocationReached(location_id));
            },
        }
    }

    reducer!(show_quest_tracker(init.player_id, &objectives));

    loop {
        let event = signal!(QuestSignal).await;

        match event {
            QuestSignal::EnemyKilled(t) => { /* update progress */ },
            QuestSignal::ItemCollected(i) => { /* update progress */ },
            QuestSignal::LocationReached(l) => { /* update progress */ },
            QuestSignal::Abandon => {
                reducer!(hide_quest_tracker(init.player_id));
                return Ok(QuestOutcome::Abandoned);
            },
        }

        if objectives.all_complete(&progress) {
            let rewards = procedure!(get_quest_rewards(init.quest_id)).await;
            reducer!(grant_rewards(init.player_id, rewards));
            reducer!(hide_quest_tracker(init.player_id));
            return Ok(QuestOutcome::Completed { rewards });
        }
    }
}

More Examples

Patrol

#[workflow]
fn patrol(init: PatrolInit) -> WorkflowResult<()> {
    let waypoints = init.waypoints;
    let home = init.home_position;

    timer!(PatrolTimer::StartPatrol, 2.secs()).await;

    loop {
        for waypoint in &waypoints {
            reducer!(move_to(ctx.entity_id, waypoint));

            select! {
                timer!(PatrolTimer::Arrival, waypoint.travel_time()) => {},
                signal!(PatrolSignal::ThreatDetected(enemy_id)) => {
                    spawn!(combat, CombatInit { enemy_id }).await;
                    reducer!(move_to(ctx.entity_id, home));
                    timer!(PatrolTimer::Arrival, home.travel_time()).await;
                    continue;
                },
            }.await;

            timer!(PatrolTimer::Wait, 3.secs()).await;
        }
    }
}

Respawn

#[workflow]
fn respawn(init: RespawnInit) -> WorkflowResult<()> {
    timer!(RespawnTimer::Spawn, init.delay_secs.secs()).await;
    reducer!(spawn_entity(init.entity_template, init.spawn_room_id));
    Ok(())
}

Production

#[workflow]
fn production(init: ProductionInit) -> WorkflowResult<()> {
    let blueprint = init.blueprint;

    loop {
        select! {
            signal!(ProductionSignal::InventoryChange) => {
                if !procedure!(check_inputs(ctx.entity_id, &blueprint)).await {
                    continue;
                }
            },
            signal!(ProductionSignal::Halt(reason)) => {
                signal!(ProductionSignal::Resume).await;
                continue;
            },
            signal!(ProductionSignal::Stop) => {
                return Ok(());
            },
        }.await;

        reducer!(consume_inputs(ctx.entity_id, &blueprint));
        timer!(ProductionTimer::CycleComplete, blueprint.cycle_time).await;
        reducer!(deposit_outputs(ctx.entity_id, &blueprint));
    }
}

NPC Dialog

#[workflow]
fn npc_dialog(init: DialogInit) -> WorkflowResult<DialogOutcome> {
    let mut node = init.start_node;

    loop {
        let dialog = procedure!(get_dialog_node(init.npc_id, node)).await;

        reducer!(show_dialog(init.player_id, dialog.text, dialog.options));

        select! {
            timer!(DialogTimer::Timeout, 60.secs()) => {
                reducer!(close_dialog(init.player_id));
                return Ok(DialogOutcome::Abandoned);
            },
            signal!(DialogSignal::SelectOption(choice)) => {
                match dialog.options[choice].action {
                    DialogAction::Continue(next) => { node = next; },
                    DialogAction::GiveQuest(quest_id) => {
                        reducer!(assign_quest(init.player_id, quest_id));
                        node = dialog.options[choice].next_node;
                    },
                    DialogAction::OpenShop => {
                        reducer!(open_shop_ui(init.player_id, init.npc_id));
                        return Ok(DialogOutcome::OpenedShop);
                    },
                    DialogAction::End => {
                        reducer!(close_dialog(init.player_id));
                        return Ok(DialogOutcome::Completed);
                    },
                }
            },
            signal!(DialogSignal::Cancel) => {
                reducer!(close_dialog(init.player_id));
                return Ok(DialogOutcome::Abandoned);
            },
        }.await;
    }
}

Auction

#[workflow]
fn auction(init: AuctionInit) -> WorkflowResult<AuctionOutcome> {
    let item = init.item;
    let mut current_bid = init.starting_price;
    let mut high_bidder: Option<PlayerId> = None;
    let mut end_time = 60.secs();

    reducer!(announce_auction(item, current_bid));

    loop {
        select! {
            timer!(AuctionTimer::End, end_time) => {
                match high_bidder {
                    Some(winner) => {
                        reducer!(transfer_item(init.seller, winner, item));
                        reducer!(transfer_gold(winner, init.seller, current_bid));
                        return Ok(AuctionOutcome::Sold { winner, price: current_bid });
                    },
                    None => {
                        reducer!(return_item(init.seller, item));
                        return Ok(AuctionOutcome::NoBids);
                    },
                }
            },
            signal!(AuctionSignal::Bid { bidder, amount }) => {
                if amount <= current_bid {
                    reducer!(notify_player(bidder, "Bid too low"));
                    continue;
                }

                if let Some(prev) = high_bidder {
                    reducer!(notify_player(prev, "You've been outbid!"));
                }

                high_bidder = Some(bidder);
                current_bid = amount;
                reducer!(announce_bid(item, bidder, amount));

                if end_time < 10.secs() {
                    end_time = 10.secs();
                }
            },
            signal!(AuctionSignal::Cancel) => {
                if high_bidder.is_none() {
                    reducer!(return_item(init.seller, item));
                    return Ok(AuctionOutcome::Cancelled);
                }
            },
        }.await;
    }
}

Trade

#[workflow]
fn trade(init: TradeInit) -> WorkflowResult<TradeOutcome> {
    let player_a = init.initiator;
    let player_b = init.target;

    reducer!(lock_items(player_a, init.offer_a));
    reducer!(lock_items(player_b, init.offer_b));
    reducer!(show_trade_ui(player_a, player_b, &init.offer_a, &init.offer_b));

    let mut a_accepted = false;
    let mut b_accepted = false;

    loop {
        select! {
            timer!(TradeTimer::Timeout, 60.secs()) => {
                reducer!(unlock_items(player_a, init.offer_a));
                reducer!(unlock_items(player_b, init.offer_b));
                reducer!(close_trade_ui(player_a));
                reducer!(close_trade_ui(player_b));
                return Ok(TradeOutcome::Timeout);
            },
            signal!(TradeSignal::Accept(player)) => {
                if player == player_a { a_accepted = true; }
                if player == player_b { b_accepted = true; }

                if a_accepted && b_accepted {
                    reducer!(transfer_items(player_a, player_b, init.offer_a));
                    reducer!(transfer_items(player_b, player_a, init.offer_b));
                    reducer!(close_trade_ui(player_a));
                    reducer!(close_trade_ui(player_b));
                    return Ok(TradeOutcome::Completed);
                }

                reducer!(show_accepted(player));
            },
            signal!(TradeSignal::Cancel(player)) => {
                reducer!(unlock_items(player_a, init.offer_a));
                reducer!(unlock_items(player_b, init.offer_b));
                reducer!(close_trade_ui(player_a));
                reducer!(close_trade_ui(player_b));
                return Ok(TradeOutcome::Cancelled { by: player });
            },
            signal!(TradeSignal::ModifyOffer(player, new_items)) => {
                a_accepted = false;
                b_accepted = false;

                if player == player_a {
                    reducer!(unlock_items(player_a, init.offer_a));
                    reducer!(lock_items(player_a, new_items));
                }
                reducer!(update_trade_ui(player_a, player_b));
            },
        }.await;
    }
}

Boss Fight

#[workflow]
fn boss_fight(init: BossFightInit) -> WorkflowResult<BossFightOutcome> {
    let boss_id = init.boss_id;
    let raid_id = init.raid_id;

    subscribe!(BossSignal::HealthChanged(boss_id));
    subscribe!(BossSignal::Wipe(raid_id));

    let enrage_deadline = 480.secs();

    // Phase 1: 100% - 70%
    reducer!(announce("Phase 1: The boss awakens!"));
    reducer!(set_boss_phase(boss_id, 1));

    loop {
        select! {
            timer!(BossTimer::Enrage, enrage_deadline) => {
                reducer!(trigger_wipe(raid_id, "Enrage"));
                return Ok(BossFightOutcome::Wipe);
            },
            signal!(BossSignal::HealthChanged(_, hp)) => {
                if hp <= 70 { break; }
            },
            signal!(BossSignal::Wipe(_)) => {
                return Ok(BossFightOutcome::Wipe);
            },
        }.await;
    }

    // Phase 2: Adds
    reducer!(announce("Phase 2: Minions, attack!"));
    reducer!(set_boss_phase(boss_id, 2));
    reducer!(set_boss_immune(boss_id, true));

    spawn!(boss_add, AddInit { add_type: "left" }).await;
    spawn!(boss_add, AddInit { add_type: "right" }).await;

    reducer!(set_boss_immune(boss_id, false));

    // Phase 3: 70% - 30%, periodic fire
    reducer!(announce("Phase 3: Flames erupt!"));
    reducer!(set_boss_phase(boss_id, 3));

    loop {
        select! {
            timer!(BossTimer::Enrage, enrage_deadline) => {
                reducer!(trigger_wipe(raid_id, "Enrage"));
                return Ok(BossFightOutcome::Wipe);
            },
            timer!(BossTimer::FireBreath, 15.secs()) => {
                reducer!(cast_fire_breath(boss_id));
            },
            signal!(BossSignal::HealthChanged(_, hp)) => {
                if hp <= 30 { break; }
            },
            signal!(BossSignal::Wipe(_)) => {
                return Ok(BossFightOutcome::Wipe);
            },
        }.await;
    }

    // Phase 4: Burn
    reducer!(announce("Phase 4: Finish him!"));
    reducer!(set_boss_phase(boss_id, 4));
    reducer!(increase_boss_damage(boss_id, 50));

    loop {
        select! {
            timer!(BossTimer::Enrage, enrage_deadline) => {
                reducer!(trigger_wipe(raid_id, "Enrage"));
                return Ok(BossFightOutcome::Wipe);
            },
            signal!(BossSignal::HealthChanged(_, hp)) => {
                if hp <= 0 { break; }
            },
            signal!(BossSignal::Wipe(_)) => {
                return Ok(BossFightOutcome::Wipe);
            },
        }.await;
    }

    reducer!(announce("Victory!"));
    let loot = procedure!(generate_loot(boss_id)).await;
    reducer!(spawn_loot_chest(boss_id, loot));

    Ok(BossFightOutcome::Victory { loot })
}

Day/Night Cycle

#[workflow]
fn day_night_cycle(init: WorldInit) -> WorkflowResult<()> {
    let day_length = 20.minutes();
    let night_length = 10.minutes();

    loop {
        reducer!(set_world_time(WorldTime::Dawn));
        reducer!(broadcast_event(WorldEvent::Dawn));
        timer!(CycleTimer::Phase, 2.minutes()).await;

        reducer!(set_world_time(WorldTime::Day));
        reducer!(broadcast_event(WorldEvent::Day));
        timer!(CycleTimer::Phase, day_length).await;

        reducer!(set_world_time(WorldTime::Dusk));
        reducer!(broadcast_event(WorldEvent::Dusk));
        timer!(CycleTimer::Phase, 2.minutes()).await;

        reducer!(set_world_time(WorldTime::Night));
        reducer!(broadcast_event(WorldEvent::Night));
        timer!(CycleTimer::Phase, night_length).await;
    }
}

How It Works

Macro Transformation

The #[workflow] macro transforms sequential code into a state machine:

  1. Identify await points - timer!().await, signal!().await, procedure!().await, spawn!().await, select!{}.await
  2. Capture live variables - Variables used across await points become state fields
  3. Generate state enum - One variant per suspension point
  4. Generate WorkflowHandler impl - Dispatch based on current state

Procedure Execution (Async with Callback)

1. Workflow hits `procedure!(calculate_damage(a, b)).await`
2. Macro generates: save state + return WorkflowResult::CallProcedure
3. Reducer transaction commits, workflow status = "Suspended"
4. Runtime sees pending procedure, executes it in own transaction
5. Procedure completes, runtime calls `workflow_procedure_complete` reducer
6. Workflow state restored, execution resumes with result

Reducer Execution (Sync, Fire-and-Forget)

reducer!(grant_xp(player_id, 100));
// Expands to direct function call, same transaction
// Workflow continues immediately

Signal Subscriptions

  • WorkflowSubscription table: workflow_id, signal_type, filter_value
  • subscribe! adds row, signals dispatch only to matching workflows
  • Subscriptions auto-cleared when workflow completes

Testing & Debugging

Debug Reducers

// Generated by install! macro
#[spacetimedb::reducer]
fn debug_fire_timer(workflow_id: u64, timer_name: String) {
    // Immediately triggers on_timer as if timer fired
}

#[spacetimedb::reducer]
fn debug_send_signal(workflow_id: u64, signal_name: String, payload: Vec<u8>) {
    // Bypasses subscription filters, delivers directly
}

Test Mode

// Start workflow in manual mode - timers don't auto-fire
workflow_start("auction", init_data, TestMode::Manual)

Integration Test Example

const wf = await client.workflow_start("auction", { item, price }, { testMode: "manual" });
await client.debug_send_signal(wf.id, "bid", { bidder: alice, amount: 100 });
await client.debug_send_signal(wf.id, "bid", { bidder: bob, amount: 150 });
await client.debug_fire_timer(wf.id, "end");
expect(wf.outcome).toBe({ winner: bob, price: 150 });

Implementation Phases

Phase 1: Core Types ✅ COMPLETE

  • Add WorkflowEvent::ProcedureComplete { name, result } variant
  • Add WorkflowEvent::ChildComplete { child_id, result } variant
  • Add WorkflowResult::Suspend with timers, reducer_calls, subscriptions
  • Add WorkflowResult::CallProcedure with procedure and reducer_calls
  • Add WorkflowResult::SpawnChild with workflow_type, initial_data, reducer_calls
  • Add WorkflowResult::Complete with result and reducer_calls
  • Add WorkflowResult::Fail with reason and reducer_calls
  • Add SignalSubscription type for filtered event routing
  • Add ProcedureCall and ReducerCall types
  • Simplify WorkflowHandler trait (start, handle, workflow_type)

Phase 2: Runtime Infrastructure ✅ COMPLETE

  • Add workflow_procedure_complete reducer
  • Add workflow_broadcast_signal reducer (subscription-based routing)
  • Add workflow_start_test reducer (test mode - timers don't auto-fire)
  • Create PendingProcedure table (generated by install! macro)
  • Create WorkflowSubscription table (generated by install! macro)
  • Add procedure scheduling/execution logic
  • Add signal subscription matching on dispatch
  • Add subscription cleanup on workflow complete
  • Add debug reducers (debug_fire_timer, debug_send_signal)
  • Add test_mode support in Workflow table and handlers

Phase 3: Workflow Macro (Basic) ✅ MOSTLY COMPLETE

  • Parse #[workflow] function signature
  • Identify await points in function body
  • Generate state struct (phase-based, with init data preservation)
  • Generate WorkflowHandler implementation
  • Support timer!().await (basic linear case)
  • Handle implicit return expressions properly
  • Handle code before/after await points
  • Generate event type checking (timer vs signal vs procedure)
  • Support signal!().await (with result binding)
  • Live variable tracking for complex cases

Phase 4: Workflow Macro (Full) - COMPLETE

  • Support reducer!() - fire-and-forget calls
  • Support subscribe!() - signal subscription registration
  • Multiple consecutive await points
  • Support procedure!().await - async procedure with callback
  • Support spawn!().await - child workflow spawning (basic)
  • Support select! { ... }.await - multi-arm event selection (basic)
  • Handle loops with select! where all arms use break/continue
  • Handle loops with dynamic iteration (counter-based for loops)
  • Handle conditionals with await points (if/else with .await inside)
  • Handle early returns (return Ok/Err before end of workflow)
  • Live variable tracking across await points
  • Signal payload binding (extracting data from signals like Stack(n))
  • Procedure/spawn result binding (capturing return value from procedure! and spawn!)
  • Select arm result binding (capturing which arm matched)

Known Limitations (Current Implementation)

Implemented patterns:

  • Counter-based for loops (for i in 0..n { spawn!(...).await }) - Fully supported
  • Infinite loops with select! and break/continue - Fully supported
  • Mutable variable tracking across await points - Fully supported
  • Conditionals with await (if cond { timer!().await } else { signal!().await }) - Fully supported

Not yet supported:

  • Nested if/else with await in inner conditionals
  • match expressions with await
  • Multiple await points in a single conditional branch

Phase 5: Integration - COMPLETE

  • Update install! macro to generate debug reducers
  • Example workflows using #[workflow] macro (buff, countdown, parent, child_task, failing, simple_timer, multi_step, procedure_test, spawn_test, select_test, conditional_test)
  • All example workflows converted to use #[workflow] macro
  • Integration test reducers for macro-generated workflows (including select!, loops)
  • TypeScript integration tests updated for new workflows
  • Regenerate TypeScript bindings with new reducers
  • Full documentation (README updated with macro syntax and examples)

Current Test Coverage

  • 57 unit tests in workflow-core:

    • Handler tests (start, handle, timer cycles, signals, events)
    • Type tests (WorkflowResult variants, TimerRequest, SignalSubscription, etc.)
    • Registry tests
    • Trait tests (Timer, Signal)
    • Property-based tests (serialization roundtrips)
    • Duration extension tests
  • Integration test reducers in workflow-example:

    • test_run_all - Runs all synchronous tests
    • test_buff_dispel - Manual workflow handler tests
    • test_workflow_cancel - Cancellation tests
    • test_query_by_entity - Entity query tests
    • test_macro_workflow - Simple timer macro workflow
    • test_multi_step_workflow - Multiple consecutive awaits
    • test_procedure_workflow - Procedure calling workflow
    • test_spawn_workflow - Child spawning workflow
    • test_debug_fire_timer - Debug reducer tests
    • test_select_workflow - Select macro (timer OR signal)
    • test_select_signal_completes - Select completes via signal arm
    • test_select_timer_completes - Select completes via timer arm
  • TypeScript integration tests (require running SpacetimeDB):

    • All original workflow tests (buff, countdown, parent-child, failing)
    • New macro-generated workflow tests (simple_timer, multi_step, procedure_test, spawn_test, select_test)

Summary

This is a clean-slate v3 design. No backward compatibility with v2 WorkflowHandler trait needed - there are no users yet.

The #[workflow] macro generates a fresh state machine implementation directly, not layered on top of the old trait.