<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Artur Pañach</title>
    <description>The latest articles on DEV Community by Artur Pañach (@artur_paach_4b9aadd3b8ed).</description>
    <link>https://dev.to/artur_paach_4b9aadd3b8ed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3967226%2Fa8b9086b-f2b9-4cd3-adcb-22058831bde7.png</url>
      <title>DEV Community: Artur Pañach</title>
      <link>https://dev.to/artur_paach_4b9aadd3b8ed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/artur_paach_4b9aadd3b8ed"/>
    <language>en</language>
    <item>
      <title>Ruby Reactor Now Has Middlewares and OpenTelemetry — Here's Why That Matters</title>
      <dc:creator>Artur Pañach</dc:creator>
      <pubDate>Wed, 17 Jun 2026 00:42:49 +0000</pubDate>
      <link>https://dev.to/artur_paach_4b9aadd3b8ed/ruby-reactor-now-has-middlewares-and-opentelemetry-heres-why-that-matters-h9e</link>
      <guid>https://dev.to/artur_paach_4b9aadd3b8ed/ruby-reactor-now-has-middlewares-and-opentelemetry-heres-why-that-matters-h9e</guid>
      <description>&lt;p&gt;You've built a checkout reactor that reserves inventory, charges a card, generates a shipping label, and sends a confirmation email. It runs through Sidekiq. When something fails, compensation logic rolls it back. It works.&lt;/p&gt;

&lt;p&gt;Then your team asks: &lt;em&gt;"How many checkouts failed this week? Which step? How long does the charge step take at p99? Can we see a trace through the entire system?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Before v0.5.0, you'd need to add logging calls to every step, build a custom Sidekiq middleware, and figure out how to correlate traces across async job boundaries. Now it's one line of config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enter Middlewares
&lt;/h2&gt;

&lt;p&gt;Ruby Reactor 0.5.0 introduces a &lt;strong&gt;middleware pipeline&lt;/strong&gt; — the same pattern that powers Rack, but designed for saga execution. A middleware is a plain Ruby object that hooks into the reactor lifecycle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TimingMiddleware&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;RubyReactor&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Middleware&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;
    &lt;span class="vi"&gt;@started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_start_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="vi"&gt;@started&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clock_gettime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Process&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CLOCK_MONOTONIC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;on_complete_step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="vi"&gt;@started&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;
    &lt;span class="n"&gt;elapsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clock_gettime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Process&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;CLOCK_MONOTONIC&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;
    &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"step &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;step_name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; took &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This middleware times every step. Register it globally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RubyReactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;middlewares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;TimingMiddleware&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every reactor — every checkout, every refund, every data import — gets step-level timing, for free.&lt;/p&gt;

&lt;h3&gt;
  
  
  The full lifecycle (20+ events)
&lt;/h3&gt;

&lt;p&gt;Middlewares can observe the complete execution lifecycle:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Reactor&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on_start_reactor&lt;/code&gt;, &lt;code&gt;on_complete_reactor&lt;/code&gt;, &lt;code&gt;on_failed_reactor&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on_start_step&lt;/code&gt;, &lt;code&gt;on_complete_step&lt;/code&gt;, &lt;code&gt;on_failed_step&lt;/code&gt;, &lt;code&gt;on_retry_attempt&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compensation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on_start_compensation&lt;/code&gt;, &lt;code&gt;on_complete_compensation&lt;/code&gt;, &lt;code&gt;on_failed_compensation&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Undo&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on_start_undo&lt;/code&gt;, &lt;code&gt;on_complete_undo&lt;/code&gt;, &lt;code&gt;on_failed_undo&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coordination&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;on_lock_acquired&lt;/code&gt;, &lt;code&gt;on_lock_failed&lt;/code&gt;, &lt;code&gt;on_semaphore_acquired&lt;/code&gt;, …&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Async&lt;/td&gt;
&lt;td&gt;&lt;code&gt;on_before_async_enqueue&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can build &lt;strong&gt;custom logging, metrics, audit trails, and alerting&lt;/strong&gt; — all without touching your reactor code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this matters
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Separation of concerns&lt;/strong&gt; — business logic lives in steps, observability lives in middlewares&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composability&lt;/strong&gt; — stack multiple middlewares (timing + logging + alerting) like Lego&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety&lt;/strong&gt; — middleware errors are caught and logged, never crash your reactor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-reactor override&lt;/strong&gt; — declare &lt;code&gt;middleware AuditMiddleware, level: :info&lt;/code&gt; on specific reactors&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  OpenTelemetry: Distributed Tracing, Zero Config
&lt;/h2&gt;

