Skip to content

Make jobs cancelled due to a soft stop immediately available - #1290

Merged
brandur merged 1 commit into
masterfrom
brandur-soft-stopped-jobs-immediately-available
Aug 7, 2026
Merged

Make jobs cancelled due to a soft stop immediately available#1290
brandur merged 1 commit into
masterfrom
brandur-soft-stopped-jobs-immediately-available

Conversation

@brandur

@brandur brandur commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

While working on #1289, I realized that jobs which are "soft stopped"
via context cancellation are still prone to the same side effects as if
they errored in any other way:

  • Their number of attempts is incremented.
  • They may be discarded if reaching max attempts.
  • They'll have to wait to be retried according to retry policy.

This doesn't really seem right because these jobs didn't actually
misbehave in any way, but were rather just slow-to-run jobs that
couldn't finish cleanly inside the default stop allowance while a client
was restarting or being deployed.

The proper behavior should probably be more like a snooze. i.e. The soft
timeout cancellation doesn't count and the jobs get a chance to be
retried immediately. Here, make that change.

The proper event to emit from a client under this condition is a little
tricky because none of the existing EventKindJobCompleted,
EventKindJobFailed, or EventKindJobSnoozed are a good fit for it. We
instead introduce a new value for EventKindJobInterrupted which
describes this precise case and nothing else. Because existing event
stream integrations are required to react based on the existing set of
enum values, it's safe to add a new one for which isn't necessary to
react to (i.e. if a client does nothing about EventKindJobInterrupted
when it receives it, that's perfectly fine).

@brandur
brandur force-pushed the brandur-soft-stopped-jobs-immediately-available branch from 00b244b to 3c09064 Compare June 19, 2026 20:16
@brandur
brandur requested a review from bgentry June 19, 2026 20:16
@brandur

brandur commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

@bgentry Do you agree with the rationale for this one?

@brandur

brandur commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c090646e1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/jobexecutor/job_executor.go Outdated
}

