Configuration¶
Configure CMDx to register components, control logging, and customize framework behavior. Configuration lives at two levels: global defaults and per-class overrides.
Configuration Hierarchy¶
CMDx uses a two-tier configuration system:
- Global Configuration — Framework-wide defaults via
CMDx.configure - Class-level overrides — On the task class via
settings,register,deregister,retry_on,deprecation
Important
Class-level registries are lazily duplicated from the parent on first access. Configure globals before any task touches a registry, or call CMDx.reset_configuration! in test setup to invalidate cached copies on Task.
Global Configuration¶
Default Values¶
| Setting | Default | Description |
|---|---|---|
logger |
Logger.new($stdout, progname: "cmdx", formatter: Line.new, level: INFO) |
Logger instance |
log_level |
nil |
Optional override applied on top of logger.level (nil = use the logger's own level) |
log_formatter |
nil |
Optional override applied on top of logger.formatter (nil = use the logger's own formatter) |
log_exclusions |
[] |
Result#to_h keys stripped from the lifecycle log entry (e.g. [:context]) |
default_locale |
"en" |
Locale for built-in translation fallbacks |
backtrace_cleaner |
nil |
Callable to clean fault backtraces |
strict_context |
false |
Raise NoMethodError on unknown context.foo reads |
correlation_id |
nil |
Callable resolved once per root execution; surfaced as xid on Chain/Result/Telemetry::Event |
middlewares |
Middlewares.new (empty) |
Middleware registry |
callbacks |
Callbacks.new (empty) |
Callback registry |
coercions |
Coercions.new (13 built-ins) |
Coercion registry |
validators |
Validators.new (7 built-ins) |
Validator registry |
executors |
Executors.new (:threads, :fibers) |
Parallel-group executor registry |
mergers |
Mergers.new (:last_write_wins, :deep_merge, :no_merge) |
Parallel-group merge-strategy registry |
retriers |
Retriers.new (7 built-ins) |
Retry/jitter strategy registry |
deprecators |
Deprecators.new (:log, :warn, :error) |
Deprecation handler registry |
telemetry |
Telemetry.new (empty) |
Telemetry pub/sub |
Default Locale¶
Set the locale used for built-in translation fallbacks when the I18n gem isn't loaded. See Internationalization for the full locale list.
Note
When I18n is loaded, CMDx delegates to I18n.translate and default_locale is unused — locale comes from I18n.locale. Without I18n, all built-in messages (validation errors, coercion errors, etc.) resolve from this setting.
Backtrace Cleaner¶
Trim noise from Fault backtraces with any callable that takes Array<String> and returns a cleaned array.
CMDx.configure do |config|
config.backtrace_cleaner = ->(bt) { bt.reject { |l| l.include?("/gems/") } }
# Rails:
config.backtrace_cleaner = ->(bt) { Rails.backtrace_cleaner.clean(bt) }
end
Note
Rails apps wire this automatically via CMDx::Railtie.
Strict Context¶
Raise NoMethodError for unknown dynamic context reads instead of returning nil. Applies to the ctx.foo reader only — [], fetch, dig, key?, and predicate ctx.foo? keep their lenient behavior. See Context - Strict Mode for usage.
Override per-task via settings(strict_context: true).
Correlation ID (xid)¶
Thread an external correlation id (e.g. Rails request_id) through every task in a chain so they can be filtered together in logs and telemetry. Provide a callable; Runtime invokes it once per root execution when it builds the chain. The resolved value is stored on the Chain and surfaced as xid on every Result#to_h and Telemetry::Event.
CMDx.configure do |config|
config.correlation_id = -> { Current.request_id }
end
result = ProcessOrder.execute(order_id: 42)
result.xid #=> "abc-123-..."
result.chain.map(&:xid).uniq #=> ["abc-123-..."] (every task in the chain shares it)
Override per-task via settings(correlation_id: ->{ ... }) to give a specific task family its own resolver. Returning nil from the callable leaves xid as nil. Resolver exceptions propagate (so misconfigured resolvers fail loudly).
Note
The resolver fires only on the root chain creation. Nested subtasks inherit the same xid via the shared chain — even if the underlying thread-local mutates mid-flight, every result in the same execution sees a stable value.
Logging¶
CMDx.configure do |config|
config.logger = Logger.new($stdout, progname: "cmdx")
config.log_level = Logger::DEBUG
config.log_formatter = CMDx::LogFormatters::JSON.new
config.log_exclusions = [:context]
end
Built-in formatters live under CMDx::LogFormatters: Line (default), JSON, KeyValue, Logstash, Raw. See Logging for the emitted fields and sample output.
log_exclusions trims noisy keys from the logged Result#to_h (common targets: :context, :metadata, :tags). The returned Result and telemetry payloads still carry the full data — only the log line is filtered.
Middlewares¶
Middlewares wrap the entire task lifecycle. The signature is call(task) { ... } — call yield (or next_link.call from a Proc) to invoke the next link.
CMDx.configure do |config|
# Class with #call(task)
config.middlewares.register CustomMiddleware
# Instance
config.middlewares.register CustomMiddleware.new(threshold: 1000)
# Proc / Lambda — must declare &next_link to pass the block
config.middlewares.register(proc do |task, &next_link|
locale = Current.user.locale || I18n.default_locale
I18n.with_locale(locale) do
task.metadata[:locale] = locale
next_link.call
end
end)
# Insert at a specific position
config.middlewares.register MyOuterMiddleware, at: 0
# Remove
config.middlewares.deregister CustomMiddleware
end
Caution
A middleware that forgets to yield raises CMDx::MiddlewareError — the task body is never invoked, so silent skips are caught immediately.
See the Middlewares docs for class-level configuration.
Callbacks¶
Callbacks fire at specific lifecycle points. Valid events:
| Event | When |
|---|---|
:before_execution |
Before any work is executed |
:before_validation |
Right after :before_execution, before input resolution |
:around_execution |
Wraps work and any rollback; must invoke its continuation |
:after_execution |
After work and rollback is executed |
:on_complete |
When state == "complete" (success path) |
:on_interrupted |
When state == "interrupted" (skip or fail) |
:on_success |
When status == "success" |
:on_skipped |
When status == "skipped" |
:on_failed |
When status == "failed" |
:on_ok |
Success or skipped (signal.ok? — not failed) |
:on_ko |
Skipped or failed (signal.ko? — not success) |
CMDx.configure do |config|
# Symbol — dispatched as task.send(:method)
config.callbacks.register :before_execution, :initialize_session
# Class / instance with #call(task)
config.callbacks.register :on_success, LogUserActivity
# Proc / Lambda — instance_exec'd on the task; receives task as block arg.
# The Result isn't built yet during callbacks; subscribe to Telemetry's
# :task_executed event when you need result data like duration.
config.callbacks.register(:on_complete, proc do |task|
StatsD.increment("task.completed", tags: ["task:#{task.class}"])
end)
# Remove every callback for an event
config.callbacks.deregister :on_success
# Or remove a specific entry — match by `==` (Procs/Lambdas by identity)
config.callbacks.deregister :on_success, LogUserActivity
end
Note
deregister(event) drops every callback for that event; pass a second arg to remove only matching entries (==). Unknown events raise ArgumentError; unmatched callables are silent no-ops.
See Callbacks for class-level usage.
Telemetry¶
Pub/sub for runtime lifecycle events. Subscribers receive a Telemetry::Event data object with cid, xid, root, type, task, tid, name, payload, and timestamp.
| Event | Payload |
|---|---|
:task_started |
empty |
:task_deprecated |
empty |
:task_retried |
{ attempt: Integer } |
:task_rolled_back |
empty |
:task_executed |
{ result: Result } |
CMDx.configure do |config|
config.telemetry.subscribe(:task_executed, ->(event) {
StatsD.timing("cmdx.task", event.payload[:result].duration, tags: [
"class:#{event.task}",
"status:#{event.payload[:result].status}"
])
})
config.telemetry.subscribe(:task_retried, ->(event) {
Rails.logger.warn("[cmdx] retry ##{event.payload[:attempt]} for #{event.task}")
})
config.telemetry.unsubscribe(:task_executed, my_subscriber)
end
Tip
Runtime emits events only when subscribers exist for them, so unused events have zero overhead.
Coercions¶
Custom coercions are callables receiving (value, **options) and returning the coerced value or CMDx::Coercions::Failure.new(message) on failure.
CMDx.configure do |config|
config.coercions.register :currency, CurrencyCoercion
config.coercions.register(:tag_list, proc do |value, **opts|
delimiter = opts[:delimiter] || ","
max_tags = opts[:max_tags] || 50
value.to_s.split(delimiter).map(&:strip).reject(&:empty?).first(max_tags)
end)
config.coercions.deregister :currency
end
See Inputs - Coercions for usage.
Validators¶
Custom validators are callables receiving (value, options) (options is a positional hash). Return CMDx::Validators::Failure.new(message) to mark the value invalid; any other return value (including nil) is treated as success.
CMDx.configure do |config|
config.validators.register :uuid, UuidValidator
config.validators.register(:access_token, proc do |value, options|
prefix = options[:prefix] || "tok_"
min = options[:min_length] || 40
unless value.is_a?(String) && value.start_with?(prefix) && value.length >= min
CMDx::Validators::Failure.new("invalid access token")
end
end)
config.validators.deregister :uuid
end
See Inputs - Validations for usage.
Executors¶
Named concurrency backends used by Workflow :parallel groups. An executor is any callable with signature call(jobs:, concurrency:, on_job:) that invokes on_job.call(job) for each job and blocks until all jobs complete. Built-ins: :threads (default), :fibers.
CMDx.configure do |config|
# Class or instance with #call(jobs:, concurrency:, on_job:)
config.executors.register :ractor, RactorExecutor
# Proc / Lambda
config.executors.register(:inline, proc do |jobs:, concurrency:, on_job:|
jobs.each { |job| on_job.call(job) }
end)
config.executors.deregister :fibers
end
See Workflows - Parallel Groups for usage.
Mergers¶
Named strategies for folding successful parallel task results back into the workflow context. A merger is any callable with signature call(workflow_context, result). Built-ins: :last_write_wins (default), :deep_merge, :no_merge.
CMDx.configure do |config|
# Only merge specific keys
config.mergers.register(:whitelist, proc do |ctx, result|
result.context.to_h.slice(:user_id, :tenant_id).each { |k, v| ctx[k] = v }
end)
config.mergers.deregister :no_merge
end
See Workflows - Parallel Groups for usage.
Class-Level Configuration¶
Settings¶
Settings exposes a small set of per-class overrides for logger and tagging:
class GenerateInvoice < CMDx::Task
settings(
logger: CustomLogger.new($stdout),
log_formatter: CMDx::LogFormatters::JSON.new,
log_level: Logger::DEBUG,
log_exclusions: [:context, :metadata],
backtrace_cleaner: ->(bt) { bt.first(8) },
tags: ["billing", "financial"],
strict_context: true
)
def work
# ...
end
end
Every getter falls back to the global configuration when an option isn't set. Subclasses inherit and may layer on top — multiple settings(...) calls compose (each merges on top of the previous).
class BaseTask < CMDx::Task
settings(tags: ["api"])
end
class ChildTask < BaseTask
settings(tags: ["billing"], log_level: Logger::DEBUG)
# tags = ["billing"] (child wins; settings.build does Hash#merge)
end
Note
Settings only stores logging/tracing options (:logger, :log_formatter, :log_level, :log_exclusions, :backtrace_cleaner, :tags, :strict_context, :correlation_id). Other config uses dedicated DSL (retry_on, deprecation, register, before_execution, …).
Retry¶
Configure exception-based retries with retry_on. Accumulates across inheritance.
class FetchInvoice < CMDx::Task
retry_on Net::OpenTimeout, Net::ReadTimeout,
limit: 3,
delay: 0.5,
max_delay: 5.0,
jitter: :exponential # :exponential, :half_random, :full_random, :bounded_random, :linear, :fibonacci, :decorrelated_jitter
retry_on External::ApiError, limit: 5 do |attempt, delay|
delay * (attempt + 1) # custom jitter block
end
end
Note
jitter: takes precedence over a custom block — pass one or the other, not both, or the block is silently ignored.
Deprecation¶
See Deprecation. Declared via the class-level deprecation DSL — not via settings.
Registrations¶
Register or deregister middlewares, callbacks, coercions, and validators on a specific task class.
class SendCampaignEmail < CMDx::Task
# Middlewares
register :middleware, AuditTrailMiddleware
deregister :middleware, GlobalLoggingMiddleware
# Callbacks (use the dedicated DSL OR register :callback explicitly)
before_execution :find_campaign
on_complete proc { |task| Analytics.track("email_sent", task.context.recipient) }
register :callback, :on_failed, :send_alert
# Coercions
register :coercion, :currency, CurrencyCoercion
# Validators
register :validator, :uuid, UuidValidator
# Inputs / outputs (per-class schemas)
register :input, :recipient_id, coerce: :integer, presence: true
register :output, :delivered_at
end
See Inputs - Definitions and Outputs for the full schema DSL — the dedicated required / optional / output helpers are usually preferred over register :input / register :output.
Note
deregister mirrors register's arity. Callbacks: deregister :callback, event[, callable] (event clear, or matched by ==). Middlewares: deregister :middleware, callable_or_class (or at: index).
Configuration Management¶
Access¶
# Global
CMDx.configuration.logger #=> <Logger instance>
CMDx.configuration.middlewares.size #=> 0
CMDx.configuration.coercions.registry #=> { array: ..., big_decimal: ..., ... }
# Class-level
class ProcessUpload < CMDx::Task
settings(tags: ["files"])
def work
self.class.settings.tags #=> ["files"]
self.class.settings.logger #=> falls back to CMDx.configuration.logger
self.class.middlewares.size #=> inherited count
end
end
Resetting¶
CMDx.reset_configuration! replaces the global config with a fresh instance and invalidates the cached registries on Task so subclasses rebuild from the new config on next access.
CMDx.reset_configuration!
# Test setup (RSpec)
RSpec.configure do |config|
config.before(:each) do
CMDx.reset_configuration!
end
end
Important
reset_configuration! clears registry caches on Task only — subclasses that already cached their own copy keep them. In tests, prefer freshly defined task classes per example (e.g. stub_const or anonymous classes).