Add JobCancelTx and JobFailTx - #1219
Conversation
9d0892e to
19ead9c
Compare
| // timeout so the retry loop (attempt 1 -> Available -> fetcher picks it | ||
| // up -> attempt 2) completes reliably even under the race detector or | ||
| // heavy CI parallelism. | ||
| deadline := time.After(30 * time.Second) |
There was a problem hiding this comment.
Default 10 seconds from WaitOrTimeoutN was not enough here for stable CI runs.
|
@brandur Any chance this could be reviewed or at least discussed? |
|
@brandur Any chance to look into this PR? |
|
Thank you. I completely understand about being busy. I will wait. |
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)
|
@brandur Thank you for your fix in #1350. Now the rebase of this PR was smooth. I just rebased this branch to latest master. @bgentry Do you think you would have time to check it out? Or at least tell me if this direction is not to your liking and I should abandon it. Personally, I really think it closes an important gap and I am eagerly waiting for it to be included in river as it would allow us to simplify logic of our dashboard where we have to sync job state with our other state, also in a case of a failure. |
See discussion in #1173 for background.
I was trying to prepare response for that discussion but more I was thinking and exploring the codebase, more I realized how this could be implemented so I decided just to go with it and implement it, which then makes it much easier to reason about all edge cases and stuff. So here it is, implementation adding support for
JobCancelTxandJobFailTxso that also failures can be recorded inside a transaction. At the end the implementation was pretty straightforward and I like the outcome.@brandur You asked:
See
example_fail_job_within_tx_test.gofile for this. I think it is pretty neat with usingdeferto handle all possible ways job body could return error. There is also a simpler version of it inexample_cancel_job_within_tx_test.go.JobFailTx/JobCancelTxedge-case matrixIntegration tests in
job_fail_tx_integration_test.go(TestJobFailTxIntegration) exercise every combination of worker return **Txcall * tx outcome.nilCompletednilJobFailTxRetryable/Available/Discarded(parameterized by attempt)nilJobFailTxCompleted(executor fallback)nilJobCancelTxCancellednilJobCancelTxCompleted(executor fallback)errRetryable/DiscardederrJobFailTxRetryable/Discarded(from Tx call; executor IfRunning no-op)errJobFailTxRetryable/Discarded(executor fallback, using Work's err)errJobCancelTxCancellederrJobCancelTxRetryable/Discarded(executor fallback, using Work's err)JobFailTxRetryable/Discarded(from Tx; panic handled, IfRunning no-op)JobCancelErrorJobFailTxRetryable/Discarded(from Tx wins; executor's cancel UPDATE is IfRunning no-op;ErrorHandler.HandleErroris not called because the cancel branch skips it)nilJobFailTxthenJobCompleteTx(same tx)Retryable(from first Tx); second Tx call returns no error and a job whoseStateis notCompleted(the row asJobFailTxleft it - IfRunning blocked the state change in SQL, so the row is returned unchanged via the UNION-ALL branch)nilJobFailTxreturned an errorCompleted) if the error fired before the DB UPDATE; into Row 2 (Retryable/Discarded) if the error fired after (args-unmarshal path).errJobFailTxreturned an errorRetryable/Discarded, executor fallback with Work's err) if the error fired before the DB UPDATE; into Row 7 (from-Tx state wins) if the error fired after.nilJobFailTxreturned an errornilreturn and completes.errJobFailTxreturned an errorNotes
Rows 2, 7, 11: "
Retryable/Discarded" depends onjob.Attemptvsjob.MaxAttempts.JobFailTxpicksDiscardedon the final attempt andRetryable/Availableotherwise, mirroring the executor's own error-path logic.Rows 3, 5, 8, 10: on rollback, the
*TxDB write is undone and the row staysrunning, so the executor's own path runs normally based on whatWorkreturned (nil → complete path; err → error path).Row 7: the
AttemptErrorpersisted is the one fromJobFailTx(e.g."from JobFailTx"), not the oneWorkreturned. The executor's later errors-append is guarded bystate = 'running'in the SQL and is a no-op.Row 11: same as row 7 in terms of the persisted
AttemptError; the panic value is swallowed at the DB level for the same reason (IfRunning guard).Row 12:
errors.As(workErr, &JobCancelError{})succeeds, so the executor takes its cancel branch inreportError. That branch (a) skipsErrorHandler.HandleError, and (b) tries to writeJobSetStateCancelled- blocked by the IfRunning guard becauseJobFailTxalready moved the row out of'running'. Net DB result: whatJobFailTxset.Row 13:
JobSetStateIfRunningMany's SQL returns the row even on a no-op update (via itsUNION ALLbranch), soJobCompleteTxfinds the row and doesn't returnErrNotFound- it simply returns the row in its post-JobFailTxstate.Rows 14–17 (
JobFailTxitself returned an error): these don't introduce new terminal states - they collapse into earlier rows depending on where inJobFailTx's body the error fired.Error-return sites in
JobFailTx(job_fail_tx.go) split into two buckets:"job must be running", client-not-in-context, metadata-marshal failure, attempt-error-marshal failure,pilot.JobSetStateIfRunningManyDB error, andlen(rows) == 0→ErrNotFound. In all of these, the row is never updated, so whether the caller commits or rolls back is irrelevant - the final DB state is whatever the executor itself chooses based onWork's return. Behavior collapses to Row 1 / 6.json.Unmarshal(EncodedArgs, &updatedJob.Args)at the end ofJobFailTx. The row was updated in the caller's tx; whether that persists depends on commit vs rollback. On commit, behavior collapses to Row 2 / 7 (from-Tx state wins). On rollback, behavior collapses to Row 1 / 6 (row never actually changed).In practice, callers follow the Go idiom
if err != nil { return err }afterJobFailTx, which meansWorkreturns the err and theirdefer tx.Rollback(ctx)fires (committing nothing). That path falls under Row 17. The "DB marked failed despiteJobFailTxerr" variants (14 on commit after args-unmarshal failure; 15 on commit after args-unmarshal failure) are pathological - the caller got a non-nil err fromJobFailTxand chose to commit anyway.