if softStopped {
params := riverdriver.JobSetStateErrorAvailable(jobRow.ID, now, ptrutil.Ptr(max(jobRow.Attempt-1, 0)), errData, metadataUpdates)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep soft-stop records out of retry backoff

For jobs that are soft-stopped and then later fail for a real reason, this path resets attempt but still appends errData to errors. The default retry policy uses len(job.Errors)+1 rather than attempt, so a deployment stop still changes future retry behavior: the first genuine failure after one soft stop is treated like the second error and backs off for 16s instead of 1s, with repeated stops inflating it further. This means soft-stop cancellations still count for retry scheduling even though the attempt count is restored.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed! Changed this to be more like snooze and not persist the cancellation error at all.

@brandur
brandur force-pushed the brandur-soft-stopped-jobs-immediately-available branch from 3c09064 to 2e49010 Compare June 20, 2026 03:34

@bgentry bgentry left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the general direction makes some sense and it's something I've also pondered in the past, but there are some tradeoffs we should discuss before adopting it.

First, this path still appears to publish a job_failed event because the job is completed back into available state with Snoozed=false. That feels inconsistent with the new model where this cancellation is intentionally not counted as an error, leaves errors unchanged, and restores attempt. Users consuming events for alerting or metrics may see a “failed” event for a job that no longer has failure state/history to explain it.

Second, skipping ErrorHandler removes a user-visible observation/control point. That may be the right outcome if River-owned stop cancellation is not considered a job error, but some users may currently rely on the handler to record shutdown interruptions, cleanup failures, or partial-work cases where the worker returned ctx.Err() because it did not reach a safe checkpoint.

Third, this bypasses retry policy and max-attempt accounting entirely. That is the desired property for normal deploy/restart interruption, but it also means repeated stops can keep a context-sensitive long-running job alive forever with no backoff and no eventual discard. That's intentional I think, but it’s a real semantic tradeoff we should decide on.

@brandur

brandur commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@bgentry I started making some changes for this to potentially bypass event subscription, but thought about it a while, and had trouble coming up with a really good argument for doing so. Maybe it's a bit inconsistent with the failure not counting against retries, but maybe it isn't? Even if making the claim that it is, is there a good reason that it matters?

I wonder if we should have it still fire ErrorHandler and consider that good enough.

Third, this bypasses retry policy and max-attempt accounting entirely. That is the desired property for normal deploy/restart interruption, but it also means repeated stops can keep a context-sensitive long-running job alive forever with no backoff and no eventual discard. That's intentional I think, but it’s a real semantic tradeoff we should decide on.

The only way for a job to stay alive forever this way is if the restarts were constant and went on forever. This might be possible if you had restart on a cron job or something like that, but that feels like pretty clear user error.

The alternative seems worse to me — you could have a job that goes through a brief but extremely tumultuous period in production (i.e. a whole bunch of restarts happen in a short period due to a bug or something), and then you'd lose them because they hit their max retries.

What do you think?

@bgentry

bgentry commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

I think I'm mainly hung up on "job failed" not being quite the right label given it didn't actually fail and is merely being rescheduled due to an unrelated shutdown. Maybe it warrants a new enum value specific to this scenario?

Other than that I think I'm good with this because it seems preferable to the status quo.

@brandur
brandur force-pushed the brandur-soft-stopped-jobs-immediately-available branch 3 times, most recently from 4e5bfe7 to 5d5d3fa Compare August 3, 2026 20:40
@brandur
brandur requested a review from bgentry August 3, 2026 21:09
@brandur

brandur commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

@bgentry Okay I like the enum addition idea. I also played around with this a bit with Codex, and it came up with the same concept independently here.

Latest push now includes a new EventKindJobInterrupted value which is emitted under this specific case where a job is parked during a restart. This should make for good forward compatibility on event subscribers, while also giving me a way to monitor for this condition if they want to.

At Codex's suggestion, also changed the Snoozed boolean in places like the JobCompleter over to a Reason enum so we don't end up with a series of boolean flags. This method seems a little cleaner and more sustainable.

Do you want to take another look and see what you think?

@brandur
brandur force-pushed the brandur-soft-stopped-jobs-immediately-available branch from 5d5d3fa to 837f68d Compare August 7, 2026 16:58
While working on #1289, I realized that jobs which are "soft stopped"
via context cancellation are still prone to the same side effects as if
they errored in any other way:

* Their number of attempts is incremented.
* They may be discarded if reaching max attempts.
* They'll have to wait to be retried according to retry policy.

This doesn't really seem right because these jobs didn't actually
misbehave in any way, but were rather just slow-to-run jobs that
couldn't finish cleanly inside the default stop allowance while a client
was restarting or being deployed.

The proper behavior should probably be more like a snooze. i.e. The soft
timeout cancellation doesn't count and the jobs get a chance to be
retried immediately. Here, make that change.

The proper event to emit from a client under this condition is a little
tricky because none of the existing `EventKindJobCompleted`,
`EventKindJobFailed`, or `EventKindJobSnoozed` are a good fit for it. We
instead introduce a new value for `EventKindJobInterrupted` which
describes this precise case and nothing else. Because existing event
stream integrations are required to react based on the existing set of
enum values, it's safe to add a new one for which isn't necessary to
react to (i.e. if a client does nothing about `EventKindJobInterrupted`
when it receives it, that's perfectly fine).
@brandur
brandur force-pushed the brandur-soft-stopped-jobs-immediately-available branch from 837f68d to 4dfafc7 Compare August 7, 2026 17:07
@brandur

brandur commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Thx!

@brandur
brandur merged commit fdf6cdf into master Aug 7, 2026
15 checks passed
@brandur
brandur deleted the brandur-soft-stopped-jobs-immediately-available branch August 7, 2026 19:33
@mitar

mitar commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

I was trying to rebase #1219 on top of this change and I am finding some behavior differences which makes it harder to rebase it and maybe I should not push "fixes" (or what I believe are fixes) into #1219. So I am making a comment here.

I asked Claude to write me reproduction comparing 0.43.0 version behavior (238776f commit) and current master (with this PR):

Case 1 — JobCompleteTx, then the worker returns an error

func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
      tx, _ := w.dbPool.Begin(ctx)
      defer tx.Rollback(ctx)

      river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
      tx.Commit(ctx)

      return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
}

Row ends completed. In 0.43.0 this posts job_completed event to client.Subscribe. In master this posts job_failed. Deterministic, 5/5 both sides.

Cases 2 and 3 both come from the same place: when a job is cancelled remotely while it is still running, client.JobCancel cannot finalize it directly, so it only writes cancel_attempted_at into the job's metadata. The state change happens later, in JobSetStateIfRunningMany, which rewrites whatever state the executor asked for. So the row lands in cancelled while the executor's own reason still describes what it asked for.

Case 2 — job cancelled remotely while running, then the worker returns an error

// Elsewhere, while the job is running. Only writes cancel_attempted_at into metadata.
client.JobCancel(ctx, jobID)

func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
      return errors.New("boom") // executor asks for retryable, SQL rewrites it to cancelled
}

