diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fa5db07..f7585a06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Guard against empty job slice returned by `JobSetStateIfRunningMany` when a job has been deleted mid-run. [PR #1308](https://github.com/riverqueue/river/pull/1308). - Fixed `JobRescuer` pagination so a full batch of running jobs with disabled or longer worker-specific timeouts can't prevent later stuck jobs from being rescued. [PR #1318](https://github.com/riverqueue/river/pull/1318). - If a job fails to unmarshal from JSON during job rescue or job execution, back off using the retry schedule and eventually discard it, similar to any other error that might occur. [PR #1324](https://github.com/riverqueue/river/pull/1324). +- Fixed `JobListOrderByFinalizedAt` validation so finalized states are accepted while non-finalized states are rejected. [PR #1327](https://github.com/riverqueue/river/issues/1327). ## [0.40.0] - 2026-07-02 diff --git a/job_list_params.go b/job_list_params.go index cebdf57b..c9f18016 100644 --- a/job_list_params.go +++ b/job_list_params.go @@ -232,6 +232,10 @@ func (p *JobListParams) toDBParams() (*dblist.JobListParams, error) { } if p.sortField == JobListOrderByFinalizedAt { + if len(p.states) == 0 { + return nil, errors.New("cannot order by finalized_at without finalized state filters") + } + currentNonFinalizedStates := make([]rivertype.JobState, 0, len(p.states)) for _, state := range p.states { switch state { @@ -240,9 +244,9 @@ func (p *JobListParams) toDBParams() (*dblist.JobListParams, error) { case rivertype.JobStateCancelled, rivertype.JobStateCompleted, rivertype.JobStateDiscarded: } } - // This indicates the user overrode the States list with only non-finalized - // states prior to then requesting FinalizedAt ordering. - if len(currentNonFinalizedStates) == 0 { + // FinalizedAt ordering is only supported when filtering to finalized + // states because non-finalized jobs have no finalized_at value. + if len(currentNonFinalizedStates) > 0 { return nil, fmt.Errorf("cannot order by finalized_at with non-finalized state filters %+v", currentNonFinalizedStates) } } diff --git a/job_list_params_test.go b/job_list_params_test.go index acc5c758..3665500c 100644 --- a/job_list_params_test.go +++ b/job_list_params_test.go @@ -187,3 +187,60 @@ func Test_JobListCursor_MarshalJSON(t *testing.T) { require.EqualError(t, err, "json: error calling MarshalText for type *river.JobListCursor: cursor initialized with only a job can't be marshaled; try a cursor from JobListResult instead") }) } + +func Test_JobListParams_toDBParams(t *testing.T) { + t.Parallel() + + t.Run("FinalizedAtWithDefaultStates", func(t *testing.T) { + t.Parallel() + + dbParams, err := NewJobListParams().OrderBy(JobListOrderByFinalizedAt, SortOrderAsc).toDBParams() + require.NoError(t, err) + require.Equal(t, []rivertype.JobState{ + rivertype.JobStateCancelled, + rivertype.JobStateCompleted, + rivertype.JobStateDiscarded, + }, dbParams.States) + }) + + t.Run("FinalizedAtWithExplicitFinalizedStates", func(t *testing.T) { + t.Parallel() + + dbParams, err := NewJobListParams(). + States(rivertype.JobStateCompleted). + OrderBy(JobListOrderByFinalizedAt, SortOrderDesc). + toDBParams() + require.NoError(t, err) + require.Equal(t, []rivertype.JobState{rivertype.JobStateCompleted}, dbParams.States) + }) + + t.Run("FinalizedAtWithMixedStates", func(t *testing.T) { + t.Parallel() + + _, err := NewJobListParams(). + States(rivertype.JobStateAvailable, rivertype.JobStateCompleted). + OrderBy(JobListOrderByFinalizedAt, SortOrderAsc). + toDBParams() + require.EqualError(t, err, "cannot order by finalized_at with non-finalized state filters [available]") + }) + + t.Run("FinalizedAtWithNonFinalizedStates", func(t *testing.T) { + t.Parallel() + + _, err := NewJobListParams(). + States(rivertype.JobStatePending, rivertype.JobStateRunning). + OrderBy(JobListOrderByFinalizedAt, SortOrderAsc). + toDBParams() + require.EqualError(t, err, "cannot order by finalized_at with non-finalized state filters [pending running]") + }) + + t.Run("FinalizedAtWithoutStates", func(t *testing.T) { + t.Parallel() + + _, err := NewJobListParams(). + States(). + OrderBy(JobListOrderByFinalizedAt, SortOrderAsc). + toDBParams() + require.EqualError(t, err, "cannot order by finalized_at without finalized state filters") + }) +}