&lt;p&gt;The most powerful middleware ships built-in: &lt;code&gt;RubyReactor::OpenTelemetry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One line of configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;RubyReactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;middlewares&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;RubyReactor&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OpenTelemetry&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Every reactor run becomes a full OpenTelemetry trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CheckoutReactor (span)
├── step.reserve_inventory (span)
├── step.charge_card (span)
│   └── step.charge_card.enqueue (span) ← async hand-off
├── step.generate_label (span)
└── step.send_confirmation (span)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a step fails and compensation runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── step.charge_card (span, ERROR)
├── compensate.charge_card (span)
└── undo.reserve_inventory (span)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Async boundaries? Handled.
&lt;/h3&gt;

&lt;p&gt;When a reactor hands work to a Sidekiq worker — an async step, a retry, or a map element — the middleware automatically injects the trace context into the serialized payload. The worker picks it up and continues the trace. The result is a &lt;strong&gt;single, connected trace across processes&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sensitive data? Redacted.
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LoginReactor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;RubyReactor&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Reactor&lt;/span&gt;
  &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="ss"&gt;:email&lt;/span&gt;
  &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="ss"&gt;:password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;redact: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# shows as [REDACTED] in traces&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No secrets leaking into your observability platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  Any exporter works
&lt;/h3&gt;

&lt;p&gt;Ruby Reactor produces OpenTelemetry spans. Where they go is up to your OTel SDK configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Datadog, Honeycomb, Jaeger, Grafana — pick your exporter&lt;/span&gt;
&lt;span class="no"&gt;OpenTelemetry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SDK&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;service_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"checkout_service"&lt;/span&gt;
  &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_span_processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="no"&gt;OpenTelemetry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SDK&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Trace&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Export&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;BatchSpanProcessor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="no"&gt;OpenTelemetry&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Exporter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;OTLP&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Exporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What else is new in the 0.5.x line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;v0.5.1 — Enhanced Validations&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;input&lt;/code&gt; DSL got streamlined. Cleaner syntax for dry-validation schemas, better error messages when validation fails.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;v0.5.2 — Nonce Lock&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;with_ordered_lock&lt;/code&gt; assigns a monotonically increasing nonce to each enqueued execution. Guarantees exactly-once processing with strict FIFO ordering — perfect for ledgers and payment processing where order matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enhanced Testing&lt;/strong&gt;&lt;br&gt;
New RSpec matchers for coordination primitives: &lt;code&gt;be_skipped&lt;/code&gt;, &lt;code&gt;be_locked&lt;/code&gt;, &lt;code&gt;have_ordered_lock_next&lt;/code&gt;, &lt;code&gt;have_ordered_lock_in_flight&lt;/code&gt;, &lt;code&gt;be_ordered_lock_drained&lt;/code&gt;. Plus &lt;code&gt;sidekiq_helpers&lt;/code&gt; and &lt;code&gt;storage_reset&lt;/code&gt; utilities.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for production Ruby apps
&lt;/h2&gt;