This needs the next retry to land beyond SchedulerInterval, so the executor asks for retryable (JobSetStateErrorRetryable). I forced that with a Config.RetryPolicy returning now + 1h. With a short retry the executor asks for available instead, which the IN ('retryable','scheduled') condition does not match, so the row stays available and both versions agree on job_failed.

Row ends cancelled. In 0.43.0 this posts job_cancelled event to client.Subscribe. In master this posts job_failed. Deterministic, 5/5 both sides.

Case 3 — same remote cancel, but the worker snoozes

client.JobCancel(ctx, jobID)

func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
      return river.JobSnooze(time.Hour) // executor asks for scheduled, same SQL branch rewrites it to cancelled
}

Row ends cancelled. In 0.43.0 this posts job_snoozed, and in master it also posts job_snoozed, so this PR does not change this case. I am including it because it is the same underlying disagreement between the row and the event: 0.43.0 keyed the snooze event off an unconditional params.Snoozed bool, which is just as blind to the rewritten state as reason is. A subscriber is told the job was snoozed when it was actually cancelled.

Master is 5/5 job_snoozed here. 0.43.0 measured 4/5 job_snoozed and 1/5 job_cancelled, because of the race noted below.

Note on reproducing cases 2 and 3

client.JobCancel on a running job also cancels that job's context, so the context-cancellation path can win the race and produce job_cancelled on its own regardless of which branch above applies. That is where the 1/5 in case 3 comes from, so these two need a few runs to read reliably.

Question

So what about this? This looks like fundamental question which I attempted to answer in #1219, too. What to do if you call JobCompleteTx but then return an error. What should be the result? I think still completed. (And I modeled the same in #1219 for JobCancelTx and JobFailTx.)

This investigation opened another conflict between outside/remote client.JobCancel and what work returns. Should remote one win? Or what work returns? I think for consistency, it should be remote one. But that changes existing behavior of a released version (case 3).

brandur added a commit that referenced this pull request Aug 13, 2026
This one's aimed at fixing [1] in which for some sequences in workers,
we'd emit a surprising event based on the state that was actually
persisted to the database. This contract was also not stable, and
changed subtly with the introduction of #1219.

From [1], this is best illustrated by a short example where we error
after invoking `JobCompleteTx`:

    func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
          tx, _ := w.dbPool.Begin(ctx)
          defer tx.Rollback(ctx)

          river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
          tx.Commit(ctx)

          return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
    }

This used to emit a `job_completed`, but has changed in `master` to emit
a `job_failed`.

Here, we try to correct the emitted event and officially standardize it:

| Scenario | Persisted state | Previous event | New event |
|---|---:|---:|---:|
| `JobCompleteTx` commits, then worker returns an error | `completed` | `job_failed` | `job_completed` |
| Remote cancellation, then worker requests a retry | `cancelled` | `job_failed` | `job_cancelled` |
| Remote cancellation, then worker snoozes | `cancelled` | `job_snoozed` | `job_cancelled` |

Unambiguous persisted states always take precedence, though we retain
reason (added in #1219) to distinguish possible states of `available`,
which may be (1) an immediate retry after failure, (2) a short snooze,
or (3) an interruption caused by client shutdown.

[1] #1290 (comment)
brandur added a commit that referenced this pull request Aug 13, 2026
This one's aimed at fixing [1] in which for some sequences in workers,
we'd emit a surprising event based on the state that was actually
persisted to the database. This contract was also not stable, and
changed subtly with the introduction of #1219.

From [1], this is best illustrated by a short example where we error
after invoking `JobCompleteTx`:

    func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
          tx, _ := w.dbPool.Begin(ctx)
          defer tx.Rollback(ctx)

          river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
          tx.Commit(ctx)

          return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
    }

This used to emit a `job_completed`, but has changed in `master` to emit
a `job_failed`.

Here, we try to correct the emitted event and officially standardize it:

