---
title: 'Background Jobs'
source: 'https://academia.sh/en/courses/mvc-framework/background-jobs'
course: 'The Model–View–Controller Framework'
language: en
updated: '2026-10-21T05:03:40+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines job, queue and worker as separate from the response of a request'
    - 'Measures with the stay rule how many requests the job record in the queue stays for'
    - 'Classifies how the stay of the record becomes shorter when the worker drains the queue'
    - 'Shows that the arguments of a job and its result are in different stays'
---

# Background Jobs

Measures how many requests the record of a queued job stays for, what happens when the worker drains it, and why the result of the job is not in the same stay.

The previous lesson measured that the identity stays in the session and that the permission decision is produced again on every request. Both values were computed inside a request, while that request waited for its response. But not every piece of work an application does has to wait for the response. Sending an invoice, shrinking an image or preparing a report can be done after the user has received the response. The question of this lesson is this: where does a job that will run after the request is over wait until then, and for how many requests does that wait last?

A **background job** is the name given to work that is left for later so that the response of a request is not delayed. Leaving work for later means turning it into a record to be done: a record made of a job name and arguments is put into a **queue**. A **worker** then drains the queue later and runs the job in the record. M12/K04 also showed a part that did not run in this request but was deferred; what was measured there was whether code ran. Here we leave the code and measure the value: even if it does not run in this request, for how many requests does the record in the queue live? The measuring axis is the course's axis: **how many requests does this value stay for?** Recall the four stays: **this request**, **next request**, **session** and **persistent**.

## Core

The measurements again rest on the same small request loop. The file is the course core and is given as it is. In this core the queue is an array inside `App#store`; in a real application the queue lives in an external store, and the core does not model it, it only carries its behavior.

```ruby
# stay.rb — a small request loop and a helper that classifies how many requests a value stays for

# Flash: a value written in this request is carried to the next request only.
class Flash
  def initialize(incoming)
    @incoming = incoming
    @fresh = {}
  end

  def [](key) = @fresh.fetch(key) { @incoming[key] }

  def []=(key, value)
    @fresh[key] = value
  end

  def passed_on = @fresh
end

# Every request gets a new context: params, flash and the request store start empty each time.
class Context
  attr_reader :app, :params, :session, :flash, :request_store

  def initialize(app, session)
    @app = app
    @params = {}
    @session = session
    @flash = Flash.new(session.delete(:flash) || {})
    @request_store = {}
  end
end

# The application store is the same across all requests and all clients;
# the session is chosen per client.
class App
  attr_reader :store

  def initialize
    @sessions = Hash.new { |h, client| h[client] = {} }
    @store = { products: {}, schema: { products: %i[name price] }, templates: {},
               queue: [], cache: {}, counter: 0 }
  end

  def request(client)
    context = Context.new(self, @sessions[client])
    result = yield context
    context.session[:flash] = context.flash.passed_on unless context.flash.passed_on.empty?
    result
  end
end

# Rule: request 1 (client A) writes the value; requests 2 and 3 (A) and request 4 (B) read it.
# If B sees it, persistent; if A's third request sees it, session; if only the second
# request sees it, next; if none sees it, this_request.
def stay(write, read)
  app = App.new
  first = app.request("A") { |b| write.call(b); read.call(b) }
  raise ArgumentError, "value could not be read in the request that wrote it" if first.nil?

  seen = [["A", 2], ["A", 3], ["B", 4]].map do |client, order|
    app.request(client) { |b| read.call(b) }.nil? ? "--" : "#{client}#{order}"
  end
  kind = if seen[2] != "--" then "persistent"
         elsif seen[1] != "--" then "session"
         elsif seen[0] != "--" then "next"
         else "this_request"
         end
  [kind, ["A1", *seen].join(" "), first]
end
```

This block is source; the file is written to disk and pulled in with `require_relative`. The core is not a framework but a model of one. The rule reads how the value behaves, not where it is stored; the measurements are meaningful within a single application process.

## Queue, Job and Worker

A job is a name and a list of arguments. Enqueuing appends this pair to the end of the queue array; the worker reads from the front, finds the code to run from the name of the job and calls it with the arguments. The excerpt below defines these three parts. The job table `JOBS` is a hash that turns a name into code.

```ruby
# jobs.rb — enqueue, job table and worker
require_relative "stay"

def enqueue(b, job_name, *args) = b.app.store[:queue] << [job_name, args]

JOBS = { "send_invoice" => ->(invoice_id) { "invoice #{invoice_id} sent" } }

def run_worker(b)
  queue = b.app.store[:queue]
  results = []
  until queue.empty?
    job_name, args = queue.shift
    results << JOBS.fetch(job_name).call(*args)
  end
  results
end

def report(name, kind, evidence, value)
  printf("%-14s%-14s%-13s%s\n", name, kind, evidence, value.inspect)
end
```

This block is source as well. `run_worker` takes the record at the front until the queue is empty, looks it up in the `JOBS` table and collects the results in an array. First, see that we run the job outside the request, not inside it. A request only leaves the record and gives its response; the record waits in the queue until the worker, which runs in a separate call.