&lt;p&gt;Observability is no longer optional. If you're running business workflows in production, you need to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which steps are slow? (timing middleware)&lt;/li&gt;
&lt;li&gt;What's failing and why? (tracing + error events)&lt;/li&gt;
&lt;li&gt;Is the system healthy? (metrics middleware)&lt;/li&gt;
&lt;li&gt;Who changed what and when? (audit middleware)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ruby Reactor 0.5.x gives you this without pulling in external workflow engines, without custom Sidekiq middleware, and without scattering logging calls through your business logic.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Gem:&lt;/strong&gt; &lt;code&gt;gem 'ruby_reactor', '~&amp;gt; 0.5'&lt;/code&gt;&lt;br&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/arturictus/ruby_reactor" rel="noopener noreferrer"&gt;github.com/arturictus/ruby_reactor&lt;/a&gt;&lt;br&gt;
&lt;strong&gt;Middleware docs:&lt;/strong&gt; &lt;a href="https://github.com/arturictus/ruby_reactor/blob/main/documentation/middlewares.md" rel="noopener noreferrer"&gt;documentation/middlewares.md&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you've been putting off observability for your Sidekiq workflows, there's no longer an excuse. ⭐ the repo and give it a try.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>sidekiq</category>
    </item>
    <item>
      <title>How to Pause and Resume Ruby Workflows Mid-Flight</title>
      <dc:creator>Artur Pañach</dc:creator>
      <pubDate>Wed, 03 Jun 2026 23:20:31 +0000</pubDate>
      <link>https://dev.to/artur_paach_4b9aadd3b8ed/how-to-pause-and-resume-ruby-workflows-mid-flight-4m93</link>
      <guid>https://dev.to/artur_paach_4b9aadd3b8ed/how-to-pause-and-resume-ruby-workflows-mid-flight-4m93</guid>
      <description>&lt;h3&gt;
  
  
  Ruby Reactor 0.4.0 introduces interrupts — the first native saga pause/resume mechanism in the Ruby ecosystem.
&lt;/h3&gt;

&lt;p&gt;You've built a checkout flow. The user submits their order, you reserve inventory, charge their card, and generate a shipping label. Everything's a Sidekiq job. It works.&lt;/p&gt;

&lt;p&gt;Then you get the requirement: &lt;em&gt;"After payment, wait for the fraud detection webhook before shipping."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Suddenly your clean Sidekiq pipeline needs to stop, hold state for an indeterminate amount of time, and resume when Stripe calls back. Your options are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Polling loop&lt;/strong&gt; (wasteful, fragile)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split into two jobs&lt;/strong&gt; (now you have to manage state yourself)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Temporal / AWS Step Functions&lt;/strong&gt; (massive infrastructure lift)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these feel right. This is where Ruby Reactor interrupts come in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is an Interrupt?
&lt;/h2&gt;

&lt;p&gt;An interrupt is a special step that &lt;strong&gt;pauses&lt;/strong&gt; reactor execution mid-flight, persists the entire execution state to Redis, and waits for an external signal to resume. While paused, no Sidekiq jobs are running. No polling. No wasted resources.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FraudCheckReactor&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;RubyReactor&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Reactor&lt;/span&gt;
  &lt;span class="n"&gt;input&lt;/span&gt; &lt;span class="ss"&gt;:order_id&lt;/span&gt;

  &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="ss"&gt;:reserve_inventory&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;argument&lt;/span&gt; &lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reserve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;undo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="ss"&gt;:charge_card&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;argument&lt;/span&gt; &lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;undo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refund&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="c1"&gt;# 👇 Pause here until Stripe calls back&lt;/span&gt;
  &lt;span class="n"&gt;interrupt&lt;/span&gt; &lt;span class="ss"&gt;:wait_for_fraud_check&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;wait_for&lt;/span&gt; &lt;span class="ss"&gt;:charge_card&lt;/span&gt;
    &lt;span class="n"&gt;correlation_id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="s2"&gt;"order-&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;strategy: :active&lt;/span&gt;
    &lt;span class="n"&gt;validate&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;included_in?: &lt;/span&gt;&lt;span class="sx"&gt;%w[passed failed]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;max_attempts&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="ss"&gt;:ship_order&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;argument&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:wait_for_fraud_check&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"passed"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="no"&gt;Shipping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_label&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="no"&gt;Failure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Fraud check failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="n"&gt;undo&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;_err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="no"&gt;Shipping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:order_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="n"&gt;returns&lt;/span&gt; &lt;span class="ss"&gt;:ship_order&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Start the reactor:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;execution&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;FraudCheckReactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;order_id: &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;execution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;  &lt;span class="c1"&gt;# =&amp;gt; :paused&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reactor runs &lt;code&gt;reserve_inventory&lt;/code&gt; → &lt;code&gt;charge_card&lt;/code&gt; → then pauses at &lt;code&gt;wait_for_fraud_check&lt;/code&gt;. Everything before the interrupt is committed. The execution state is serialized to Redis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Resume when the webhook arrives:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In your Stripe webhook controller&lt;/span&gt;
