Items here are exploratory roadmap ideas, not shipped features or release commitments. Names, APIs, and layering may change as designs are validated against real apps and benchmarks.
Use this file to track what we might build next and why. When work starts on an item, link a design note or issue and move status toward in progress / done in the changelog instead of treating this doc as the source of truth for behavior.
Status: Idea (foundational — intended before or alongside cache and checkpoint)
Theme: One generic encode/decode layer for anything that persists Ruby-ish data through CMDx::Stores or job payloads.
CMDx::Stores only speaks strings (see Stores). Features that need to round-trip structured state—workflow checkpoints, cached task outputs, large context snapshots—must agree on:
- Format (JSON, MessagePack, compressed JSON, app-specific encryption),
- Versioning (blob schema when task classes or context keys change),
- Safety (what types may appear in a payload; no accidental
Marshalof arbitrary objects), - Failure modes (corrupt blob, unknown serializer id, decode errors).
Today, Context#to_json and Result#to_h help with logging and introspection, but they are not a contract for store-backed persistence. Cache and checkpoint would otherwise each invent their own envelope and error types.
A small serializer framework parallel to existing registries (coercions, validators, executors, mergers):
- Global config —
CMDx.configure { |c| c.serializers = … }with a default registry instance (andCMDx.reset_configuration!restores it in tests, same hierarchy rules as other registries). - Registry —
CMDx::Serializers(name TBD) maps symbols to serializer objects that implement a narrow contract, e.g.#dump(object) → Stringand#load(string) → object(exact names aligned with Ruby conventions). - Built-ins — at minimum a JSON serializer that produces UTF-8 strings safe for Redis/memory adapters; optional built-ins (e.g. compressed JSON, MessagePack) only if they stay stdlib/light-deps friendly or live behind optional requires.
- Registration —
CMDx.serializers.register :my_app, MySerializerorconfig.serializers.register(...)so apps can plug encryption, Avro, or “store id in SQL, blob in S3” wrappers without forking core. - Envelope — higher-level helpers (not necessarily exposed to every user) wrap domain objects in a versioned document, e.g. serializer id, format version, task/workflow identity, payload — so cache and checkpoint share one on-the-wire shape and telemetry can record
serializer:andbytes:. - Resolution order — feature-level option → task/workflow
settings→ global default (same layering story as loggers and middlewares). - Explicit boundaries — serializers used for persistence should default to JSON-safe data (Hash-like structures with string keys, scalars, arrays); opt-in hooks for types that implement a CMDx protocol (e.g.
#as_cmdx_payload/.from_cmdx_payload) instead of implicitas_jsonon every value.
# config/initializers/cmdx.rb
CMDx.configure do |config|
config.serializers = CMDx::Serializers.new
config.serializers.register :json, CMDx::Serializers::JSON.new
config.serializers.register :encrypted_json, MyApp::EncryptedJsonSerializer.new
config.default_serializer = :json
end
# Lookup
blob = CMDx.serializers[:json].dump({ "v" => 1, "outputs" => { "rate" => "1.02" } })
data = CMDx.serializers[:json].load(blob)
# Feature override (cache / checkpoint / future store features)
class FetchExchangeRate < ApplicationTask
cache_result key: :pair, ttl: 300, serializer: :json
end
class FulfillmentWorkflow < CMDx::Task
include CMDx::Workflow
settings checkpoint_serializer: :encrypted_json
end- Not idempotency values: Idempotency store entries (
processing/completed) stay tiny opaque strings; serializers target payload blobs, not lease tokens. - Size limits: Registry or envelope may expose
max_bytes/ compression hints; adapters can refuse writes before hitting Redis limits. - Corruption:
#loadraises a dedicated error (subclass ofCMDx::Erroror similar) so cache/checkpoint can treat decode failure as a miss or resume from scratch, with telemetry:serializer_decode_failed. - Testing: In-memory store + round-trip helpers in spec support (
dump_load_roundtrip) keep features from re-testing JSON edge cases. - Job adapters: The same registry can validate enqueue payloads (
serializers[:json].dump(context_hash)must succeed) so background-job and store features share one definition of “serializable context.”
- Single
CMDx::Serializers::JSONvs reusingContext#to_json/ custom key normalization (symbols → strings)? - Built-in allowlist of context keys per task (
persist: %i[rate user_id]) enforced at dump time? - Whether envelope lives in core or in a
CMDx::Persistence::Envelopemodule shared by cache/checkpoint only? - Compression as a serializer decorator vs separate
:gzip_jsonregistration?
- Configuration — pattern for global registries
- Stores — string values, TTL
CMDx::Context#to_json,CMDx::Result#to_h— logging/introspection, not persistence contractcache_resultand Checkpoint / resume (below) — primary consumers
Status: Idea
Theme: Cross-process memoization of successful task runs (read-through cache), distinct from idempotency.
Many tasks are pure or expensive-but-deterministic for a given input set: geocoding, FX rates, permission snapshots, third-party “get user” calls. Today you either:
- Recompute on every
execute, or - Hand-roll caching in
#workwith Redis/Memcached and inconsistent key/TTL/error semantics.
idempotent_by solves duplicate side effects (at-most-once execution), not reuse of a prior successful result when you are happy to skip work entirely and return stored outputs.
A declarative helper (working name cache_result, backed by something like CMDx::Cache) that:
- Runs after input resolution (same general placement as idempotency/locks in
Runtime) so keys are built from validated, coerced inputs and context. - Computes a stable cache key from the task class and a user-defined segment (symbol on context, proc, etc.—same ergonomics as
idempotent_by/lock_with). - On cache hit with a valid, non-expired entry: short-circuit before
#work, hydratecontext/ declared outputs from the stored payload, and finish with a successful result (telemetry should distinguish cached vs executed). - On miss: run the normal lifecycle (
work, output verification, etc.). On success only, dump a snapshot of the return surface (outputs and any agreed context keys) through the configured serializer intoCMDx::Storeswith a TTL. - On failure (
fail!,skip!, raised errors): do not write or refresh the cache entry (failed runs must not poison the cache).
class FetchExchangeRate < ApplicationTask
cache_result key: ->(t) { "#{t.base_currency}:#{t.quote_currency}:#{t.as_of}" },
ttl: 300,
namespace: "cmdx:cache",
serializer: :json
required :base_currency, :quote_currency
required :as_of, coerce: :date
output :rate
def work
context.rate = ExternalRates.fetch(base_currency, quote_currency, as_of)
end
end- Shared store layer: Reuse
CMDx::Stores(Redis/Memory adapters, TTL, namespacing) so cache entries behave like idempotency and circuit-breaker keys in multi-worker deployments. - Similar machinery to checkpoint (below): Key composition, TTL, conditional writes, and telemetry hooks should follow the same patterns as
IdempotencyandLock—one implementation style for “store-backed runtime primitives.” - Serializers registry: Use
config.serializers(see above) for dump/load; cache owns what to persist (outputs, optional context keys), not how to encode bytes. - Must respect
outputdeclarations and avoid caching secrets unless the app opts in (serializer:/ allowlists). - Interaction with
idempotent_by: A duplicate invocation might still be an idempotency skip while cache is a separate concern; ordering inRuntimeand docs need to spell out combined behavior (e.g. idempotency claim first, then cache read inside the claim).
- Cache invalidation API (manual
del, version in key,if:/unless:gates)? - Should cache hits run callbacks (
on_executed, telemetry subscribers) or a lighter “cache_hit” event only? - Stale-while-revalidate or single-flight deduplication under load?
- Stores — planned home for cache and checkpoint bytes
- Idempotency — duplicate execution, not result reuse
CMDx::Idempotency— reference for store key layout andRuntimewrapping- Serializers (above) — shared encode/decode for cache entries
Status: Idea
Theme: Run a task without blocking the caller; observe completion via a future-like handle.
Task.execute / execute! are synchronous: the caller blocks until Runtime finishes, including retries, locks, and parallel workflow children. That is correct for HTTP requests and scripts, but awkward when you want to:
- Kick off work and continue in the same process (UI thread, event loop,
Asyncintegration), - Compose multiple tasks concurrently without manually managing threads/fibers,
- Attach timeouts or cancellation at the call site without pushing that into every task.
Apps today wrap execute in Thread.new, custom executors, or job queues—losing consistent telemetry, chain semantics, and error surfaces.
An async entry point (name TBD, e.g. execute_async) that:
- Schedules the same full
Runtimelifecycle on a configurable executor (thread pool, fiber scheduler, or injected backend). - Returns immediately with a future-like object that can:
- Block with
value/wait(raising underexecute!-equivalent strictness), - Poll
ready?/failed?, - Expose the frozen
CMDx::Resultwhen complete, - Optionally propagate correlation into the child execution’s chain/telemetry.
- Block with
- Preserves existing semantics: middlewares, callbacks, idempotency, locks, circuit breaker, and output verification run unchanged inside the async run.
- Documents interaction with fiber-local
Chain(child run should not corrupt the parent’s chain; likely a fresh chain root unless explicitly linked).
future = ProcessUpload.execute_async(file_id: upload.id)
# ... do other work ...
result = future.wait # blocks
result = future.wait(timeout: 5) # TBD
ProcessUpload.execute_async(file_id: upload.id).then { |r| notify(r) } # TBD callback style- Not a replacement for distributed background jobs (see next section); optimized for in-process concurrency and testability.
- Strict vs non-strict should mirror
execute/execute!on the future. - Error objects raised on
waitshould match today’sFault/ exception story.
- Default executor vs explicit
executor:on each call vs global config? - Cancellation API (kill thread vs cooperative cancel token into
work)? - Integration with workflow
:parallelgroups—shared pool or separate?
CMDx::Runtime— lifecycle to schedule unchanged- Workflows – parallel groups — existing in-pipeline concurrency
- Blog: Background Jobs + CMDx — when async leaves the process
Status: Idea
Theme: First-class enqueue helpers for Sidekiq, ActiveJob, GoodJob, etc., with safe serialization at the boundary.
Production apps almost always run CMDx at the edge of a job runner. The recommended pattern today is a thin job class that calls execute!—which works but duplicates boilerplate across apps:
- JSON-safe argument hashes (string keys),
- Correlation id handoff across processes,
- Mapping permanent
fail!outcomes to “do not retry” vs transient errors, - Optional
idempotent_bykeys derived from job arguments.
Self-including Sidekiq::Job on the task class (documented in the blog) blurs “domain task” and “infrastructure adapter” and makes testing/no-Sidekiq environments heavier.
Optional adapter layer (per backend or a small family of modules) that:
- Exposes familiar enqueue methods on the task class, e.g.
perform_async,perform_in,perform_at(exact names aligned with the target gem where possible). - At enqueue time, validates that the payload round-trips through the configured serializer (default
:json): attribute keys, primitive types, no live objects. Fail fast in development if someone passes anActiveRecordinstance. - At perform time, rebuilds context from the payload and calls
execute/execute!with documented retry behavior hooks (e.g. register whichmetadata[:code]values are permanent). - Supports correlation id propagation via an optional key in the payload and a documented callback or
settingshook so telemetry lines up across web → job → nested job. - Stays optional: no Sidekiq gem required in core; adapters live in optional requires or companion gems if needed.
class Users::SendVerification < ApplicationTask
include CMDx::Adapters::Sidekiq # name TBD
sidekiq_options queue: :mailers, retry: 5
idempotent_by :user_id
required :user_id, coerce: :integer
def work
# ...
end
end
Users::SendVerification.perform_async(user_id: 42)
Users::SendVerification.perform_in(5.minutes, user_id: 42)- Enqueue surface should mirror what teams already know from Sidekiq/ActiveJob to reduce migration friction.
- Document pairing with
idempotent_byandlock_withfor redelivered messages and concurrent workers. - ActiveJob wrapper might be a generator template first, then a shared module.
- One meta-adapter vs per-framework gems?
- Built-in mapping from
CMDx::Fault/metadata[:code]to “discard” vs “retry” without per-apprescue? - Workflow steps that enqueue child jobs—official pattern vs app responsibility?
- Background Jobs + CMDx
- Idempotency, Locks
- Stores — shared state across workers
- Serializers (above) — shared definition of serializable job/context payloads
Status: Idea
Theme: Persist workflow progress so a restarted run can skip completed step groups.
Long-running workflows (onboarding, fulfillment, provisioning) often span multiple processes or survive worker crashes. Today a workflow is a single Pipeline run on one context in one process:
- If the process dies after step 3 of 5, a retry typically re-runs from the start unless the app manually tracks state in the database.
- Parallel groups and rollback semantics make ad hoc “save state in
#work” easy to get wrong.
Checkpointing that:
- Identifies the workflow run with a stable run id (from context,
idempotent_by, or explicitcheckpoint_by—design TBD). - After each execution group completes successfully (respecting
if:/unless:skips), dumps a versioned snapshot of workflowcontext(plus resume metadata: completed group ids, skip/fail markers) via the serializers registry intoCMDx::Storesor a pluggable backend. - On a subsequent
executewith the same run id, loads the blob through the same serializer (or fails gracefully if the format is unknown/corrupt), hydrates context, and skips groups already marked complete, then continues from the next pending group. - Handles failure without advancing the checkpoint; failed groups remain retryable.
- Defines behavior for parallel groups (checkpoint only after merger succeeds), conditional groups (record skipped vs completed distinctly), and rollback (whether checkpoint is cleared or branched).
class FulfillmentWorkflow < ApplicationTask
include CMDx::Workflow
idempotent_by :order_id # natural pairing: same order → same logical run
checkpoint true # or checkpoint_by: :order_id, ttl: 1.day, serializer: :json
task ValidateOrder
task ReserveInventory
tasks ChargePayment, CreateShipmentLabel, strategy: :parallel
task NotifyCustomer
end- Pairs with
idempotent_by: Duplicate job delivery can resume the same checkpointed run instead of duplicating side effects; docs should show recommended key shapes. - Serializers registry: Checkpoint blobs use the same envelope as cache (serializer id + version); large payloads may use a custom registered serializer (compression, external blob pointer).
- Store size and TTL: Allowlists of persistable context keys enforced at dump time; core may store pointers when the serializer returns a short reference string.
- Security: Checkpoint blobs may contain PII; encryption and namespace isolation are app concerns but the API should not surprise authors.
- Observability: Telemetry events for checkpoint write, resume, and skip-per-group aid debugging stuck workflows.
- Opt-in per workflow vs per-group
checkpoint: falsefor sensitive steps? - Migration when task classes change (envelope
format_versionvs per-workflowcheckpoint_version)? - Interaction with saga rollback—truncate checkpoint or fork a new run?
- Workflows — execution groups and pipeline
- Stores — “(eventually) workflow checkpoints”
CMDx::Pipeline— group boundaries for persistence hookscache_result(above) — shared store primitive patterns- Serializers (above) — dump/load contract for checkpoint blobs
- Add or refine a section with problem, intended function, and open questions—not only a one-line bullet.
- When prototyping, add a link to a PR or discussion at the top of that section.
- Ship user-facing behavior through docs + CHANGELOG; trim or archive roadmap text here once the feature is released.