Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Job retry: keep the current page open and show an actionable error when a retry conflicts with another active unique job. [PR #623](https://github.com/riverqueue/riverui/pull/623).
- Global live update pause: disable automatic query refreshes on browser focus and reconnect, preventing paused workflow detail pages from re-fetching wait data outside the configured refresh interval. [PR #584](https://github.com/riverqueue/riverui/pull/584).
- Job detail: preserve line breaks in attempt logs while keeping structured JSON viewer content outside preformatted code markup. [PR #592](https://github.com/riverqueue/riverui/pull/592).

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.0
toolchain go1.25.7

require (
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6
github.com/jackc/pgx/v5 v5.10.0
github.com/riverqueue/apiframe v0.0.0-20251229202423-2b52ce1c482e
github.com/riverqueue/river v0.40.0
Expand All @@ -24,7 +25,6 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
Expand Down
32 changes: 32 additions & 0 deletions handler_api_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import (
"strconv"
"time"

"github.com/jackc/pgerrcode"
"github.com/jackc/pgx/v5/pgconn"

"github.com/riverqueue/apiframe/apiendpoint"
"github.com/riverqueue/apiframe/apierror"
"github.com/riverqueue/apiframe/apitype"
Expand Down Expand Up @@ -542,6 +545,9 @@ func (a *jobRetryEndpoint[TTx]) Execute(ctx context.Context, req *jobRetryReques
if errors.Is(err, river.ErrNotFound) {
return nil, NewNotFoundJob(jobID)
}
if isJobRetryUniqueConflict(err) {
return nil, newJobRetryUniqueConflictError(err)
}
return nil, err
}
}
Expand All @@ -550,6 +556,32 @@ func (a *jobRetryEndpoint[TTx]) Execute(ctx context.Context, req *jobRetryReques
})
}

const (
jobRetryUniqueConstraint = "river_job_unique_idx"
jobRetryUniqueMessage = "Job can't be retried because another active job has the same unique properties. Wait for it to finish or delete it before retrying."
)

type jobRetryUniqueConflictError struct {
apierror.APIError
}

func newJobRetryUniqueConflictError(internalErr error) *jobRetryUniqueConflictError {
return &jobRetryUniqueConflictError{
APIError: apierror.APIError{
InternalError: internalErr,
Message: jobRetryUniqueMessage,
StatusCode: http.StatusConflict,
},
}
}

func isJobRetryUniqueConflict(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) &&
pgErr.Code == pgerrcode.UniqueViolation &&
pgErr.ConstraintName == jobRetryUniqueConstraint
}

//
// queueGetEndpoint
//
Expand Down
34 changes: 34 additions & 0 deletions handler_api_endpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package riverui
import (
"context"
"log/slog"
"net/http"
"testing"
"time"

Expand All @@ -19,6 +20,7 @@ import (
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/riverqueue/river/rivershared/riversharedtest"
"github.com/riverqueue/river/rivershared/startstop"
"github.com/riverqueue/river/rivershared/uniquestates"
"github.com/riverqueue/river/rivershared/util/ptrutil"
"github.com/riverqueue/river/rivertype"

Expand Down Expand Up @@ -646,6 +648,38 @@ func TestAPIHandlerJobRetry(t *testing.T) {
_, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobRetryRequest{JobIDs: []int64String{123}})
uicommontest.RequireAPIError(t, NewNotFoundJob(123), err)
})

t.Run("UniqueConflict", func(t *testing.T) {
t.Parallel()

endpoint, bundle := setupEndpoint(ctx, t, newJobRetryEndpoint)
uniqueKey := []byte("job-retry-unique-conflict")
uniqueStates := uniquestates.UniqueStatesToBitmask([]rivertype.JobState{rivertype.JobStateAvailable})

discardedParams := testfactory.Job_Build(t, &testfactory.JobOpts{
FinalizedAt: ptrutil.Ptr(time.Now()),
State: ptrutil.Ptr(rivertype.JobStateDiscarded),
})
discardedParams.UniqueKey = uniqueKey
discardedParams.UniqueStates = uniqueStates
discardedJob, err := bundle.exec.JobInsertFull(ctx, discardedParams)
require.NoError(t, err)

activeParams := testfactory.Job_Build(t, nil)
activeParams.UniqueKey = uniqueKey
activeParams.UniqueStates = uniqueStates
_, err = bundle.exec.JobInsertFull(ctx, activeParams)
require.NoError(t, err)

_, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &jobRetryRequest{JobIDs: []int64String{int64String(discardedJob.ID)}})
require.Error(t, err)

var apiErr *jobRetryUniqueConflictError
require.ErrorAs(t, err, &apiErr)
require.Equal(t, http.StatusConflict, apiErr.StatusCode)
require.Equal(t, jobRetryUniqueMessage, apiErr.Message)
require.Error(t, apiErr.InternalError)
})
}