&lt;span class="no"&gt;FraudCheckReactor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;continue_by_correlation_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="ss"&gt;correlation_id: &lt;/span&gt;&lt;span class="s2"&gt;"order-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="ss"&gt;payload: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"passed"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="ss"&gt;step_name: :wait_for_fraud_check&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reactor wakes up, validates the payload (using the &lt;code&gt;validate&lt;/code&gt; schema), feeds the payload as the interrupt's result, and continues to &lt;code&gt;ship_order&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Matters
&lt;/h2&gt;

&lt;p&gt;The interrupt pattern solves a class of problems that previously forced Ruby developers into bad choices:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Without interrupts&lt;/th&gt;
&lt;th&gt;With interrupts&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Polling loops that burn DB/Redis&lt;/td&gt;
&lt;td&gt;Zero resource usage while waiting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual state management across jobs&lt;/td&gt;
&lt;td&gt;Reactor handles all state persistence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hard-to-debug split job chains&lt;/td&gt;
&lt;td&gt;Single reactor definition = single mental model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No timeout handling (orphaned states)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;timeout&lt;/code&gt; with &lt;code&gt;:active&lt;/code&gt; or &lt;code&gt;:lazy&lt;/code&gt; strategies&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No validation on resume payloads&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;validate&lt;/code&gt; block validates incoming data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Real-World Use Cases
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Webhook-driven workflows:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripe payment confirmation → interrupt → continue on &lt;code&gt;payment_intent.succeeded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;External KYC provider → interrupt → continue on verification complete&lt;/li&gt;
&lt;li&gt;Async report generation → interrupt → continue when report URL is ready&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Human-in-the-loop:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manager approval for large transactions&lt;/li&gt;
&lt;li&gt;Customer confirmation for subscription changes&lt;/li&gt;
&lt;li&gt;Support agent review for flagged accounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Long-running external jobs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video transcoding (submit → interrupt → continue on complete)&lt;/li&gt;
&lt;li&gt;ML model training (kick off → interrupt → continue on model ready)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Comparison: How Others Handle This
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Pause/Resume&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ruby Reactor 0.4.0&lt;/td&gt;
&lt;td&gt;✅ Built-in &lt;code&gt;interrupt&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Correlation ID, Redis state, timeout&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dry-transaction&lt;/td&gt;
&lt;td&gt;❌ No support&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trailblazer&lt;/td&gt;
&lt;td&gt;❌ Manual only&lt;/td&gt;
&lt;td&gt;Custom state machine required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporal (Go/Node)&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;td&gt;Signal-based, built into engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Step Functions&lt;/td&gt;
&lt;td&gt;✅ Native&lt;/td&gt;
&lt;td&gt;Task token, built into service&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ruby Reactor is the &lt;strong&gt;only Ruby-native library&lt;/strong&gt; that offers this pattern without pulling in external workflow engines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gem &lt;span class="nb"&gt;install &lt;/span&gt;ruby_reactor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'ruby_reactor'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'~&amp;gt; 0.4'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full interrupt docs: &lt;a href="https://github.com/arturictus/ruby_reactor/blob/main/documentation/interrupts.md" rel="noopener noreferrer"&gt;github.com/arturictus/ruby_reactor&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;If you're building workflows that need to pause and wait for external events, give Ruby Reactor a ⭐ and try it in your next Rails app.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Discussion questions: What's your current approach to webhook-driven workflows in Ruby? Polling? Split jobs? Let me know in the comments. 👇&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>sidekiq</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