| Scenario | Persisted state | Previous event | New event |
|---|---:|---:|---:|
| `JobCompleteTx` commits, then worker returns an error | `completed` | `job_failed` | `job_completed` |
| Remote cancellation, then worker requests a retry | `cancelled` | `job_failed` | `job_cancelled` |
| Remote cancellation, then worker snoozes | `cancelled` | `job_snoozed` | `job_cancelled` |

Unambiguous persisted states always take precedence, though we retain
reason (added in #1219) to distinguish possible states of `available`,
which may be (1) an immediate retry after failure, (2) a short snooze,
or (3) an interruption caused by client shutdown.

[1] #1290 (comment)
brandur added a commit that referenced this pull request Aug 13, 2026
This one's aimed at fixing [1] in which for some sequences in workers,
we'd emit a surprising event based on the state that was actually
persisted to the database. This contract was also not stable, and
changed subtly with the introduction of #1219.

From [1], this is best illustrated by a short example where we error
after invoking `JobCompleteTx`:

    func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
          tx, _ := w.dbPool.Begin(ctx)
          defer tx.Rollback(ctx)

          river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
          tx.Commit(ctx)

          return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
    }

This used to emit a `job_completed`, but has changed in `master` to emit
a `job_failed`.

Here, we try to correct the emitted event and officially standardize it:

| Scenario | Persisted state | Previous event | New event |
|---|---:|---:|---:|
| `JobCompleteTx` commits, then worker returns an error | `completed` | `job_failed` | `job_completed` |
| Remote cancellation, then worker requests a retry | `cancelled` | `job_failed` | `job_cancelled` |
| Remote cancellation, then worker snoozes | `cancelled` | `job_snoozed` | `job_cancelled` |

Unambiguous persisted states always take precedence, though we retain
reason (added in #1219) to distinguish possible states of `available`,
which may be (1) an immediate retry after failure, (2) a short snooze,
or (3) an interruption caused by client shutdown.

[1] #1290 (comment)
@brandur

brandur commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @mitar. Yeah that definitely seems like a place that we should tighten up. Opened #1350 to resolve this.

brandur added a commit that referenced this pull request Aug 14, 2026
This one's aimed at fixing [1] in which for some sequences in workers,
we'd emit a surprising event based on the state that was actually
persisted to the database. This contract was also not stable, and
changed subtly with the introduction of #1290.

From [1], this is best illustrated by a short example where we error
after invoking `JobCompleteTx`:

    func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
          tx, _ := w.dbPool.Begin(ctx)
          defer tx.Rollback(ctx)

          river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
          tx.Commit(ctx)

          return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
    }

This used to emit a `job_completed`, but has changed in `master` to emit
a `job_failed`.

Here, we try to correct the emitted event and officially standardize it:

| Scenario | Persisted state | Previous event | New event |
|---|---:|---:|---:|
| `JobCompleteTx` commits, then worker returns an error | `completed` | `job_failed` | `job_completed` |
| Remote cancellation, then worker requests a retry | `cancelled` | `job_failed` | `job_cancelled` |
| Remote cancellation, then worker snoozes | `cancelled` | `job_snoozed` | `job_cancelled` |

Unambiguous persisted states always take precedence, though we retain
reason (added in #1290) to distinguish possible states of `available`,
which may be (1) an immediate retry after failure, (2) a short snooze,
or (3) an interruption caused by client shutdown.

[1] #1290 (comment)
brandur added a commit that referenced this pull request Aug 14, 2026
This one's aimed at fixing [1] in which for some sequences in workers,
we'd emit a surprising event based on the state that was actually
persisted to the database. This contract was also not stable, and
changed subtly with the introduction of #1290.

From [1], this is best illustrated by a short example where we error
after invoking `JobCompleteTx`:

    func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
          tx, _ := w.dbPool.Begin(ctx)
          defer tx.Rollback(ctx)

          river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
          tx.Commit(ctx)

          return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
    }

This used to emit a `job_completed`, but has changed in `master` to emit
a `job_failed`.

Here, we try to correct the emitted event and officially standardize it:

| Scenario | Persisted state | Previous event | New event |
|---|---:|---:|---:|
| `JobCompleteTx` commits, then worker returns an error | `completed` | `job_failed` | `job_completed` |
| Remote cancellation, then worker requests a retry | `cancelled` | `job_failed` | `job_cancelled` |
| Remote cancellation, then worker snoozes | `cancelled` | `job_snoozed` | `job_cancelled` |