```ruby
# deferral.rb — the request only leaves the record, the worker runs the job
require_relative "jobs"

app = App.new
response = app.request("A") { |b| enqueue(b, "send_invoice", 1); "response ready" }
p response
p app.store[:queue].size
p app.request("worker") { |b| run_worker(b) }
p app.store[:queue].size
```
```
"response ready"
1
["invoice 1 sent"]
0
```

The request gave its response and one record remained in the queue. The worker took the record out in a separate call and ran the job; the queue was empty again. This gap between the response and the job is the basic reason for a background job. **AS4.** In the core the worker is a method called by hand after the request. A real worker is often a separate process, and when it will run is not decided by the measurement; this timing is not modeled here.

## The Stay of a Record in the Queue

The first observation is the record of the enqueued job. The `write` lambda leaves the record, and the `read` lambda returns the first record in the queue.

```ruby
# queue.rb — how many requests does the queued job stay for
require_relative "jobs"

write = ->(b) { enqueue(b, "send_invoice", 1) }
read = ->(b) { b.app.store[:queue].first }
report("queued job", *stay(write, read))
```
```
queued job    persistent    A1 A2 A3 B4  ["send_invoice", [1]]
```

The record appeared in all four requests, including the request of B. This is the **persistent** stay. The difference from the session is clear: the record is not bound to a client, it is in the shared store of the application. The invoice job that one client's request enqueued is also seen by the process that handles the request of another client; that is the very task of a worker. In M12/K04 the record was in the "deferred" class, that is, it did not run in this request. Here that deferral is the same thing as the record going beyond the request: a record that does not run waits in the shared store of the application until the worker arrives.

The order of the queue is also part of this stay. Take two jobs in turn and look at the queue.

```ruby
# order.rb — two jobs stay in the order they entered the queue
require_relative "jobs"

app = App.new
app.request("A") do |b|
  enqueue(b, "x")
  enqueue(b, "y", 2)
end
p app.store[:queue]
```
```
[["x", []], ["y", [2]]]
```

A job without arguments carries an empty list, and the job `y` carries the value `2`. The order was preserved: the worker takes `x` first and `y` after it. **AS5.** It was measured with only two jobs; anything that could break the order with many jobs, for example several workers reading the queue at the same time, is not in this core.

## A Record Runs Once

The queue holds a record; the worker takes it out. Taking it out deletes the record from the queue. This has a consequence: calling the same worker a second time does not run the same job again, because no record is left to run. To measure it, a job is enqueued and the worker is called in two requests in a row.

```ruby
# repeat.rb — how many times does the job run if the worker is called twice
require_relative "jobs"

app = App.new
app.request("A") { |b| enqueue(b, "send_invoice", 1) }
p app.request("worker1") { |b| run_worker(b) }
p app.request("worker2") { |b| run_worker(b) }
p app.store[:queue].size
```
```
["invoice 1 sent"]
[]
0
```

The first call ran the job, the second returned an empty list. The job running once is the direct consequence of the record leaving the queue; no separate "this job was done" mark was needed for it. This ties the stay of the job to its presence in the queue: the moment the record leaves the queue, the job no longer exists for this core. Code that wants to learn later that a job ran cannot find this information in the queue. This is the result of this single-worker core; the delivery guarantees of real queue systems are examined separately.

## The Stay of an Argument Placed in the Queue

The argument of an enqueued job is persistent together with the record. But does the record hold a copy of the argument, or the argument itself? The question matters, because the code that makes the request can change the argument after putting it in the queue. The measurement goes like this: a list is given to the queue as an argument, then the same list is changed and the record in the queue is looked at.

```ruby
# argument.rb — what does the record see if the queued argument is changed afterwards
require_relative "jobs"

app = App.new
list = [1]
app.request("A") { |b| enqueue(b, "send_invoice", list) }
list << 2
p app.store[:queue]
p app.store[:queue].first[1].first.equal?(list)
```
```
[["send_invoice", [[1, 2]]]]
true
```

The record held the list itself, not a copy of it. A change made after the request ended reached the job in the queue as well. Therefore the argument a job sees when it runs may not be the value it had when it was enqueued. In terms of the stay, this means: the record is persistent, but the object inside the record is not a persistent copy but a shared object, and every place that holds it sees its changes. In this core, code that wants to fix the argument of a job copies the value before enqueuing it.

## An Unknown Job Name

The worker looks up the code to run from the name of the job in the `JOBS` table. A name that is not in the table can enter the queue; the queue does not check names. The error appears when the worker reaches that record. The measurement uses two records: the first a name that is not in the table, the second a valid job. Only the class of the exception is printed.

```ruby
# unknown_job.rb — how a job name that is not in the table stops the worker
require_relative "jobs"

app = App.new
app.request("A") do |b|
  enqueue(b, "unknown")
  enqueue(b, "send_invoice", 1)
end
begin
  app.request("worker") { |b| run_worker(b) }
rescue => e
  puts e.class
end
p app.store[:queue]
```
```
KeyError
[["send_invoice", [1]]]
```