func TestAPIHandlerQueueGet(t *testing.T) {
Expand Down
63 changes: 63 additions & 0 deletions src/hooks/use-retry-jobs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { type ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

import { useRetryJobs } from "./use-retry-jobs";

const { mockRetryJobs, mockToastError, mockToastSuccess } = vi.hoisted(() => ({
mockRetryJobs: vi.fn(),
mockToastError: vi.fn(),
mockToastSuccess: vi.fn(),
}));

vi.mock("@services/jobs", () => ({
retryJobs: mockRetryJobs,
}));

vi.mock("@services/toast", () => ({
toastError: mockToastError,
toastSuccess: mockToastSuccess,
}));

describe("useRetryJobs", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("keeps retry errors in the mutation and shows the API message", async () => {
const error = new Error(
"Job can't be retried because another active job has the same unique properties. Wait for it to finish or delete it before retrying.",
);
mockRetryJobs.mockRejectedValue(error);
const onSuccess = vi.fn();
const { result } = renderHook(
() =>
useRetryJobs({
onSuccess,
successMessage: "Job enqueued for retry",
}),
{ wrapper: createWrapper() },
);

act(() => result.current.mutate([123n]));

await waitFor(() => expect(result.current.isError).toBe(true));
expect(mockToastError).toHaveBeenCalledWith({
message: "Job retry failed",
subtext: error.message,
});
expect(mockToastSuccess).not.toHaveBeenCalled();
expect(onSuccess).not.toHaveBeenCalled();
});
});

const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false, throwOnError: true } },
});

return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
30 changes: 30 additions & 0 deletions src/hooks/use-retry-jobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { retryJobs } from "@services/jobs";
import { toastError, toastSuccess } from "@services/toast";
import { useMutation } from "@tanstack/react-query";

type UseRetryJobsOptions = {
onSuccess: () => unknown;
successMessage: string;
};

export const useRetryJobs = ({
onSuccess,
successMessage,
}: UseRetryJobsOptions) =>
useMutation<void, Error, bigint[]>({
mutationFn: async (jobIDs, context) => retryJobs({ ids: jobIDs }, context),
onError: (error) => {
toastError({
message: "Job retry failed",
subtext: error.message,
});
},
onSuccess: () => {
toastSuccess({
duration: 2000,
message: successMessage,
});
return onSuccess();
},
throwOnError: false,
});
24 changes: 6 additions & 18 deletions src/routes/jobs/$jobId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@ import JobDetail from "@components/JobDetail";
import JobNotFound from "@components/JobNotFound";
import { useRefreshSetting } from "@contexts/RefreshSettings.hook";
import { refreshQueryOptions } from "@contexts/RefreshSettings.query";
import {
cancelJobs,
deleteJobs,
getJob,
getJobKey,
retryJobs,
} from "@services/jobs";
import { toastError, toastSuccess } from "@services/toast";
import { useRetryJobs } from "@hooks/use-retry-jobs";
import { cancelJobs, deleteJobs, getJob, getJobKey } from "@services/jobs";
import { toastError } from "@services/toast";
import { JobState } from "@services/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Expand Down Expand Up @@ -99,20 +94,13 @@ function JobComponent() {
},
});

const retryMutation = useMutation<void, Error, void>({
mutationFn: async (_variables, context) =>
retryJobs({ ids: [jobId] }, context),
throwOnError: true,
const retryMutation = useRetryJobs({
onSuccess: () => {
toastSuccess({
message: "Job enqueued for retry",
duration: 2000,
});

return queryClient.invalidateQueries({
queryKey: queryOptionsWithRefresh.queryKey,
});
},
successMessage: "Job enqueued for retry",
});

if (jobQuery.isLoading || !jobQuery.data) {
Expand All @@ -126,7 +114,7 @@ function JobComponent() {
cancel={cancelMutation.mutate}
deleteFn={deleteMutation.mutate}
job={job}
retry={retryMutation.mutate}
retry={() => retryMutation.mutate([jobId])}
/>
);
}
14 changes: 4 additions & 10 deletions src/routes/jobs/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type RefreshQueryOptions,
refreshQueryOptions,
} from "@contexts/RefreshSettings.query";
import { useRetryJobs } from "@hooks/use-retry-jobs";
import { defaultValues, jobSearchSchema } from "@routes/jobs/index.schema";
import {
cancelJobs,
Expand All @@ -13,10 +14,9 @@ import {
listJobs,
ListJobsKey,
listJobsKey,
retryJobs,
} from "@services/jobs";
import { countsByState, countsByStateKey } from "@services/states";
import { toastError, toastSuccess } from "@services/toast";
import { toastError } from "@services/toast";
import { JobState } from "@services/types";
import {
PlaceholderDataFunction,
Expand Down Expand Up @@ -291,20 +291,14 @@ function JobsIndexComponent() {
},
});

const retryMutation = useMutation({
mutationFn: async (jobIDs: bigint[], context) =>
retryJobs({ ids: jobIDs }, context),
throwOnError: true,
const retryMutation = useRetryJobs({
onSuccess: () => {
toastSuccess({
message: "Jobs enqueued for retry",
duration: 2000,
});
queryClient.invalidateQueries({
queryKey: listJobsKey({ limit, state }),
});
queryClient.invalidateQueries({ queryKey: countsByStateKey() });
},
successMessage: "Jobs enqueued for retry",
});

return (
Expand Down
Loading