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
43 changes: 43 additions & 0 deletions api_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,49 @@ func stringIDsToInt64s(strs []string) ([]int64, error) {
return ints, nil
}

type jobDeleteRequest struct {
JobIDStrings []string `json:"ids"`
}

func (a *apiHandler) JobDelete(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 5*time.Second)
defer cancel()

var payload jobDeleteRequest
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}
jobIDs, err := stringIDsToInt64s(payload.JobIDStrings)
if err != nil {
http.Error(rw, err.Error(), http.StatusBadRequest)
return
}

numDeleted := 0
if err := pgx.BeginFunc(ctx, a.dbPool, func(tx pgx.Tx) error {
for _, jobID := range jobIDs {
_, err := a.client.JobDeleteTx(ctx, tx, jobID)
if err != nil {
if errors.Is(rivertype.ErrJobRunning, err) {
fmt.Printf("job %d is running\n", jobID)
}
if errors.Is(err, river.ErrNotFound) {
fmt.Printf("job %d not found\n", jobID)
}
return err
}
numDeleted++
}
return nil
}); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}

writeResponse(ctx, rw, []byte("{\"status\": \"ok\", \"num_deleted\": "+strconv.Itoa(numDeleted)+"}"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

OOC, is making your own JSON like this an optimization? IMO probably not worth the trade off of maybe accidentally returning invalid JSON. The JSON encoder is definitely slower than Rust's, but it's heavily optimized and pretty darn fast.

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.

Nah, I just only had static payloads up until this one and didn't bother to change it here. Definitely down with switching it to proper serialization!

}

type jobRetryRequest struct {
JobIDStrings []string `json:"ids"`
}
Expand Down
13 changes: 6 additions & 7 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ module github.com/riverqueue/riverui
go 1.22

require (
github.com/jackc/pgx/v5 v5.5.5
github.com/jackc/pgx/v5 v5.6.0
github.com/joho/godotenv v1.5.1
github.com/riverqueue/river v0.4.2-0.20240502213604-597f6384ac36
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.4.2-0.20240502213604-597f6384ac36
github.com/riverqueue/river/rivertype v0.4.2-0.20240502213604-597f6384ac36
github.com/riverqueue/river v0.7.0
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.7.0
github.com/riverqueue/river/rivertype v0.7.0
github.com/rs/cors v1.10.0
github.com/samber/slog-http v1.0.0
)
Expand All @@ -17,13 +17,12 @@ require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/riverqueue/river/riverdriver v0.4.2-0.20240502213604-597f6384ac36 // indirect
github.com/riverqueue/river/riverdriver v0.7.0 // indirect
go.opentelemetry.io/otel v1.19.0 // indirect
go.opentelemetry.io/otel/trace v1.19.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/text v0.16.0 // indirect
)

