Building Workflows Across Callbacks
A useful operation often needs several asynchronous steps, with each result deciding what happens next. A callback receives one result; a workflow connects those results to the task the caller wants completed. You need somewhere to keep progress between callbacks, decide which step may follow, and report the final outcome.
The ChecksumPacker demo shows this with a practical task: create a ZIP archive of a directory and include a
checksum.txt manifest containing the files’ checksums.
Scanning and packing run on worker threads, while callbacks on the main loop coordinate the operation.
This page focuses on operation state and dependencies, building on the bound continuations introduced in
Simplifying Event Code with Bindings.
From Individual Steps to One Operation
Creating a package has a dependency: packing needs the file list and checksums produced by scanning. A successful scan makes packing possible, but the caller’s task is complete only when the archive has been written. If either step fails, the workflow must report failure instead of leaving the caller waiting for success.
The demo creates one Workflow object for each source directory.
That object owns the scanner, packer, current state, and a completion callback supplied by the application.
It survives the individual callbacks, giving all steps a shared context.
Its states describe progress toward one archive:
Scanning and Packing mean that the operation is still in progress.
Done and Failed are its two terminal outcomes.
The application retains its workflows until cleanup, and queued callbacks also capture a shared pointer to their
workflow so it stays alive while they use it.
This state belongs to the whole operation. The scanner knows how to produce a file list; the workflow knows that a successful file list should lead to packing. Keeping that decision in the workflow lets each worker concentrate on its own step. In your own application, the equivalent object might coordinate downloading and importing a document, or loading and transforming an image.
Advancing the Workflow from a Result
The first transition happens in Workflow::start(), which the application queues on the main loop.
It sets the state to Scanning before asking the scanner to begin.
It also supplies the callback that will carry the result back to this workflow:
event/ChecksumPacker/src/Workflow.hpp
_state.requireAndSet(State::Initialized, State::Scanning);
_scanner.scan(el::bindEvent(shared_from_this(), &Workflow::onScanned));
The binding captures the current main-loop target and retains this workflow.
scan() starts a worker thread and returns; the workflow remains in Scanning while main dispatches other work.
When scanning finishes, the worker calls its ordinary completion function with the file list and an optional error.
That bound function queues onScanned() on main, where the result determines the next state:
event/ChecksumPacker/src/Workflow.hpp
void onScanned(FileWithDigestList fileList, std::exception_ptr error) {
_scanner.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Scanning, State::Failed);
_onDone(std::move(error));
return;
}
_state.requireAndSet(State::Scanning, State::Packing);
_packer.pack(std::move(fileList), el::bindEvent(shared_from_this(), &Workflow::onPacked));
}
A failed scan ends the workflow and reports the error through _onDone.
A successful scan changes the state to Packing and passes the file list to the packer.
The callback supplied to pack() arranges for onPacked() to receive the next result on the main loop.
The method then returns while packing continues.
requireAndSet() expresses which transition is valid: this callback must arrive while the workflow is Scanning.
It makes an unexpected transition a detectable programming error.
You do not need a general workflow engine to connect these steps; named continuation methods and explicit state make the
sequence visible in ordinary application code.
Returning Decisions to the Owner Loop
The scan call has two distinct boundaries: starting a worker and returning its result to the main loop.
Scanner::scan() itself runs on main; it starts a thread that executes scanInThread().
When the work finishes, scanInThread() calls the completion function passed to scan(), named onDone inside
the scanner.
That function is the event binding supplied by the workflow.
Calling it on the worker queues its continuation on main.
The scanner’s completion callback reports that one step has finished.
The queued onScanned() continuation decides what that means for the whole operation.
An ordinary callback has no automatic main-loop affinity.
The binding establishes that return boundary when the workflow registers it.
Packing uses the same pattern: onScanned() starts the packer, the pack worker reports its result, and a queued
callback enters onPacked() on main.
All workflow state changes therefore happen on one loop, even though the individual steps run elsewhere.
Communicating Between Event Loops develops the request and reply mechanics in more detail.
Ownership Across the Steps
The demo needs no application mutex around its workflow state or file list because different threads do not modify those
objects at the same time.
The main loop alone changes _state.
The scan worker builds its own list, supplies a result value to its callback, and that value moves into the queued
continuation.
After receiving it, the main loop passes it onward to the packing worker.
Data |
Where it is used |
|---|---|
Workflow progress and next-step decisions |
Main-loop callbacks only. |
File list being assembled |
Scan worker only. |
Result waiting for dispatch |
Owned by the queued callback. |
File list used to create the archive |
Pack worker, after the handoff from main. |
The strong member binding keeps the workflow alive; it does not make its members thread-safe.
The ownership and execution rules are what make access safe.
The worker shutdown signal is explicitly atomic, and the event queue handles synchronization for delivering callbacks.
Before advancing past a worker’s result, the workflow joins that worker through shutdown().
Application cleanup also stops and joins workers before releasing the workflows.
See Cancelling Work and Shutting Down for the broader shutdown design.
Defining Completion for the Caller
Each step has a result, but only the workflow knows whether that result completes the caller’s task.
A successful scan continues to packing; a successful pack completes the archive.
Either worker’s reported error ends this workflow with failure.
The common completion callback, _onDone, carries nullptr for success or an exception pointer for failure.
The final continuation records the outcome before notifying the application:
event/ChecksumPacker/src/Workflow.hpp
void onPacked(std::exception_ptr error) {
_packer.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Packing, State::Failed);
} else {
_state.requireAndSet(State::Packing, State::Done);
}
_onDone(std::move(error));
}
The caller therefore observes a workflow that has already reached its terminal state. Both failure paths and the success path provide an explicit end to the operation. When you compose your own steps, give each result this same decision: continue with a valid next step, or finish with an outcome the caller can handle.
The application may have several independent directory workflows in progress.
It quits successfully when all have reached Done; a reported error is rethrown on main so the application’s error
handling can report failure and begin cleanup.
That application-wide policy is separate from the sequence needed to create one archive.
Handling Event Failures explains error reporting beyond the individual workflow.
Complete demo sources
event/ChecksumPacker/src/Workflow.hpp
// Copyright (c) 2026 Tobias Erbsland - https://erbsland.dev
// SPDX-License-Identifier: Apache-2.0
#pragma once
#include "Packer.hpp"
#include "Scanner.hpp"
#include "State.hpp"
#include "Workflow_fwd.hpp"
#include <erbsland/BindEvent.hpp>
#include <erbsland/LogStream.hpp>
#include <erbsland/Path.hpp>
#include <exception>
#include <functional>
#include <memory>
#include <utility>
namespace demo {
using namespace el::text::literals;
/// Coordinate scanning and packing while keeping all state transitions on the main event loop.
/// Workers return captured results through events; the application owns the workflow until both workers join.
class Workflow final : public std::enable_shared_from_this<Workflow> {
public:
/// The callback if the workflow is done.
/// On success, nullptr is passed to indicate that no failures occurred.
/// On failure, a std::exception_ptr containing an exception is passed to indicate why the workflow failed.
using OnDone = std::function<void(std::exception_ptr)>;
public:
/// Create a new workflow from a source directory.
/// @param sourceDirectory Path to the source directory.
/// @param zipPath The path for the final ZIP file.
/// @param logStream The log stream to write output to.
/// @param onDone Called on the main event loop after success or failure.
explicit Workflow(el::Path sourceDirectory, el::Path zipPath, el::LogStreamPtr logStream, OnDone onDone) :
_logStream{std::move(logStream)},
_onDone{std::move(onDone)},
_state{_logStream},
_scanner{sourceDirectory, _logStream},
_packer{std::move(sourceDirectory), std::move(zipPath), _logStream} {}
public: // events
/// Start the workflow for this working set.
void start() {
_logStream->trace("Starting workflow"_el);
_state.requireAndSet(State::Initialized, State::Scanning);
_scanner.scan(el::bindEvent(shared_from_this(), &Workflow::onScanned));
}
/// Test if the workflow is done.
[[nodiscard]] auto isDone() const noexcept -> bool { return _state.is(State::Done); }
/// Shutdown the workflow.
void shutdown() {
_scanner.shutdown();
_packer.shutdown();
}
private:
/// Receive the scan result on the main loop, joining the worker before starting packing.
void onScanned(FileWithDigestList fileList, std::exception_ptr error) {
_scanner.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Scanning, State::Failed);
_onDone(std::move(error));
return;
}
_state.requireAndSet(State::Scanning, State::Packing);
_packer.pack(std::move(fileList), el::bindEvent(shared_from_this(), &Workflow::onPacked));
}
/// Receive one packing result and notify the application after the worker has joined.
void onPacked(std::exception_ptr error) {
_packer.shutdown();
if (error != nullptr) {
_state.requireAndSet(State::Packing, State::Failed);
} else {
_state.requireAndSet(State::Packing, State::Done);
}
_onDone(std::move(error));
}
private:
// readonly setup data:
const el::LogStreamPtr _logStream; ///< The log stream to write output to during the operation.
const OnDone _onDone; ///< Callback for when the workflow is done.
// owned by this working set and its events:
State _state; ///< The state of the working set.
// the workflow components
Scanner _scanner; ///< The scanner logic.
Packer _packer; ///< The packer logic.
};
}