ReactiveDag.ResumptionWorker (reactive_dag v0.17.0-rc.65)

Copy Markdown View Source

The Oban job that resumes a suspended cascade — and the one place expensive work runs.

A cascade that reached something it could not finish inline recorded a suspension and committed. This picks that up: it reads every suspension at the stopping point, does the work ONCE, writes the result, cascades onward from that write, and discharges exactly the rows it read.

The shape, and why each part is where it is

1. suspensions = Suspension.at(point)     one read
   []  ->  exit. A duplicate job, and an ordinary outcome.

2. ids = their ids                        remembered BEFORE the work

3. recompute                              OUTSIDE any transaction
                                             this is the whole point

4. transaction:
     write the result
     cascade onward to the next stop
     discharge(ids)                       by id, never by point

Step 3 outside a transaction is the redesign. The drain held one open across the recompute, so a nine-minute extraction sat inside it until Neon closed the connection at five. Here nothing is open while the model runs.

Step 4's discharge(ids) is what makes the append-only table safe. A change arriving during step 3 writes a new suspension at the same point; deleting by point would take it too, discarding a change nobody observed. Deleting by the ids read in step 1 cannot: the new row is not in the list, and the next job picks it up.

The two reasons differ only in step 3

:expensive runs the recompute. :approval skips it — the write already happened, the gate merely opened, so there is nothing to compute and the job only propagates. Same read, same discharge, same everything else, which is the argument for one table and one worker rather than two of each.

Uniqueness IS :infinity here

Unlike ReactiveDag.CascadeWorker, whose args name a change and must not coalesce forever. These args name a STOPPING POINT. A second change to the same point writes a second suspension row but must not enqueue a second job: the pending job will read both when it runs.

That is the whole coalescing story, and it is why the args deliberately carry no version and no suspension id. A job queued at 12:00 and run at 12:05 must act on what is true at 12:05.

It may run twice, and that is safe

There is no lock. At-least-once delivery over idempotent work is a stronger position than exactly-once scheduling, because it degrades gracefully instead of depending on job-state bookkeeping staying correct across a node death. Three things make the second run harmless:

  • a duplicate finds no suspensions and exits at step 1;
  • payload writes upsert, so two identical writes are one write;
  • an expensive op is expected to be CONTENT-ADDRESSED — keyed on its input's digest, checked before the spend — so a duplicate is a cache hit rather than a repeated bill.

That last one is a host obligation, not a library guarantee. A node declared suspends whose op derives its cache key from anything other than its inputs breaks it silently, and the symptom is a bill.

Summary

Functions

Enqueue a resumption for one stopping point.

Functions

enqueue(point, opts \\ [])

@spec enqueue(
  map(),
  keyword()
) :: {:ok, Oban.Job.t()} | {:error, term()}

Enqueue a resumption for one stopping point.

Coalescing is the point's, not Oban's: several suspensions at one point produce one job, because the args name the point rather than any of them.