Unambiguous persisted states always take precedence, though we retain
reason (added in #1290) to distinguish possible states of `available`,
which may be (1) an immediate retry after failure, (2) a short snooze,
or (3) an interruption caused by client shutdown.

[1] #1290 (comment)
brandur added a commit that referenced this pull request Aug 18, 2026
This one's aimed at fixing [1] in which for some sequences in workers,
we'd emit a surprising event based on the state that was actually
persisted to the database. This contract was also not stable, and
changed subtly with the introduction of #1290.

From [1], this is best illustrated by a short example where we error
after invoking `JobCompleteTx`:

    func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
          tx, _ := w.dbPool.Begin(ctx)
          defer tx.Rollback(ctx)

          river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
          tx.Commit(ctx)

          return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
    }

This used to emit a `job_completed`, but has changed in `master` to emit
a `job_failed`.

Here, we try to correct the emitted event and officially standardize it:

| Scenario | Persisted state | Previous event | New event |
|---|---:|---:|---:|
| `JobCompleteTx` commits, then worker returns an error | `completed` | `job_failed` | `job_completed` |
| Remote cancellation, then worker requests a retry | `cancelled` | `job_failed` | `job_cancelled` |
| Remote cancellation, then worker snoozes | `cancelled` | `job_snoozed` | `job_cancelled` |

Unambiguous persisted states always take precedence, though we retain
reason (added in #1290) to distinguish possible states of `available`,
which may be (1) an immediate retry after failure, (2) a short snooze,
or (3) an interruption caused by client shutdown.

[1] #1290 (comment)
brandur added a commit that referenced this pull request Aug 18, 2026
)

This one's aimed at fixing [1] in which for some sequences in workers,
we'd emit a surprising event based on the state that was actually
persisted to the database. This contract was also not stable, and
changed subtly with the introduction of #1290.

From [1], this is best illustrated by a short example where we error
after invoking `JobCompleteTx`:

    func (w *Worker) Work(ctx context.Context, job *river.Job[Args]) error {
          tx, _ := w.dbPool.Begin(ctx)
          defer tx.Rollback(ctx)

          river.JobCompleteTx[*riverpgxv5.Driver](ctx, tx, job) // row -> completed
          tx.Commit(ctx)

          return errors.New("boom") // executor reports an error, but its UPDATE is an IfRunning no-op
    }

This used to emit a `job_completed`, but has changed in `master` to emit
a `job_failed`.

Here, we try to correct the emitted event and officially standardize it:

| Scenario | Persisted state | Previous event | New event |
|---|---:|---:|---:|
| `JobCompleteTx` commits, then worker returns an error | `completed` | `job_failed` | `job_completed` |
| Remote cancellation, then worker requests a retry | `cancelled` | `job_failed` | `job_cancelled` |
| Remote cancellation, then worker snoozes | `cancelled` | `job_snoozed` | `job_cancelled` |

Unambiguous persisted states always take precedence, though we retain
reason (added in #1290) to distinguish possible states of `available`,
which may be (1) an immediate retry after failure, (2) a short snooze,
or (3) an interruption caused by client shutdown.

[1] #1290 (comment)
chrisgeo added a commit to full-chaos/dev-health-ops that referenced this pull request Aug 22, 2026
Sequenced after CHAOS-4011's real fix (pool-budget structural split +
durable-truth cancellation confirmation), per the owner's ruling —
bumping first would have masked the lost-NOTIFY mechanism that fix
addresses rather than fixing it.

- go.mod/go.sum: river, riverdriver, riverdriver/riverpgxv5, rivertype,
  rivershared v0.40.0 -> v0.44.0; rivercontrib/otelriver v0.11.0 ->
  v0.12.0 (the latest release, targeting river v0.41.0 as its own
  go.mod floor — Go's MVS resolves the shared river packages to our
  higher v0.44.0 requirement; verified by building the whole repo, not
  just the version bump in isolation).
- probe.go: RiverVersion/RiverDriverVersion constants bumped to
  v0.44.0 (probe.go verifies these against the actual linked
  dependency at runtime via debug.ReadBuildInfo, so a stale constant
  fails every operation immediately — this is why direct-matrix failed
  on the first local run of this change, with zero stderr output by
  design: main.go deliberately keeps error detail off stderr to avoid
  leaking credentials, encoding only a sanitized {status, phase} to
  stdout instead). Re-verified and updated the CHAOS-4011 pool-sizing
  derivation's line-number citations against v0.44.0's actual
  client.go (same six maintenance services, same notifier/elector
  structure — just shifted lines) rather than leaving stale references.