// replace github.com/riverqueue/river => ../river
Expand Down
28 changes: 14 additions & 14 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
Expand All @@ -21,16 +21,16 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/riverqueue/river v0.4.2-0.20240502213604-597f6384ac36 h1:OgQsF0SEC/LnCw/dy0rIbc1xaLtN55zZRoWF1ZTo/9o=
github.com/riverqueue/river v0.4.2-0.20240502213604-597f6384ac36/go.mod h1:Rn1g//zWRcdtfDhqKre2Os145zYY73Kk/Zoit8oNJWg=
github.com/riverqueue/river/riverdriver v0.4.2-0.20240502213604-597f6384ac36 h1:uQ20OjBZ03P+tEoLEDYGKH0pGLvUY1zL5L+WNfS7xro=
github.com/riverqueue/river/riverdriver v0.4.2-0.20240502213604-597f6384ac36/go.mod h1:5GwQBioi1sSi9bJ49kZKO500jd9x7C8g70k5Xa0Kfcw=
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.4.0 h1:t2tvQ8xrrJdqkERPj6QCYCcDSYnYINMiGdyHpLSoq+0=
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.4.0/go.mod h1:iShNx8WyAQtw/vyO/HTe0UM5UN9EoYOHMZI3Ch0uPu4=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.4.2-0.20240502213604-597f6384ac36 h1:lzWqreyfiBE7zV17kdQZr1eMUwtCCxJBAZhsB5iiTvM=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.4.2-0.20240502213604-597f6384ac36/go.mod h1:IiLk7WDOzwGZk4G4OefarKGHUNuS31IrcQsUYI8GQs0=
github.com/riverqueue/river/rivertype v0.4.2-0.20240502213604-597f6384ac36 h1:ms1ndaN/KiqWy+JXurk982rYdMXOmEXddlkdVv+NbeM=
github.com/riverqueue/river/rivertype v0.4.2-0.20240502213604-597f6384ac36/go.mod h1:nDd50b/mIdxR/ezQzGS/JiAhBPERA7tUIne21GdfspQ=
github.com/riverqueue/river v0.7.0 h1:STWnPn0APPKQkAjsJG5Q8NzFOxPXI/BKNAL6dPT7oI8=
github.com/riverqueue/river v0.7.0/go.mod h1:ulIQc2U1mUTQNTnInytnVu4vhU9GJGeWReXtndvLBXI=
github.com/riverqueue/river/riverdriver v0.7.0 h1:cPnv/T3vNoS8kQQ1GzXhT5eghIFAd6ElF6sEEp/tPv4=
github.com/riverqueue/river/riverdriver v0.7.0/go.mod h1:tvFRvim+fO0xWQVvOSQw9eGLg1JID6a8p+dMxH6uS7Y=
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.7.0 h1:yUyWh3qdVyOflr9ngW4doaovwLQEfU5kAZVOCcD+38w=
github.com/riverqueue/river/riverdriver/riverdatabasesql v0.7.0/go.mod h1:6WLpOPCAwjNgoazf+V6FJtTRlMorpbU9YM5YmOlAMsM=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.7.0 h1:UXwVclLfDEjuL/FTy30+mnQEIToo18kj9JRmqBVXNs8=
github.com/riverqueue/river/riverdriver/riverpgxv5 v0.7.0/go.mod h1:YZiuH89mCuxfH/wpE4C8+lxLi4MtZNP0NBZqfECYBjY=
github.com/riverqueue/river/rivertype v0.7.0 h1:sqnl40ymCfT5DfstHsLbg35hddVccPkPbUBTxUVkaUA=
github.com/riverqueue/river/rivertype v0.7.0/go.mod h1:nDd50b/mIdxR/ezQzGS/JiAhBPERA7tUIne21GdfspQ=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rs/cors v1.10.0 h1:62NOS1h+r8p1mW6FM0FSB0exioXLhd/sh15KpjWBZ+8=
Expand All @@ -52,8 +52,8 @@ golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
Expand Down
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ func initAndServe(ctx context.Context) int {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/jobs", handler.JobList)
mux.HandleFunc("POST /api/jobs/cancel", handler.JobCancel)
mux.HandleFunc("POST /api/jobs/delete", handler.JobDelete)
mux.HandleFunc("POST /api/jobs/retry", handler.JobRetry)
mux.HandleFunc("GET /api/jobs/{id}", handler.JobGet)
mux.HandleFunc("GET /api/queues", handler.QueueList)
Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/JobDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import { jobFactory } from "@test/factories/job";
test("adds 1 + 2 to equal 3", () => {
const job = jobFactory.build();
const cancel = () => {};
const deleteFn = () => {};
const retry = () => {};
const { getByTestId: _getTestById } = render(
<JobDetail cancel={cancel} job={job} retry={retry} />
<JobDetail cancel={cancel} deleteFn={deleteFn} job={job} retry={retry} />
);
expect(3).toBe(3);
});
44 changes: 35 additions & 9 deletions ui/src/components/JobDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Button, ButtonProps } from "./Button";