The worker stopped at the first record. The record had been taken out of the queue before the error, so the faulty record did not stay in the queue; the second record is still in place because it was never read. In this core a faulty job loses its own record and delays the jobs after it. This core has no retry mechanism; nothing was written to bring the lost record back. In terms of the stay, this is a limit of the persistence of the record: the record is persistent while it is in the queue, but its presence in the queue ends the moment it passes into the worker's hands. A job leaving the queue does not mean the job has finished; the queue knows only the moment of removal, not whether the job finished successfully. A core that wants to retry on error has to build a separate record scheme that waits for the end of the job instead of deleting the record from the queue before running it; in real queue systems this mechanism can be configured.

## When the Worker Drains the Queue

A persistent stay is not endless. A worker that drains the record makes the stay shorter. To measure this, the `read` lambda keeps the call count: in the second request, after looking at the record, it runs the worker and drains the queue.

```ruby
# drain.rb — what happens to the stay if the worker drains the queue in the second request
require_relative "jobs"

write = ->(b) { enqueue(b, "send_invoice", 1) }
calls = 0
read = lambda do |b|
  calls += 1
  record = b.app.store[:queue].first
  run_worker(b) if calls == 2
  record
end
report("drained", *stay(write, read))
```
```
drained       next          A1 A2 -- --  ["send_invoice", [1]]
```

The rule classified this as **next request**: the record appeared in the first and second requests, and after the worker ran it was gone in the third and fourth. The same record without a worker was **persistent**. What determines the stay is not the place where it is stored but the behavior: if a process drains the queue, the record stays until the process that handles it arrives. **AS6.** The draining was arranged in the second request. In reality, the request in which the worker runs decides how many requests can see the record, and that number depends on the length of the queue and the speed of the worker.

## Storing the Result in the Worker's Request

In the bounding measurement below the result stays in the request store. But does the stay change if we write the result to the shared store of the application inside the worker's request? The measurement uses the same enqueuing and worker call as before; the only difference is where the result is written.

```ruby
# result_store.rb — how writing the result to the shared store changes the stay
require_relative "jobs"

write = lambda do |b|
  enqueue(b, "send_invoice", 1)
  b.app.store[:result] = run_worker(b).first
end
read = ->(b) { b.app.store[:result] }
report("stored result", *stay(write, read))
```
```
stored result persistent    A1 A2 A3 B4  "invoice 1 sent"
```

This time the result is in the **persistent** stay: it appeared in all four requests. Only the place it is written changed. How many requests the result stays for is decided not by the worker but by the code that writes the result. This is the direct counterpart of the result in the next section: the queue does not carry the "what happened" information, and code that wants to carry it writes it explicitly to the shared store of the application.

## The Bounding Measurement

So far, what was persistent was the **record** of the job, that is, its name and arguments. But where is the **result** of the job? The lambda in the `JOBS` table returns a string, but `run_worker` only collects that string in an array. Measure the result with the same stay rule: the job is enqueued and run by the worker in the same request, and the result is put into the request store of that request.

```ruby
# result.rb — how many requests does the result of the job stay for
require_relative "jobs"

write = lambda do |b|
  enqueue(b, "send_invoice", 1)
  b.request_store[:result] = run_worker(b).first
end
read = ->(b) { b.request_store[:result] }
report("job result", *stay(write, read))
```
```
job result    this_request  A1 -- -- --  "invoice 1 sent"
```

The result is in the **this request** stay. The argument of the job was persistent, its result is not. The reason is simple: the worker returns the result, but writing the returned value somewhere is the job's own task. If the result is to be visible in later requests, the job has to store it itself. In this core the queue carries only the "what is to be done" information, not the "what happened" information. This distinction can lead to a mistaken assumption: throwing a job into the queue and expecting to read its result from the queue later means looking for a value the queue does not carry.

## Summary

- A background job is a record made of the name and arguments of work left for later so that the response is not delayed; the queue holds the record, the worker drains it and runs it.
- The record of an enqueued job is in the shared store of the application, independent of the client; the stay of this lesson is **persistent**.
- The queue holds jobs in the order they entered. When the worker runs, the record leaves the queue and runs once; a second worker call returns empty. If the worker drains the record in the second request, the rule sees it as **next request**.
- The queue holds the argument itself, not a copy; a later change reaches the job. An unknown job name stops the worker with `KeyError`, and the faulty record has already left the queue.
- The argument of a job is persistent, its result is not; storing the result is the job's own work.
- The result depends on where it is written: if written to the shared store it stays **persistent**, if written to the request store it stays **this request**.

## Next Step

The queue makes a record persistent in order to defer a job. What happens if we want to make a result persistent instead, so that the same computation is not repeated on every request? The next lesson measures caching: a fragment being produced once and served to later requests, and how the resulting staleness is managed.