- run.sh: the four golden .river_version/.river_driver_version
  assertions for the CURRENT-version side (direct/session matrix,
  external-consume, both schema-7 N-1 orientation checks) updated to
  v0.44.0. The N-1 side's three v0.39.0 assertions are untouched (that
  tests River's OLD version, unrelated to this bump). The frozen
  ci/evidence/go-worker-migration/v1-river-spike/local-harness-results.json
  evidence artifact and its ci/check_river_compat_static.sh assertions
  are also untouched — that's a point-in-time historical record of a
  specific past run, not a live contract that should track the
  currently-pinned version.

Event-subscription audit (v0.41-v0.44 changelog + direct source diff
against v0.40.0, not just changelog headlines):
- Upstream riverqueue/river#1350 (v0.44) fixes exactly the event-kind
  routing bug CHAOS-4011 found and worked around: v0.40's
  distributeJobEvent derived the emitted EventKind by switching on the
  job's persisted State as read back after the write; v0.44 replaces
  that with an explicit riverdriver.JobSetStateReason the completer/driver
  reports directly, closing the race where a job's actual persisted
  outcome could diverge from what the emitted event implied. Retires
  the specific vulnerability probe.go's waitForCancelledRow works
  around — but that function is kept as-is (not reverted to plain
  waitForEvent): reading the persisted row is strictly more robust
  than trusting the event stream regardless of which River version is
  running, so there's no reason to give back a safety margin that
  costs nothing on the fast path.
- Upstream riverqueue/river#1290 (v0.44, new EventKindJobInterrupted)
  is unrelated to CHAOS-4011's remote-cancellation testing despite
  surface-level similarity: verified via source diff that its new
  isSoftStopCancelError() checks specifically for
  context.Cause(ctx) == rivercommon.ErrStop (client graceful-shutdown
  cancellation), which is a different, mutually exclusive cause from
  rivertype.ErrJobCancelledRemotely (our JobCancel()-driven remote-cancel
  path) — zero overlap with the cross-client/same-client cancellation
  flow CHAOS-4011 fixed. probe.go's own `defer client.StopAndCancel()`
  could theoretically hit this new path, but only if a job is still
  running at that point, which normal operation never leaves.
- What the bump does NOT retire (confirmed unchanged by diffing v0.40.0
  against v0.44.0 source directly, not assumed from changelog silence):
  the notifier's silent exponential backoff on any listener error
  (internal/notifier/notifier.go, 0.5s-64s, identical code); the total
  absence of a polling fallback for remote job-cancel delivery
  (producer.go's control-topic listening is still LISTEN-only); and
  JobRescuer's default 1-hour RescueStuckJobsAfter window when
  unconfigured (identical constant, identical default-computation).
  These are exactly why CHAOS-4011's pool-budget and durable-truth
  fixes are structural, not version-specific, and remain necessary
  after this bump.
- Whole-codebase sweep (not just the probe): `.Subscribe(` on a
  river.Client's event stream appears in exactly one place in the
  entire non-test Go codebase — tests/compatibility/river/go/probe.go.
  No production code path subscribes to River events at all. Production
  job-outcome handling (internal/joboutbox/strand_repair.go,
  terminal_delivery_repair.go) reads rivertype.JobState directly from
  the persisted row — the same durable-truth pattern CHAOS-4011's fix
  adopted for the probe — so it was never exposed to the #1350
  event-routing ambiguity in the first place. Of probe.go's five
  event-waits, only the cancellation-confirmation one (already fixed
  under CHAOS-4011) was ever in the exposed shape; the other four
  (execute-sample completion, recovery job failure/completion,
  external-consume completion) involve no cancellation/JobCompleteTx
  race and were never vulnerable.

Verified: whole-repo `go build ./...` and `go vet ./...` clean with
zero code changes required outside the three files above; full real
end-to-end river-compat harness run (all three profiles, all phases)
passes; ci/check_river_compat_static.sh clean; `ci/check_go.sh ci`
(the exact CI go-quality gate) passes, exit 0.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants