Make jobs cancelled due to a soft stop immediately available - #1290
Conversation
00b244b to
3c09064
Compare
|
@bgentry Do you agree with the rationale for this one? |
|
@codex review |
There was a problem hiding this comment.
💡 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".
| } | ||
|
|
||
| if softStopped { | ||
| params := riverdriver.JobSetStateErrorAvailable(jobRow.ID, now, ptrutil.Ptr(max(jobRow.Attempt-1, 0)), errData, metadataUpdates) |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
Fixed! Changed this to be more like snooze and not persist the cancellation error at all.
3c09064 to
2e49010
Compare
bgentry
left a comment
There was a problem hiding this comment.
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.
2e49010 to
651fff2
Compare
|
@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
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? |
|
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. |
4e5bfe7 to
5d5d3fa
Compare
|
@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 At Codex's suggestion, also changed the Do you want to take another look and see what you think? |
5d5d3fa to
837f68d
Compare
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).
837f68d to
4dfafc7
Compare
|
Thx! |
|
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 errorRow ends completed. In 0.43.0 this posts Cases 2 and 3 both come from the same place: when a job is cancelled remotely while it is still running, Case 2 — job cancelled remotely while running, then the worker returns an errorThis needs the next retry to land beyond Row ends cancelled. In 0.43.0 this posts Case 3 — same remote cancel, but the worker snoozesRow ends Master is 5/5 Note on reproducing cases 2 and 3
QuestionSo what about this? This looks like fundamental question which I attempted to answer in #1219, too. What to do if you call This investigation opened another conflict between outside/remote |
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)
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)
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)
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)
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)
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)
) 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)
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.
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:
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, orEventKindJobSnoozedare a good fit for it. Weinstead introduce a new value for
EventKindJobInterruptedwhichdescribes 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
EventKindJobInterruptedwhen it receives it, that's perfectly fine).