ruby 113 lines · 3 tabs

Chaining Dependent GoodJob Jobs with a Callback-Scheduled Continuation

Shared by codesnips Aug 2026
3 tabs
class ExportWriter
  def initialize(export)
    @export = export
  end

  def append(rows)
    return if rows.empty?

    records = rows.map do |row|
      {
        export_id: @export.id,
        row_key: row_key_for(row),
        payload: serialize(row),
        created_at: Time.current,
        updated_at: Time.current
      }
    end

    ExportRow.transaction do
      ExportRow.upsert_all(
        records,
        unique_by: %i[export_id row_key]
      )

      @export.update!(
        cursor: rows.last.id,
        rows_written: @export.rows_written + rows.size
      )
    end
  end

  private

  def row_key_for(row)
    "#{row.class.name}:#{row.id}"
  end

  def serialize(row)
    row.slice(:id, :name, :email, :created_at)
  end
end
3 files · ruby Explain with highlit

This snippet shows how a long-running export is split into a chain of dependent background jobs, where each job schedules its own successor from an ActiveJob callback rather than looping inside a single monster job. The pattern keeps individual jobs short (so GoodJob can retry them cheaply, respect timeouts, and interleave with other work) while still processing an unbounded dataset to completion.

In ExportChunkJob, perform processes exactly one page of rows starting from cursor, writes them into the export via ExportWriter, and then decides whether more work remains. Crucially it does not enqueue the next job directly in perform; instead it stores the next cursor in @next_cursor and lets an after_perform callback enqueue the continuation. Doing the enqueue in after_perform means the follow-up is only scheduled once the current unit of work has fully succeeded — if perform raises, no successor is queued and GoodJob retries the same chunk, preserving at-least-once semantics without duplicating the chain.

The continue? guard and finalize! split the terminal case from the recursive case, so the last job flips the export to completed and notifies the user exactly once. Idempotency matters because retries can re-run a chunk: ExportWriter#append in ExportWriter upserts on a deterministic row_key, so replaying the same cursor never produces duplicate output rows. The writer also advances export.cursor transactionally, which lets a resumed chain pick up exactly where it left off.

ExportsController kicks off the chain from create by enqueuing the first ExportChunkJob with cursor: 0, and exposes show for polling progress. Because the state lives in the Export row, the controller and jobs share a single source of truth.

The trade-off of this approach is latency: a chain of N chunks incurs N enqueue round-trips and is slower end-to-end than one tight loop. The payoff is resilience and fairness — no single job monopolizes a worker, memory stays flat per chunk, and a crash mid-export costs only one chunk of redo. A common pitfall is enqueuing the successor inside perform before the transaction commits; scheduling from after_perform avoids that race. This continuation style is worth reaching for whenever a task is naturally paginated and must survive restarts.


Related snips

Share this code

Here's the card — post it anywhere.

Chaining Dependent GoodJob Jobs with a Callback-Scheduled Continuation — share card
Link copied