Skip to content

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.