Basics - Chain¶
A Chain is the ordered trace of every Result produced by a top-level task and the subtasks it triggered. It's assembled automatically and gives you one id to correlate an entire execution.
Structure¶
A Chain is an ordered, mutex-guarded collection of Results. Subtasks push onto the chain as they finalize; the root unshifts itself last, so the root ends up at index 0 and children follow in completion order.
From a result, reach the chain via:
| Method | Returns |
|---|---|
result.chain |
The owning CMDx::Chain (Enumerable; id, size, first, last, etc.) |
result.cid |
The chain's UUID v7 (String) |
result.xid |
External correlation id (String, nil when no resolver is configured) |
result.index |
This result's zero-based position in the chain (Integer, nil if absent) |
result.root? |
true when this result is the chain's root |
CMDx::Chain.current |
The live Chain object (only inside execution) |
result = ImportDataset.execute(dataset_id: 456)
result.cid #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
result.index #=> 0
result.root? #=> true
result.chain.size #=> 4
result.chain.first #=> root result (ImportDataset)
result.chain.last #=> last subtask result
result.chain.each_with_index do |r, idx|
puts "#{idx}: #{r.task} - #{r.status}"
end
The Chain instance exposes id, xid, results, push (aliased <<), unshift, index, size, empty?, each, last, plus root delegators:
| Method | Returns |
|---|---|
chain.root |
The result flagged with root: true, or nil |
chain.state |
chain.root&.state — "complete" / "interrupted" / nil |
chain.status |
chain.root&.status — "success" / "skipped" / "failed" / nil |
Subtasks¶
Subtasks automatically join the current chain, building a unified execution trail:
class ImportDataset < CMDx::Task
def work
context.dataset = Dataset.find(context.dataset_id)
result1 = ValidateSchema.execute(context)
result1.chain.size #=> 1 (the parent hasn't finalized yet)
result2 = TransformData.execute!(context)
result2.cid == result1.cid #=> true
result2.chain.size #=> 2
SaveToDatabase.execute(dataset_id: context.dataset_id)
end
end
# After ImportDataset finalizes, its result is unshifted to position 0:
result = ImportDataset.execute(dataset_id: 456)
result.chain.size #=> 4 (parent + 3 subtasks)
result.chain.first.task #=> ImportDataset (the root)
result.chain.map(&:task)
#=> [ImportDataset, ValidateSchema, TransformData, SaveToDatabase]
Note
Chain lifecycle is automatic: Runtime installs a fresh chain when the top-level task starts, freezes it (and its results array) on root teardown, and clears the fiber-local reference. result.index is nil on a result that was never appended to the chain — every finalized Result is always on its chain, so this only happens in test doubles.
Correlation ID (xid)¶
Chain#xid is an optional external correlation id (e.g. Rails request_id) — distinct from cid (the per-execution chain UUID). It's resolved once per root chain creation from CMDx.configuration.correlation_id (a callable; per-task override via settings(correlation_id: ->{ ... })). Every nested subtask inherits the same xid via the shared chain, so a single request id ties together every task it touches in logs and telemetry.
CMDx.configure do |config|
config.correlation_id = -> { Current.request_id } # e.g. Rails ActionDispatch::RequestId
end
result = ImportDataset.execute(dataset_id: 456)
result.xid #=> "abc-123-..."
result.chain.xid #=> "abc-123-..."
result.chain.map(&:xid).uniq #=> ["abc-123-..."]
See Configuration - Correlation ID for resolver semantics.
Fiber Storage¶
The active chain lives on Fiber[] (fiber-local), so each Thread's root fiber and every explicit Fiber.new sees its own chain. Workflow parallel groups intentionally propagate the parent chain into their worker threads so their results roll up under the same trace; the chain's internal mutex makes concurrent pushes safe.
# Thread A — its root fiber gets a fresh chain
Thread.new do
result = ImportDataset.execute(file_path: "/data/batch1.csv")
result.cid #=> "018c2b95-b764-7615-a924-cc5b910ed1e5"
end
# Thread B — completely separate chain
Thread.new do
result = ImportDataset.execute(file_path: "/data/batch2.csv")
result.cid #=> "018c2c11-c821-7892-b156-dd7c921fe2a3"
end
# Inspect or clear the current fiber's chain (rarely needed)
CMDx::Chain.current #=> Returns current chain or nil
CMDx::Chain.clear #=> Clears current fiber's chain (Runtime does this on teardown)
Warning
Runtime freezes the chain on root teardown, so CMDx::Chain.clear is really only useful in test setup before the next execution. Mutating a frozen chain raises FrozenError.