type JobDetailProps = {
cancel: () => void;
deleteFn: () => void;
job: Job;
retry: () => void;
};
Expand Down Expand Up @@ -44,20 +45,21 @@ function ButtonForGroup({

function ActionButtons({
cancel,
deleteFn,
job,
retry,
}: {
cancel: () => void;
deleteFn: () => void;
job: Job;
retry: () => void;
}) {
// Enable immediate retry if the job is not running or pending:
const retryDisabled = [JobState.Running, JobState.Pending].includes(
job.state
);
const retryJob = (event: FormEvent) => {
// Can only delete jobs that aren't running:
const deleteDisabled = job.state === JobState.Running;

const deleteJob = (event: FormEvent) => {
event.preventDefault();
retry();
deleteFn();
};

// Can only cancel jobs that aren't already finalized (completed, discarded, cancelled):
Expand All @@ -72,6 +74,15 @@ function ActionButtons({
cancel();
};

// Enable immediate retry if the job is not running or pending:
const retryDisabled = [JobState.Running, JobState.Pending].includes(
job.state
);
const retryJob = (event: FormEvent) => {
event.preventDefault();
retry();
};

return (
<span className="isolate inline-flex rounded-md shadow-sm">
<ButtonForGroup
Expand All @@ -86,12 +97,22 @@ function ActionButtons({
disabled={cancelDisabled}
onClick={cancelJob}
/>
<ButtonForGroup Icon={TrashIcon} text="Delete" disabled />
<ButtonForGroup
Icon={TrashIcon}
text="Delete"
disabled={deleteDisabled}
onClick={deleteJob}
/>
</span>
);
}

export default function JobDetail({ cancel, job, retry }: JobDetailProps) {
export default function JobDetail({
cancel,
deleteFn,
job,
retry,
}: JobDetailProps) {
const [showAllAttempts, setShowAllAttempts] = useState(false);
const attemptsToDisplay = useMemo(() => {
if (showAllAttempts) {
Expand All @@ -118,7 +139,12 @@ export default function JobDetail({ cancel, job, retry }: JobDetailProps) {
</p>
</div>
<div className="order-none flex w-full justify-around sm:block sm:w-auto sm:flex-none">
<ActionButtons cancel={cancel} job={job} retry={retry} />
<ActionButtons
cancel={cancel}
deleteFn={deleteFn}
job={job}
retry={retry}
/>
</div>
</div>
</header>
Expand Down
26 changes: 25 additions & 1 deletion ui/src/routes/jobs/$jobId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
createFileRoute,
getRouteApi,
} from "@tanstack/react-router";
import { cancelJobs, getJob, getJobKey, retryJobs } from "@services/jobs";
import {
useMutation,
useQueryClient,
Expand All @@ -14,6 +13,14 @@ import JobDetail from "@components/JobDetail";
import { NotFoundError } from "@utils/api";
import JobNotFound from "@components/JobNotFound";
import { toastError, toastSuccess } from "@services/toast";
import {
cancelJobs,
deleteJobs,
getJob,
getJobKey,
retryJobs,
} from "@services/jobs";
import { JobState } from "@services/types";

const routeApi = getRouteApi("/jobs/$jobId");

Expand Down Expand Up @@ -50,6 +57,7 @@ export const Route = createFileRoute("/jobs/$jobId")({

function JobComponent() {
const { jobId } = Route.useParams();
const navigate = Route.useNavigate();
const { queryOptions } = Route.useRouteContext();
const refreshSettings = useRefreshSetting();
queryOptions.refetchInterval = refreshSettings.intervalMs;
Expand All @@ -71,6 +79,21 @@ function JobComponent() {
},
});

const deleteMutation = useMutation({
mutationFn: async () => deleteJobs({ ids: [jobId] }),
throwOnError: true,
onSuccess: async () => {
toastError({
message: "Job deleted",
duration: 2000,
});
await navigate({ to: "/jobs", search: { state: JobState.Running } });
await queryClient.removeQueries({
queryKey: queryOptions.queryKey,
});
},
});

const retryMutation = useMutation({
mutationFn: async () => retryJobs({ ids: [jobId] }),
throwOnError: true,
Expand All @@ -95,6 +118,7 @@ function JobComponent() {
return (
<JobDetail
cancel={cancelMutation.mutate}
deleteFn={deleteMutation.mutate}
job={job}
retry={retryMutation.mutate}
/>
Expand Down
8 changes: 8 additions & 0 deletions ui/src/services/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ export const cancelJobs: MutationFunction<void, CancelPayload> = async ({
return API.post("/jobs/cancel", JSON.stringify({ ids: ids.map(String) }));
};

type DeletePayload = JobIdsPayload;

export const deleteJobs: MutationFunction<void, DeletePayload> = async ({
ids,
}) => {
return API.post("/jobs/delete", JSON.stringify({ ids: ids.map(String) }));
};

type ListJobsFilters = {
limit?: number;
state?: JobState;
Expand Down