From 5a4f01972c6fbf5a711ef7c278c3ae829cc780d4 Mon Sep 17 00:00:00 2001 From: Brandur Date: Sat, 24 Oct 2026 12:49:09 -0500 Subject: [PATCH 1/3] Bring dead letter jobs to River UI We got a request in #706 that dead letter jobs should be in the UI, and that is indeed very true. It's often quite convenient to pull up a UI and retry a job from there instead of trying to write a one-off script to do it programmatically. Here, dead letters are added as a new entry on the left sidebar under "Queues". I also thought about it integrating it directly into the "Jobs" pane, but we'd need some new view in there as well, and I don't think it's any better of a fit. Like jobs, dead letters get a "filter" textbox at the top that lets you select a variety of attributes to filter the view underneath in pursuit of finding specific job(s). The new pane is shown only for the Pro binary: in OSS, navigation is hidden, access to the pane is blocked, and the API endpoints aren't mounted. Fixes #706. --- CHANGELOG.md | 5 + .../river_ui_driver_interface.go | 39 ++ .../riveruidrivertest/job_dead_letter.go | 156 +++++ .../riveruidrivertest/riveruidrivertest.go | 1 + .../riveruipostgres/internal/dbsqlc/models.go | 11 + .../internal/dbsqlc/river_job_dead_letter.sql | 48 ++ .../dbsqlc/river_job_dead_letter.sql.go | 79 +++ .../internal/dbsqlc/river_queries.go | 16 + .../riveruipostgres/internal/dbsqlc/sqlc.yaml | 2 + .../river_ui_postgres_driver.go | 41 ++ .../riveruisqlite/internal/dbsqlc/models.go | 11 + .../internal/dbsqlc/river_job_dead_letter.sql | 51 ++ .../dbsqlc/river_job_dead_letter.sql.go | 82 +++ .../internal/dbsqlc/river_queries.go | 16 + .../riveruisqlite/internal/dbsqlc/sqlc.yaml | 2 + .../riveruisqlite/river_ui_sqlite_driver.go | 42 ++ riverproui/endpoints.go | 13 + riverproui/endpoints_test.go | 14 + riverproui/go.mod | 2 +- .../prohandler/dead_letter_endpoints.go | 255 ++++++++ .../prohandler/dead_letter_endpoints_test.go | 240 ++++++++ riverproui/pro_handler_test.go | 12 + src/components/DeadLetterJobDetail.tsx | 89 +++ src/components/DeadLetterJobList.tsx | 158 +++++ src/components/Layout.tsx | 32 +- src/components/WorkflowDetail.test.tsx | 1 + src/components/job-search/JobSearch.test.tsx | 49 ++ src/components/job-search/JobSearch.tsx | 5 + src/components/job-search/hooks.ts | 31 + src/routeTree.gen.ts | 71 +++ src/routes/dead-letter-jobs.test.tsx | 561 ++++++++++++++++++ src/routes/dead-letter-jobs.tsx | 8 + src/routes/dead-letter-jobs/$jobId.tsx | 94 +++ src/routes/dead-letter-jobs/index.schema.ts | 94 +++ src/routes/dead-letter-jobs/index.tsx | 103 ++++ src/services/deadLetterJobs.test.ts | 127 ++++ src/services/deadLetterJobs.ts | 106 ++++ src/services/features.test.ts | 3 + src/services/features.ts | 1 + src/test/utils/features.ts | 1 + 40 files changed, 2668 insertions(+), 4 deletions(-) create mode 100644 internal/riveruidriver/riveruidrivertest/job_dead_letter.go create mode 100644 internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql create mode 100644 internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go create mode 100644 internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql create mode 100644 internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go create mode 100644 riverproui/internal/prohandler/dead_letter_endpoints.go create mode 100644 riverproui/internal/prohandler/dead_letter_endpoints_test.go create mode 100644 src/components/DeadLetterJobDetail.tsx create mode 100644 src/components/DeadLetterJobList.tsx create mode 100644 src/routes/dead-letter-jobs.test.tsx create mode 100644 src/routes/dead-letter-jobs.tsx create mode 100644 src/routes/dead-letter-jobs/$jobId.tsx create mode 100644 src/routes/dead-letter-jobs/index.schema.ts create mode 100644 src/routes/dead-letter-jobs/index.tsx create mode 100644 src/services/deadLetterJobs.test.ts create mode 100644 src/services/deadLetterJobs.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6a5fc8..08615905 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add a Pro-only dead letter queue panel with ID, kind, queue, priority, and tag filters, dead letter autocomplete, bounded database queries, and More/Fewer controls to inspect discarded jobs and retry them as new jobs. [PR #711](https://github.com/riverqueue/riverui/pull/711). + ### Changed - Require Node.js 24.15+ or 26+ for frontend development and use Node.js 24.21.0 in CI and container builds to support the updated test dependencies. [PR #690](https://github.com/riverqueue/riverui/pull/690). ### Fixed +- Clear filters and pagination when switching between Jobs and Dead Letters, while preserving them when switching job states within Jobs. [PR #711](https://github.com/riverqueue/riverui/pull/711). - Show queues whose concurrency metadata is missing, `null`, or `{}` as having no concurrency override, matching how River Pro interprets them, and stop showing a zero concurrency config for producers that report none. [PR #704](https://github.com/riverqueue/riverui/pull/704). - Reject negative queue concurrency `global_limit` and `local_limit` values with a 400 error instead of storing them. [PR #704](https://github.com/riverqueue/riverui/pull/704). - Keep workflow diagram layouts compatible with Dagre's package exports after dependency upgrades. [PR #687](https://github.com/riverqueue/riverui/pull/687). diff --git a/internal/riveruidriver/river_ui_driver_interface.go b/internal/riveruidriver/river_ui_driver_interface.go index 0a04c554..97826751 100644 --- a/internal/riveruidriver/river_ui_driver_interface.go +++ b/internal/riveruidriver/river_ui_driver_interface.go @@ -28,6 +28,45 @@ type Executor interface { // JobCountEstimate returns inexpensive database estimates where supported. // Drivers without an estimate strategy return an empty map. JobCountEstimate(ctx context.Context, params *JobCountEstimateParams) (map[rivertype.JobState]JobCountEstimateResult, error) + // JobDeadLetterList returns at most Max jobs, ordered by ID ascending. + // Only Pro endpoints call this query; it requires the dead letter table. + JobDeadLetterList(ctx context.Context, params *JobDeadLetterListParams) ([]*JobDeadLetterListResult, error) + JobDeadLetterAutocomplete(ctx context.Context, params *JobDeadLetterAutocompleteParams) ([]string, error) +} + +// JobDeadLetterFilters matches any value within each populated filter and +// requires all populated filters to match. Tags match any exact tag. +type JobDeadLetterFilters struct { + IDs []int64 `json:"ids,omitempty"` + Kinds []string `json:"kinds,omitempty"` + Priorities []int16 `json:"priorities,omitempty"` + Queues []string `json:"queues,omitempty"` + Tags []string `json:"tags,omitempty"` +} + +type JobDeadLetterAutocompleteParams struct { + Exclude []string + Facet string + Match string + Max int + Schema string +} + +type JobDeadLetterListParams struct { + Filters JobDeadLetterFilters + Max int + Schema string +} + +// JobDeadLetterListResult contains only fields displayed in the list. Args, +// metadata, and attempt history are loaded separately when opening a job. +type JobDeadLetterListResult struct { + ID int64 `json:"id"` + Attempt int `json:"attempt"` + FinalizedAt *time.Time `json:"finalized_at"` + Kind string `json:"kind"` + MaxAttempts int `json:"max_attempts"` + Queue string `json:"queue"` } type JobCountByAllStatesCappedParams struct { diff --git a/internal/riveruidriver/riveruidrivertest/job_dead_letter.go b/internal/riveruidriver/riveruidrivertest/job_dead_letter.go new file mode 100644 index 00000000..302cc9e3 --- /dev/null +++ b/internal/riveruidriver/riveruidrivertest/job_dead_letter.go @@ -0,0 +1,156 @@ +package riveruidrivertest + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/rivershared/testfactory" + "github.com/riverqueue/river/rivershared/util/dbutil" + + "riverqueue.com/riverui/internal/riveruidriver" +) + +func exerciseJobDeadLetterList( + ctx context.Context, + t *testing.T, + driver riveruidriver.Driver, + executorWithTx func(ctx context.Context, t *testing.T) (riverdriver.Executor, string), +) { + t.Helper() + const ( + urgentTag = "urgent" + reportKind = "archive_report" + mailQueue = "mail" + reportsQueue = "reports" + originalKind = "send_email" + customerTag = "customer" + emailKind = "archive_email" + kindFacet = "job_kind" + ) + t.Run("JobDeadLetterList", func(t *testing.T) { + t.Parallel() + executor, schema := executorWithSchema(ctx, t, driver, executorWithTx) + prefix := "" + if schema != "" { + prefix = dbutil.SafeIdentifier(schema) + "." + } + // OSS test schemas don't include Pro tables. Reproduce only the columns + // this query needs, preserving each database's timestamp column type. + require.NoError(t, executor.Exec(ctx, "CREATE TABLE "+prefix+"river_job_dead_letter AS SELECT id, attempt, finalized_at, kind, max_attempts, queue, priority, tags FROM "+prefix+"river_job WHERE 1 = 0")) + testfactory.Job(ctx, t, executor, &testfactory.JobOpts{Schema: schema}) + jobs, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Max: 20, Schema: schema}) + require.NoError(t, err) + require.Empty(t, jobs, "live jobs must not appear in the dead letter list") + require.NotNil(t, jobs) + + // Deliberately insert in reverse order, with IDs beyond JavaScript's + // exact integer range, and enough rows to exceed the maximum UI limit. + const firstID int64 = 1970670598291982290 + rows := make([]string, 0, 205) + for i := 204; i >= 0; i-- { + finalizedAt := "'2026-10-21 10:02:03.456+00:00'" + if i == 0 { + finalizedAt = "NULL" + } + rows = append(rows, fmt.Sprintf("(%d, 3, %s, 'send_email', 5, 'mail')", firstID+int64(i), finalizedAt)) + } + require.NoError(t, executor.Exec(ctx, "INSERT INTO "+prefix+"river_job_dead_letter (id, attempt, finalized_at, kind, max_attempts, queue) VALUES "+strings.Join(rows, ","))) + + for _, limit := range []int{1, 20, 40, 200} { + jobs, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Max: limit, Schema: schema}) + require.NoError(t, err) + require.Len(t, jobs, limit) + for i, job := range jobs { + require.Equal(t, firstID+int64(i), job.ID) + require.Equal(t, 3, job.Attempt) + require.Equal(t, 5, job.MaxAttempts) + require.Equal(t, originalKind, job.Kind) + require.Equal(t, mailQueue, job.Queue) + if i == 0 { + require.Nil(t, job.FinalizedAt) + } else { + require.NotNil(t, job.FinalizedAt) + require.True(t, job.FinalizedAt.Equal(time.Date(2026, time.October, 21, 10, 2, 3, 456_000_000, time.UTC))) + } + } + } + for _, limit := range []int{-1, 0, 201} { + _, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Max: limit, Schema: schema}) + require.EqualError(t, err, "dead letter list max must be between 1 and 200") + } + + // Filtering must happen before limiting, without rounding large IDs. + jobs, err = executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{ + Filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{firstID + 204}}, Max: 1, Schema: schema, + }) + require.NoError(t, err) + require.Len(t, jobs, 1) + require.Equal(t, firstID+204, jobs[0].ID) + + archive := func(kind, queue string, priority int, tags []string) int64 { + job := testfactory.Job(ctx, t, executor, &testfactory.JobOpts{ + Kind: &kind, Queue: &queue, Priority: &priority, Tags: tags, Schema: schema, + }) + require.NoError(t, executor.Exec(ctx, fmt.Sprintf("INSERT INTO %sriver_job_dead_letter SELECT id, attempt, finalized_at, kind, max_attempts, queue, priority, tags FROM %sriver_job WHERE id = %d", prefix, prefix, job.ID))) + return job.ID + } + first := archive(emailKind, mailQueue, 1, []string{urgentTag, customerTag}) + second := archive(reportKind, reportsQueue, 3, []string{"batch", customerTag}) + third := archive(reportKind, mailQueue, 2, []string{"not-urgent"}) + for _, tt := range []struct { + name string + filters riveruidriver.JobDeadLetterFilters + want []int64 + }{ + {name: "IDs", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{second, first}}, want: []int64{first, second}}, + {name: "Kinds", filters: riveruidriver.JobDeadLetterFilters{Kinds: []string{emailKind, reportKind}}, want: []int64{first, second, third}}, + {name: "Queues", filters: riveruidriver.JobDeadLetterFilters{Queues: []string{reportsQueue}}, want: []int64{second}}, + {name: "Priorities", filters: riveruidriver.JobDeadLetterFilters{Priorities: []int16{1, 3}}, want: []int64{first, second}}, + {name: "TagsMatchAny", filters: riveruidriver.JobDeadLetterFilters{Tags: []string{urgentTag, "batch"}}, want: []int64{first, second}}, + {name: "TagsAreExact", filters: riveruidriver.JobDeadLetterFilters{Tags: []string{urgentTag}}, want: []int64{first}}, + {name: "Combined", filters: riveruidriver.JobDeadLetterFilters{Kinds: []string{emailKind, reportKind}, Queues: []string{mailQueue}, Priorities: []int16{1, 2}, Tags: []string{customerTag}}, want: []int64{first}}, + {name: "NoMatches", filters: riveruidriver.JobDeadLetterFilters{Kinds: []string{"missing"}}, want: []int64{}}, + {name: "LiteralValues", filters: riveruidriver.JobDeadLetterFilters{Tags: []string{"urgent' OR 1=1 --"}}, want: []int64{}}, + {name: "EmptyFilters", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{}, Kinds: []string{}, Queues: []string{}, Tags: []string{}, Priorities: []int16{}}, want: []int64{first, second, third}}, + } { + t.Run(tt.name, func(t *testing.T) { + jobs, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Filters: tt.filters, Max: 3, Schema: schema}) + require.NoError(t, err) + got := make([]int64, 0, len(jobs)) + for _, job := range jobs { + got = append(got, job.ID) + } + require.Equal(t, tt.want, got) + }) + } + + for _, tt := range []struct { + name string + facet string + match string + exclude []string + max int + want []string + }{ + {name: "Kinds", facet: kindFacet, max: 100, want: []string{emailKind, reportKind, originalKind}}, + {name: "PrefixCaseInsensitive", facet: kindFacet, match: "ARCHIVE_", max: 100, want: []string{emailKind, reportKind}}, + {name: "Exclude", facet: kindFacet, match: "archive_", exclude: []string{reportKind}, max: 100, want: []string{emailKind}}, + {name: "Limit", facet: kindFacet, max: 1, want: []string{emailKind}}, + {name: "Queues", facet: "queue_name", max: 100, want: []string{mailQueue, reportsQueue}}, + {name: "NoLiveJobs", facet: kindFacet, match: "fake_job", max: 100, want: []string{}}, + {name: "LiteralPrefix", facet: kindFacet, match: "%", max: 100, want: []string{}}, + } { + t.Run("Autocomplete"+tt.name, func(t *testing.T) { + values, err := executor.JobDeadLetterAutocomplete(ctx, &riveruidriver.JobDeadLetterAutocompleteParams{Facet: tt.facet, Match: tt.match, Exclude: tt.exclude, Max: tt.max, Schema: schema}) + require.NoError(t, err) + require.Equal(t, tt.want, values) + }) + } + }) +} diff --git a/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go b/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go index 6917337c..1fbb6632 100644 --- a/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go +++ b/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go @@ -28,6 +28,7 @@ func Exercise( exerciseGetExecutor(ctx, t, driver, executorWithTx) exerciseJobCountByAllStatesCapped(ctx, t, driver, executorWithTx) exerciseJobCountEstimate(ctx, t, databaseName, driver, executorWithTx) + exerciseJobDeadLetterList(ctx, t, driver, executorWithTx) } func exerciseGetExecutor( diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/models.go b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/models.go index 316efb74..b073149f 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/models.go +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/models.go @@ -72,3 +72,14 @@ type RiverJob struct { State RiverJobState ScheduledAt time.Time } + +type RiverJobDeadLetter struct { + ID int64 + Attempt int16 + FinalizedAt *time.Time + Kind string + MaxAttempts int16 + Queue string + Priority int16 + Tags []string +} diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql new file mode 100644 index 00000000..f9f2bf5b --- /dev/null +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql @@ -0,0 +1,48 @@ +-- Minimal schema for the Pro-only dead letter list query. River Pro owns the +-- real table and its migrations. +CREATE TABLE river_job_dead_letter ( + id bigint PRIMARY KEY, + attempt smallint NOT NULL, + finalized_at timestamptz, + kind text NOT NULL, + max_attempts smallint NOT NULL, + queue text NOT NULL, + priority smallint NOT NULL DEFAULT 1, + tags varchar(255)[] NOT NULL +); + +-- name: JobDeadLetterList :one +-- Apply the limit before aggregation so neither the database nor the browser +-- materializes the entire queue. River's common executor exposes QueryRow, +-- so return the bounded set as one JSON value. +SELECT coalesce(json_agg(json_build_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', limited.finalized_at, + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +) ORDER BY limited.id), '[]'::json)::text AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE (NOT jsonb_exists(@filters::jsonb, 'ids') OR id IN (SELECT value::bigint FROM jsonb_array_elements_text(@filters::jsonb -> 'ids'))) + AND (NOT jsonb_exists(@filters::jsonb, 'kinds') OR kind IN (SELECT value::text FROM jsonb_array_elements_text(@filters::jsonb -> 'kinds'))) + AND (NOT jsonb_exists(@filters::jsonb, 'priorities') OR priority IN (SELECT value::smallint FROM jsonb_array_elements_text(@filters::jsonb -> 'priorities'))) + AND (NOT jsonb_exists(@filters::jsonb, 'queues') OR queue IN (SELECT value::text FROM jsonb_array_elements_text(@filters::jsonb -> 'queues'))) + AND (NOT jsonb_exists(@filters::jsonb, 'tags') OR tags && ARRAY(SELECT jsonb_array_elements_text(@filters::jsonb -> 'tags'))::varchar[]) + ORDER BY id + LIMIT @max::int +) AS limited; + +-- name: JobDeadLetterAutocomplete :one +-- Suggest values from the archive, including kinds and queues with no live jobs. +SELECT coalesce(json_agg(suggestions.value ORDER BY suggestions.value), '[]'::json)::text AS values +FROM ( + SELECT DISTINCT CASE WHEN @facet::text = 'job_kind' THEN kind ELSE queue END AS value + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE left(lower(CASE WHEN @facet::text = 'job_kind' THEN kind ELSE queue END), length(@match::text)) = lower(@match::text) + AND (CASE WHEN @facet::text = 'job_kind' THEN kind ELSE queue END) NOT IN (SELECT jsonb_array_elements_text(@exclude::jsonb)) + ORDER BY value + LIMIT @max::int +) AS suggestions; diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go new file mode 100644 index 00000000..add724a0 --- /dev/null +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go @@ -0,0 +1,79 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.0 +// source: river_job_dead_letter.sql + +package dbsqlc + +import ( + "context" +) + +const jobDeadLetterAutocomplete = `-- name: JobDeadLetterAutocomplete :one +SELECT coalesce(json_agg(suggestions.value ORDER BY suggestions.value), '[]'::json)::text AS values +FROM ( + SELECT DISTINCT CASE WHEN $1::text = 'job_kind' THEN kind ELSE queue END AS value + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE left(lower(CASE WHEN $1::text = 'job_kind' THEN kind ELSE queue END), length($2::text)) = lower($2::text) + AND (CASE WHEN $1::text = 'job_kind' THEN kind ELSE queue END) NOT IN (SELECT jsonb_array_elements_text($3::jsonb)) + ORDER BY value + LIMIT $4::int +) AS suggestions +` + +type JobDeadLetterAutocompleteParams struct { + Facet string + Match string + Exclude []byte + Max int32 +} + +// Suggest values from the archive, including kinds and queues with no live jobs. +func (q *Queries) JobDeadLetterAutocomplete(ctx context.Context, db DBTX, arg *JobDeadLetterAutocompleteParams) (string, error) { + row := db.QueryRow(ctx, jobDeadLetterAutocomplete, + arg.Facet, + arg.Match, + arg.Exclude, + arg.Max, + ) + var values string + err := row.Scan(&values) + return values, err +} + +const jobDeadLetterList = `-- name: JobDeadLetterList :one +SELECT coalesce(json_agg(json_build_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', limited.finalized_at, + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +) ORDER BY limited.id), '[]'::json)::text AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE (NOT jsonb_exists($1::jsonb, 'ids') OR id IN (SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb -> 'ids'))) + AND (NOT jsonb_exists($1::jsonb, 'kinds') OR kind IN (SELECT value::text FROM jsonb_array_elements_text($1::jsonb -> 'kinds'))) + AND (NOT jsonb_exists($1::jsonb, 'priorities') OR priority IN (SELECT value::smallint FROM jsonb_array_elements_text($1::jsonb -> 'priorities'))) + AND (NOT jsonb_exists($1::jsonb, 'queues') OR queue IN (SELECT value::text FROM jsonb_array_elements_text($1::jsonb -> 'queues'))) + AND (NOT jsonb_exists($1::jsonb, 'tags') OR tags && ARRAY(SELECT jsonb_array_elements_text($1::jsonb -> 'tags'))::varchar[]) + ORDER BY id + LIMIT $2::int +) AS limited +` + +type JobDeadLetterListParams struct { + Filters []byte + Max int32 +} + +// Apply the limit before aggregation so neither the database nor the browser +// materializes the entire queue. River's common executor exposes QueryRow, +// so return the bounded set as one JSON value. +func (q *Queries) JobDeadLetterList(ctx context.Context, db DBTX, arg *JobDeadLetterListParams) (string, error) { + row := db.QueryRow(ctx, jobDeadLetterList, arg.Filters, arg.Max) + var jobs string + err := row.Scan(&jobs) + return jobs, err +} diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go index a37c7633..6021c0d2 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go @@ -86,3 +86,19 @@ func jobCountEstimateQuery(state rivertype.JobState) (string, error) { return "", fmt.Errorf("invalid job state for count estimate: %q", state) } } + +func JobDeadLetterListRiver(ctx context.Context, db RowQuerier, maxRows int, filters string) (string, error) { + var jobs string + if err := db.QueryRow(ctx, jobDeadLetterList, filters, maxRows).Scan(&jobs); err != nil { + return "", err + } + return jobs, nil +} + +func JobDeadLetterAutocompleteRiver(ctx context.Context, db RowQuerier, facet, match, exclude string, maxRows int) (string, error) { + var values string + if err := db.QueryRow(ctx, jobDeadLetterAutocomplete, facet, match, exclude, maxRows).Scan(&values); err != nil { + return "", err + } + return values, nil +} diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/sqlc.yaml b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/sqlc.yaml index 5f1918c7..91b1aa0a 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/sqlc.yaml +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/sqlc.yaml @@ -3,8 +3,10 @@ sql: - engine: "postgresql" queries: - river_job.sql + - river_job_dead_letter.sql schema: - river_job.sql + - river_job_dead_letter.sql gen: go: package: "dbsqlc" diff --git a/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go b/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go index 007e7228..d62d5ada 100644 --- a/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go +++ b/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go @@ -139,3 +139,44 @@ func (w riverExecutorWrapper) QueryRow(ctx context.Context, query string, args . // the query and context through unchanged and let it consume the template. return w.executor.QueryRow(ctx, query, args...) } + +func (e *Executor) JobDeadLetterList(ctx context.Context, params *riveruidriver.JobDeadLetterListParams) ([]*riveruidriver.JobDeadLetterListResult, error) { + if params.Max < 1 || params.Max > 200 { + return nil, errors.New("dead letter list max must be between 1 and 200") + } + filters, err := json.Marshal(params.Filters) + if err != nil { + return nil, fmt.Errorf("error encoding dead letter filters: %w", err) + } + rawJobs, err := dbsqlc.JobDeadLetterListRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Max, string(filters)) + if err != nil { + return nil, fmt.Errorf("error listing dead letter jobs: %w", err) + } + var jobs []*riveruidriver.JobDeadLetterListResult + if err := json.Unmarshal([]byte(rawJobs), &jobs); err != nil { + return nil, fmt.Errorf("error decoding dead letter jobs: %w", err) + } + return jobs, nil +} + +func (e *Executor) JobDeadLetterAutocomplete(ctx context.Context, params *riveruidriver.JobDeadLetterAutocompleteParams) ([]string, error) { + if params.Facet != "job_kind" && params.Facet != "queue_name" { + return nil, errors.New("invalid dead letter autocomplete facet") + } + if params.Max < 1 || params.Max > 100 { + return nil, errors.New("dead letter autocomplete max must be between 1 and 100") + } + exclude, err := json.Marshal(append([]string{}, params.Exclude...)) + if err != nil { + return nil, fmt.Errorf("error encoding dead letter exclusions: %w", err) + } + rawValues, err := dbsqlc.JobDeadLetterAutocompleteRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Facet, params.Match, string(exclude), params.Max) + if err != nil { + return nil, fmt.Errorf("error autocompleting dead letter jobs: %w", err) + } + var values []string + if err := json.Unmarshal([]byte(rawValues), &values); err != nil { + return nil, fmt.Errorf("error decoding dead letter suggestions: %w", err) + } + return values, nil +} diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/models.go b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/models.go index 7f213228..8529d6ac 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/models.go +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/models.go @@ -16,6 +16,17 @@ type RiverJob struct { ScheduledAt time.Time } +type RiverJobDeadLetter struct { + ID int64 + Attempt int64 + FinalizedAt *time.Time + Kind string + MaxAttempts int64 + Queue string + Priority int64 + Tags string +} + type SqliteStat4 struct { Tbl string Idx string diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql new file mode 100644 index 00000000..6295e003 --- /dev/null +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql @@ -0,0 +1,51 @@ +-- Minimal schema for the Pro-only dead letter list query. River Pro owns the +-- real table and its migrations. +CREATE TABLE river_job_dead_letter ( + id integer PRIMARY KEY, + attempt integer NOT NULL, + finalized_at timestamp, + kind text NOT NULL, + max_attempts integer NOT NULL, + queue text NOT NULL, + priority integer NOT NULL DEFAULT 1, + tags text NOT NULL +); + +-- name: JobDeadLetterList :one +-- Apply the limit before aggregation so neither the database nor the browser +-- materializes the entire queue. Normalize SQLite timestamps to RFC 3339 for +-- the shared result decoder; strftime preserves NULL timestamps. +SELECT CAST(json_group_array(json_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', strftime('%Y-%m-%dT%H:%M:%fZ', limited.finalized_at), + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +)) AS text) AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE (json_type(CAST(@filters AS text), '$.ids') IS NULL OR id IN (SELECT CAST(value AS integer) FROM json_each(CAST(@filters AS text), '$.ids'))) + AND (json_type(CAST(@filters AS text), '$.kinds') IS NULL OR kind IN (SELECT CAST(value AS text) FROM json_each(CAST(@filters AS text), '$.kinds'))) + AND (json_type(CAST(@filters AS text), '$.priorities') IS NULL OR priority IN (SELECT CAST(value AS integer) FROM json_each(CAST(@filters AS text), '$.priorities'))) + AND (json_type(CAST(@filters AS text), '$.queues') IS NULL OR queue IN (SELECT CAST(value AS text) FROM json_each(CAST(@filters AS text), '$.queues'))) + AND (json_type(CAST(@filters AS text), '$.tags') IS NULL OR EXISTS (SELECT 1 FROM json_each(river_job_dead_letter.tags) AS job_tag JOIN json_each(CAST(@filters AS text), '$.tags') AS filter_tag ON job_tag.value = filter_tag.value)) + ORDER BY id + LIMIT @max +) AS limited; + +-- name: JobDeadLetterAutocomplete :one +-- Suggest values from the archive, including kinds and queues with no live jobs. +SELECT CAST(json_group_array(suggestions.value) AS text) AS values_json +FROM ( + SELECT DISTINCT candidates.value + FROM ( + SELECT CASE WHEN CAST(@facet AS text) = 'job_kind' THEN kind ELSE queue END AS value + FROM /* TEMPLATE: schema */river_job_dead_letter + ) AS candidates + WHERE substr(lower(candidates.value), 1, length(CAST(@match AS text))) = lower(CAST(@match AS text)) + AND candidates.value NOT IN (SELECT value FROM json_each(CAST(@exclude AS text))) + ORDER BY candidates.value + LIMIT @max +) AS suggestions; diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go new file mode 100644 index 00000000..84d6d0a3 --- /dev/null +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go @@ -0,0 +1,82 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.0 +// source: river_job_dead_letter.sql + +package dbsqlc + +import ( + "context" +) + +const jobDeadLetterAutocomplete = `-- name: JobDeadLetterAutocomplete :one +SELECT CAST(json_group_array(suggestions.value) AS text) AS values_json +FROM ( + SELECT DISTINCT candidates.value + FROM ( + SELECT CASE WHEN CAST(?1 AS text) = 'job_kind' THEN kind ELSE queue END AS value + FROM /* TEMPLATE: schema */river_job_dead_letter + ) AS candidates + WHERE substr(lower(candidates.value), 1, length(CAST(?2 AS text))) = lower(CAST(?2 AS text)) + AND candidates.value NOT IN (SELECT value FROM json_each(CAST(?3 AS text))) + ORDER BY candidates.value + LIMIT ?4 +) AS suggestions +` + +type JobDeadLetterAutocompleteParams struct { + Facet string + Match string + Exclude string + Max int64 +} + +// Suggest values from the archive, including kinds and queues with no live jobs. +func (q *Queries) JobDeadLetterAutocomplete(ctx context.Context, db DBTX, arg *JobDeadLetterAutocompleteParams) (string, error) { + row := db.QueryRowContext(ctx, jobDeadLetterAutocomplete, + arg.Facet, + arg.Match, + arg.Exclude, + arg.Max, + ) + var values_json string + err := row.Scan(&values_json) + return values_json, err +} + +const jobDeadLetterList = `-- name: JobDeadLetterList :one +SELECT CAST(json_group_array(json_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', strftime('%Y-%m-%dT%H:%M:%fZ', limited.finalized_at), + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +)) AS text) AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE (json_type(CAST(?1 AS text), '$.ids') IS NULL OR id IN (SELECT CAST(value AS integer) FROM json_each(CAST(?1 AS text), '$.ids'))) + AND (json_type(CAST(?1 AS text), '$.kinds') IS NULL OR kind IN (SELECT CAST(value AS text) FROM json_each(CAST(?1 AS text), '$.kinds'))) + AND (json_type(CAST(?1 AS text), '$.priorities') IS NULL OR priority IN (SELECT CAST(value AS integer) FROM json_each(CAST(?1 AS text), '$.priorities'))) + AND (json_type(CAST(?1 AS text), '$.queues') IS NULL OR queue IN (SELECT CAST(value AS text) FROM json_each(CAST(?1 AS text), '$.queues'))) + AND (json_type(CAST(?1 AS text), '$.tags') IS NULL OR EXISTS (SELECT 1 FROM json_each(river_job_dead_letter.tags) AS job_tag JOIN json_each(CAST(?1 AS text), '$.tags') AS filter_tag ON job_tag.value = filter_tag.value)) + ORDER BY id + LIMIT ?2 +) AS limited +` + +type JobDeadLetterListParams struct { + Filters string + Max int64 +} + +// Apply the limit before aggregation so neither the database nor the browser +// materializes the entire queue. Normalize SQLite timestamps to RFC 3339 for +// the shared result decoder; strftime preserves NULL timestamps. +func (q *Queries) JobDeadLetterList(ctx context.Context, db DBTX, arg *JobDeadLetterListParams) (string, error) { + row := db.QueryRowContext(ctx, jobDeadLetterList, arg.Filters, arg.Max) + var jobs string + err := row.Scan(&jobs) + return jobs, err +} diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go index ef42302d..7376e0b9 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go @@ -36,3 +36,19 @@ func JobCountEstimateStat4River(ctx context.Context, db RowQuerier) (string, err } return samples, nil } + +func JobDeadLetterListRiver(ctx context.Context, db RowQuerier, maxRows int, filters string) (string, error) { + var jobs string + if err := db.QueryRow(ctx, jobDeadLetterList, filters, maxRows).Scan(&jobs); err != nil { + return "", err + } + return jobs, nil +} + +func JobDeadLetterAutocompleteRiver(ctx context.Context, db RowQuerier, facet, match, exclude string, maxRows int) (string, error) { + var values string + if err := db.QueryRow(ctx, jobDeadLetterAutocomplete, facet, match, exclude, maxRows).Scan(&values); err != nil { + return "", err + } + return values, nil +} diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/sqlc.yaml b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/sqlc.yaml index ad5fb697..00624ae4 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/sqlc.yaml +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/sqlc.yaml @@ -3,8 +3,10 @@ sql: - engine: "sqlite" queries: - river_job.sql + - river_job_dead_letter.sql schema: - river_job.sql + - river_job_dead_letter.sql gen: go: package: "dbsqlc" diff --git a/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go b/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go index 47760b12..bb4ec784 100644 --- a/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go +++ b/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go @@ -4,6 +4,7 @@ package riveruisqlite import ( "context" "encoding/hex" + "encoding/json" "errors" "fmt" "math" @@ -240,3 +241,44 @@ func (w riverExecutorWrapper) QueryRow(ctx context.Context, query string, args . // the query and context through unchanged and let it consume the template. return w.executor.QueryRow(ctx, query, args...) } + +func (e *Executor) JobDeadLetterList(ctx context.Context, params *riveruidriver.JobDeadLetterListParams) ([]*riveruidriver.JobDeadLetterListResult, error) { + if params.Max < 1 || params.Max > 200 { + return nil, errors.New("dead letter list max must be between 1 and 200") + } + filters, err := json.Marshal(params.Filters) + if err != nil { + return nil, fmt.Errorf("error encoding dead letter filters: %w", err) + } + rawJobs, err := dbsqlc.JobDeadLetterListRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Max, string(filters)) + if err != nil { + return nil, fmt.Errorf("error listing dead letter jobs: %w", err) + } + var jobs []*riveruidriver.JobDeadLetterListResult + if err := json.Unmarshal([]byte(rawJobs), &jobs); err != nil { + return nil, fmt.Errorf("error decoding dead letter jobs: %w", err) + } + return jobs, nil +} + +func (e *Executor) JobDeadLetterAutocomplete(ctx context.Context, params *riveruidriver.JobDeadLetterAutocompleteParams) ([]string, error) { + if params.Facet != "job_kind" && params.Facet != "queue_name" { + return nil, errors.New("invalid dead letter autocomplete facet") + } + if params.Max < 1 || params.Max > 100 { + return nil, errors.New("dead letter autocomplete max must be between 1 and 100") + } + exclude, err := json.Marshal(append([]string{}, params.Exclude...)) + if err != nil { + return nil, fmt.Errorf("error encoding dead letter exclusions: %w", err) + } + rawValues, err := dbsqlc.JobDeadLetterAutocompleteRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Facet, params.Match, string(exclude), params.Max) + if err != nil { + return nil, fmt.Errorf("error autocompleting dead letter jobs: %w", err) + } + var values []string + if err := json.Unmarshal([]byte(rawValues), &values); err != nil { + return nil, fmt.Errorf("error decoding dead letter suggestions: %w", err) + } + return values, nil +} diff --git a/riverproui/endpoints.go b/riverproui/endpoints.go index 2ed66a2d..220714f9 100644 --- a/riverproui/endpoints.go +++ b/riverproui/endpoints.go @@ -90,6 +90,14 @@ func (e *endpoints[TTx]) Extensions(ctx context.Context) (map[string]bool, error } defer execTx.Rollback(ctx) + hasDeadLetterTable, err := execTx.TableExists(ctx, &riverdriver.TableExistsParams{ + Schema: schema, + Table: "river_job_dead_letter", + }) + if err != nil { + return nil, err + } + hasPeriodicJobTable, err := execTx.TableExists(ctx, &riverdriver.TableExistsParams{ Schema: schema, Table: "river_periodic_job", @@ -120,6 +128,7 @@ func (e *endpoints[TTx]) Extensions(ctx context.Context) (map[string]bool, error } return map[string]bool{ + "dead_letter_jobs": hasDeadLetterTable, "durable_periodic_jobs": hasPeriodicJobTable, "producer_queries": true, "workflow_queries": hasWorkflowV2Tables, @@ -157,6 +166,10 @@ func (e *endpoints[TTx]) MountEndpoints(archetype *baseservice.Archetype, logger endpoints := e.ossEndpoints.MountEndpoints(archetype, logger, mux, mountOpts) endpoints = append(endpoints, + apiendpoint.Mount(mux, prohandler.NewDeadLetterJobListEndpoint(bundle), mountOpts), + apiendpoint.Mount(mux, prohandler.NewDeadLetterAutocompleteEndpoint(bundle), mountOpts), + apiendpoint.Mount(mux, prohandler.NewDeadLetterJobGetEndpoint(bundle), mountOpts), + apiendpoint.Mount(mux, prohandler.NewDeadLetterJobRetryEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, prohandler.NewPeriodicJobListEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, prohandler.NewProducerListEndpoint(bundle), mountOpts), apiendpoint.Mount(mux, prohandler.NewWorkflowCancelEndpoint(bundle), mountOpts), diff --git a/riverproui/endpoints_test.go b/riverproui/endpoints_test.go index 5282892e..0606bc26 100644 --- a/riverproui/endpoints_test.go +++ b/riverproui/endpoints_test.go @@ -65,6 +65,20 @@ func TestProEndpointsExtensions(t *testing.T) { } } + t.Run("DeadLetterJobs", func(t *testing.T) { + t.Parallel() + bundle := setup(ctx, t) + ext, err := bundle.endpoint.Extensions(ctx) + require.NoError(t, err) + require.True(t, ext["dead_letter_jobs"]) + + _, err = bundle.tx.Exec(ctx, `DROP TABLE river_job_dead_letter`) + require.NoError(t, err) + ext, err = bundle.endpoint.Extensions(ctx) + require.NoError(t, err) + require.False(t, ext["dead_letter_jobs"]) + }) + t.Run("DurablePeriodicJobs", func(t *testing.T) { //nolint:dupl t.Parallel() diff --git a/riverproui/go.mod b/riverproui/go.mod index dcede7c9..72b51380 100644 --- a/riverproui/go.mod +++ b/riverproui/go.mod @@ -72,4 +72,4 @@ retract ( // replace riverqueue.com/riverpro/driver/riverpropgxv5 => ../../riverpro/driver/riverpropgxv5 -// replace riverqueue.com/riverui => ../ +replace riverqueue.com/riverui => ../ diff --git a/riverproui/internal/prohandler/dead_letter_endpoints.go b/riverproui/internal/prohandler/dead_letter_endpoints.go new file mode 100644 index 00000000..9676e0f3 --- /dev/null +++ b/riverproui/internal/prohandler/dead_letter_endpoints.go @@ -0,0 +1,255 @@ +package prohandler + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/riverqueue/apiframe/apiendpoint" + "github.com/riverqueue/apiframe/apierror" + "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/rivershared/util/dbutil" + "github.com/riverqueue/river/rivershared/util/ptrutil" + "github.com/riverqueue/river/rivershared/util/sliceutil" + "github.com/riverqueue/river/rivertype" + + "riverqueue.com/riverpro/driver" + + "riverqueue.com/riverui/internal/riveruidriver" + "riverqueue.com/riverui/internal/riveruidriver/riveruipostgres" + "riverqueue.com/riverui/internal/riveruidriver/riveruisqlite" +) + +func (a *ProAPIBundle[TTx]) requireDeadLetterJobs(ctx context.Context) error { + exists, err := a.DB.TableExists(ctx, &riverdriver.TableExistsParams{ + Schema: a.Client.Schema(), + Table: "river_job_dead_letter", + }) + if err != nil { + return fmt.Errorf("error checking dead letter schema support: %w", err) + } + if !exists { + return apierror.NewBadRequest("Dead letter jobs require River Pro's dead letter queue migration.") + } + return nil +} + +// IDs are strings so JavaScript can preserve the full int64 range. +type deadLetterJobListItem struct { + *riveruidriver.JobDeadLetterListResult + + ID int64 `json:"id,string"` +} + +type deadLetterJob struct { + *riverJobSerializable + + ID int64 `json:"id,string"` +} + +func serializableDeadLetterJob(job *rivertype.JobRow) *deadLetterJob { + result := internalJobToSerializableJob(job) + if result.Errors == nil { + result.Errors = []rivertype.AttemptError{} + } + return &deadLetterJob{riverJobSerializable: result, ID: job.ID} +} + +type deadLetterJobListEndpoint[TTx any] struct { + ProAPIBundle[TTx] + apiendpoint.Endpoint[deadLetterJobListRequest, listResponse[deadLetterJobListItem]] + + uiDriver riveruidriver.Driver +} + +func NewDeadLetterJobListEndpoint[TTx any](bundle ProAPIBundle[TTx]) *deadLetterJobListEndpoint[TTx] { + return &deadLetterJobListEndpoint[TTx]{ProAPIBundle: bundle, uiDriver: deadLetterUIDriver(bundle.Driver.DatabaseName())} +} + +func deadLetterUIDriver(databaseName string) riveruidriver.Driver { + var uiDriver riveruidriver.Driver + switch databaseName { + case riverdriver.DatabaseNamePostgres: + uiDriver = riveruipostgres.New() + case riverdriver.DatabaseNameSQLite: + uiDriver = riveruisqlite.New() + default: + panic(fmt.Sprintf("unsupported River UI database %q", databaseName)) + } + return uiDriver +} + +func (*deadLetterJobListEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { + return &apiendpoint.EndpointMeta{Pattern: "GET /api/pro/dead-letter-jobs", StatusCode: http.StatusOK} +} + +type deadLetterJobListRequest struct { + IDs []int64 `json:"-" validate:"omitempty,max=1000,dive,min=1"` + Kinds []string `json:"-" validate:"omitempty,max=100,dive,required,max=1024"` + Priorities []int16 `json:"-" validate:"omitempty,max=4,dive,min=1,max=4"` + Queues []string `json:"-" validate:"omitempty,max=100,dive,required,max=1024"` + Tags []string `json:"-" validate:"omitempty,max=100,dive,required,max=1024"` + Limit *int `json:"-" validate:"omitempty,min=1,max=200"` +} + +func (req *deadLetterJobListRequest) ExtractRaw(r *http.Request) error { + query := r.URL.Query() + req.Kinds, req.Queues, req.Tags = query["kinds"], query["queues"], query["tags"] + for _, value := range query["ids"] { + id, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return apierror.NewBadRequestf("Invalid job ID %q.", value) + } + req.IDs = append(req.IDs, id) + } + for _, value := range query["priorities"] { + priority, err := strconv.ParseInt(value, 10, 16) + if err != nil { + return apierror.NewBadRequestf("Invalid priority %q.", value) + } + req.Priorities = append(req.Priorities, int16(priority)) + } + if limitString := r.URL.Query().Get("limit"); limitString != "" { + limit, err := strconv.Atoi(limitString) + if err != nil { + return apierror.NewBadRequestf("Couldn't convert `limit` to integer: %s.", err) + } + req.Limit = &limit + } + return nil +} + +func (a *deadLetterJobListEndpoint[TTx]) Execute(ctx context.Context, req *deadLetterJobListRequest) (*listResponse[deadLetterJobListItem], error) { + if err := a.requireDeadLetterJobs(ctx); err != nil { + return nil, err + } + jobs, err := a.uiDriver.GetExecutor(a.DB).JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{ + Filters: riveruidriver.JobDeadLetterFilters{IDs: req.IDs, Kinds: req.Kinds, Priorities: req.Priorities, Queues: req.Queues, Tags: req.Tags}, + Max: ptrutil.ValOrDefault(req.Limit, 20), + Schema: a.Client.Schema(), + }) + if err != nil { + return nil, fmt.Errorf("error listing dead letter jobs: %w", err) + } + return listResponseFrom(sliceutil.Map(jobs, func(job *riveruidriver.JobDeadLetterListResult) *deadLetterJobListItem { + return &deadLetterJobListItem{JobDeadLetterListResult: job, ID: job.ID} + })), nil +} + +type deadLetterJobRequest struct { + JobID int64 `json:"-" validate:"required,min=1"` +} + +func (req *deadLetterJobRequest) ExtractRaw(r *http.Request) error { + jobID, err := strconv.ParseInt(r.PathValue("job_id"), 10, 64) + if err != nil { + return apierror.NewBadRequestf("Couldn't convert job ID to int64: %s.", err) + } + req.JobID = jobID + return nil +} + +type deadLetterJobGetEndpoint[TTx any] struct { + ProAPIBundle[TTx] + apiendpoint.Endpoint[deadLetterJobRequest, deadLetterJob] +} + +func NewDeadLetterJobGetEndpoint[TTx any](bundle ProAPIBundle[TTx]) *deadLetterJobGetEndpoint[TTx] { + return &deadLetterJobGetEndpoint[TTx]{ProAPIBundle: bundle} +} + +func (*deadLetterJobGetEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { + return &apiendpoint.EndpointMeta{Pattern: "GET /api/pro/dead-letter-jobs/{job_id}", StatusCode: http.StatusOK} +} + +func (a *deadLetterJobGetEndpoint[TTx]) Execute(ctx context.Context, req *deadLetterJobRequest) (*deadLetterJob, error) { + if err := a.requireDeadLetterJobs(ctx); err != nil { + return nil, err + } + job, err := a.DB.JobDeadLetterGetByID(ctx, &driver.JobDeadLetterGetByIDParams{ID: req.JobID, Schema: a.Client.Schema()}) + if errors.Is(err, rivertype.ErrNotFound) { + return nil, apierror.NewNotFoundf("Dead letter job with ID %d not found. It may already have been retried.", req.JobID) + } + if err != nil { + return nil, fmt.Errorf("error getting dead letter job: %w", err) + } + return serializableDeadLetterJob(job), nil +} + +type deadLetterJobRetryEndpoint[TTx any] struct { + ProAPIBundle[TTx] + apiendpoint.Endpoint[deadLetterJobRequest, deadLetterJob] +} + +func NewDeadLetterJobRetryEndpoint[TTx any](bundle ProAPIBundle[TTx]) *deadLetterJobRetryEndpoint[TTx] { + return &deadLetterJobRetryEndpoint[TTx]{ProAPIBundle: bundle} +} + +func (*deadLetterJobRetryEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { + return &apiendpoint.EndpointMeta{Pattern: "POST /api/pro/dead-letter-jobs/{job_id}/retry", StatusCode: http.StatusOK} +} + +func (a *deadLetterJobRetryEndpoint[TTx]) Execute(ctx context.Context, req *deadLetterJobRequest) (*deadLetterJob, error) { + if err := a.requireDeadLetterJobs(ctx); err != nil { + return nil, err + } + return dbutil.WithTxV(ctx, a.DB, func(ctx context.Context, execTx riverdriver.ExecutorTx) (*deadLetterJob, error) { + result, err := a.Client.JobDeadLetterRetryTx(ctx, a.Driver.UnwrapTx(execTx), req.JobID) + if errors.Is(err, rivertype.ErrNotFound) { + return nil, apierror.NewNotFoundf("Dead letter job with ID %d not found. It may already have been retried.", req.JobID) + } + if err != nil { + return nil, fmt.Errorf("error retrying dead letter job: %w", err) + } + // A skipped insert must roll back the deletion from the dead letter queue. + if result.UniqueSkippedAsDuplicate { + return nil, &apierror.APIError{ + Message: "Job can't be retried because another active job has the same unique properties. Wait for it to finish before retrying.", + StatusCode: http.StatusConflict, + } + } + return serializableDeadLetterJob(result.Job), nil + }) +} + +type deadLetterAutocompleteEndpoint[TTx any] struct { + ProAPIBundle[TTx] + apiendpoint.Endpoint[deadLetterAutocompleteRequest, listResponse[string]] + + uiDriver riveruidriver.Driver +} + +func NewDeadLetterAutocompleteEndpoint[TTx any](bundle ProAPIBundle[TTx]) *deadLetterAutocompleteEndpoint[TTx] { + return &deadLetterAutocompleteEndpoint[TTx]{ProAPIBundle: bundle, uiDriver: deadLetterUIDriver(bundle.Driver.DatabaseName())} +} + +func (*deadLetterAutocompleteEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { + return &apiendpoint.EndpointMeta{Pattern: "GET /api/pro/dead-letter-jobs/autocomplete", StatusCode: http.StatusOK} +} + +type deadLetterAutocompleteRequest struct { + Facet string `json:"-" validate:"required,oneof=job_kind queue_name"` + Match string `json:"-" validate:"max=1024"` + Exclude []string `json:"-" validate:"omitempty,max=100,dive,max=1024"` +} + +func (req *deadLetterAutocompleteRequest) ExtractRaw(r *http.Request) error { + query := r.URL.Query() + req.Facet, req.Match, req.Exclude = query.Get("facet"), query.Get("match"), query["exclude"] + return nil +} + +func (a *deadLetterAutocompleteEndpoint[TTx]) Execute(ctx context.Context, req *deadLetterAutocompleteRequest) (*listResponse[string], error) { + if err := a.requireDeadLetterJobs(ctx); err != nil { + return nil, err + } + values, err := a.uiDriver.GetExecutor(a.DB).JobDeadLetterAutocomplete(ctx, &riveruidriver.JobDeadLetterAutocompleteParams{ + Facet: req.Facet, Match: req.Match, Exclude: req.Exclude, Max: 100, Schema: a.Client.Schema(), + }) + if err != nil { + return nil, err + } + return listResponseFrom(sliceutil.Map(values, func(value string) *string { return &value })), nil +} diff --git a/riverproui/internal/prohandler/dead_letter_endpoints_test.go b/riverproui/internal/prohandler/dead_letter_endpoints_test.go new file mode 100644 index 00000000..f149d285 --- /dev/null +++ b/riverproui/internal/prohandler/dead_letter_endpoints_test.go @@ -0,0 +1,240 @@ +package prohandler + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/riverqueue/apiframe/apitest" + "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/rivertype" + + "riverqueue.com/riverpro/driver" + + "riverqueue.com/riverui/internal/riverinternaltest/testfactory" +) + +func insertDeadLetterJob(ctx context.Context, t *testing.T, bundle *setupEndpointTestBundle) *rivertype.JobRow { + t.Helper() + params := testfactory.Job_Build(t, &testfactory.JobOpts{ + Attempt: new(3), + EncodedArgs: []byte(`{"id":1970670598291982290}`), + FinalizedAt: new(time.Now()), + Priority: new(1), + Queue: new("mail"), + State: new(rivertype.JobStateDiscarded), + Errors: [][]byte{[]byte(`{"at":"2026-10-21T00:00:00Z","attempt":3,"error":"delivery failed"}`)}, + }) + params.Schema = bundle.schema + job, err := bundle.exec.JobInsertFull(ctx, params) + require.NoError(t, err) + job, err = bundle.exec.JobDeadLetterMoveByID(ctx, &driver.JobDeadLetterMoveByIDParams{ID: job.ID, Schema: bundle.schema}) + require.NoError(t, err) + return job +} + +func TestDeadLetterJobList(t *testing.T) { + t.Parallel() + ctx := t.Context() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterJobListEndpoint) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{}) + require.NoError(t, err) + require.Empty(t, resp.Data) + encoded, err := json.Marshal(resp) + require.NoError(t, err) + require.JSONEq(t, `{"data":[]}`, string(encoded)) + + job := insertDeadLetterJob(ctx, t, bundle) + liveParams := testfactory.Job_Build(t, nil) + liveParams.Schema = bundle.schema + _, err = bundle.exec.JobInsertFull(ctx, liveParams) + require.NoError(t, err) + + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{}) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + require.Equal(t, job.ID, resp.Data[0].ID) + require.Equal(t, "mail", resp.Data[0].Queue) + require.Equal(t, job.MaxAttempts, resp.Data[0].MaxAttempts) + require.WithinDuration(t, *job.FinalizedAt, *resp.Data[0].FinalizedAt, time.Millisecond) + encoded, err = json.Marshal(resp) + require.NoError(t, err) + require.Contains(t, string(encoded), fmt.Sprintf(`"id":"%d"`, job.ID)) + for range 24 { + insertDeadLetterJob(ctx, t, bundle) + } + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{}) + require.NoError(t, err) + require.Len(t, resp.Data, 20) + require.Equal(t, job.ID, resp.Data[0].ID) + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Limit: new(2)}) + require.NoError(t, err) + require.Len(t, resp.Data, 2) + require.Less(t, resp.Data[0].ID, resp.Data[1].ID) + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Limit: new(40)}) + require.NoError(t, err) + require.Len(t, resp.Data, 25) + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{ + IDs: []int64{job.ID}, Kinds: []string{job.Kind}, Priorities: []int16{1}, Queues: []string{"mail"}, Limit: new(1), + }) + require.NoError(t, err) + require.Len(t, resp.Data, 1) + require.Equal(t, job.ID, resp.Data[0].ID) + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Queues: []string{"missing"}}) + require.NoError(t, err) + require.Empty(t, resp.Data) + for _, invalid := range []*deadLetterJobListRequest{ + {IDs: []int64{0}}, + {IDs: []int64{-1}}, + {IDs: make([]int64, 1001)}, + {Priorities: []int16{0}}, + {Priorities: []int16{5}}, + {Kinds: []string{""}}, + {Queues: []string{strings.Repeat("x", 1025)}}, + {Tags: make([]string, 101)}, + } { + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), invalid) + require.Error(t, err) + } + for _, limit := range []int{-1, 0, 201} { + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Limit: &limit}) + require.Error(t, err) + } +} + +func TestDeadLetterJobGet(t *testing.T) { + t.Parallel() + ctx := t.Context() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterJobGetEndpoint) + job := insertDeadLetterJob(ctx, t, bundle) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID}) + require.NoError(t, err) + require.Equal(t, job.ID, resp.ID) + require.Len(t, resp.Errors, 1) + require.Equal(t, "delivery failed", resp.Errors[0].Error) + + _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID + 1}) + require.EqualError(t, err, fmt.Sprintf("Dead letter job with ID %d not found. It may already have been retried.", job.ID+1)) +} + +func TestDeadLetterJobRetry(t *testing.T) { + t.Parallel() + ctx := t.Context() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterJobRetryEndpoint) + job := insertDeadLetterJob(ctx, t, bundle) + + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID}) + require.NoError(t, err) + require.NotEqual(t, job.ID, resp.ID) + require.Equal(t, string(rivertype.JobStateAvailable), resp.State) + require.Zero(t, resp.Attempt) + require.Empty(t, resp.Errors) + require.Equal(t, string(job.EncodedArgs), resp.Args) + require.Equal(t, job.Queue, resp.Queue) + + _, err = bundle.exec.JobDeadLetterGetByID(ctx, &driver.JobDeadLetterGetByIDParams{ID: job.ID, Schema: bundle.schema}) + require.ErrorIs(t, err, rivertype.ErrNotFound) + live, err := bundle.exec.JobGetByID(ctx, &riverdriver.JobGetByIDParams{ID: resp.ID, Schema: bundle.schema}) + require.NoError(t, err) + require.Equal(t, rivertype.JobStateAvailable, live.State) + + _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID}) + require.EqualError(t, err, fmt.Sprintf("Dead letter job with ID %d not found. It may already have been retried.", job.ID)) + _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: live.ID}) + require.EqualError(t, err, fmt.Sprintf("Dead letter job with ID %d not found. It may already have been retried.", live.ID)) +} + +func TestDeadLetterJobRetryUniqueConflict(t *testing.T) { + t.Parallel() + ctx := t.Context() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterJobRetryEndpoint) + + params := testfactory.Job_Build(t, nil) + params.Schema = bundle.schema + params.UniqueKey = []byte("dead-letter-unique-job") + params.UniqueStates = 0xff + job, err := bundle.exec.JobInsertFull(ctx, params) + require.NoError(t, err) + _, err = bundle.exec.JobDeadLetterMoveByID(ctx, &driver.JobDeadLetterMoveByIDParams{ID: job.ID, Schema: bundle.schema}) + require.NoError(t, err) + _, err = bundle.exec.JobInsertFull(ctx, params) + require.NoError(t, err) + + _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID}) + require.ErrorContains(t, err, "another active job has the same unique properties") + _, err = bundle.exec.JobDeadLetterGetByID(ctx, &driver.JobDeadLetterGetByIDParams{ID: job.ID, Schema: bundle.schema}) + require.NoError(t, err, "a conflict must retain the dead letter job") +} + +func TestDeadLetterJobRequest(t *testing.T) { + t.Parallel() + for _, id := range []string{"invalid", "9223372036854775808"} { + t.Run(id, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil) + req.SetPathValue("job_id", id) + require.Error(t, (&deadLetterJobRequest{}).ExtractRaw(req)) + }) + } +} + +func TestDeadLetterJobSerialization(t *testing.T) { + t.Parallel() + encoded, err := json.Marshal(serializableDeadLetterJob(&rivertype.JobRow{ID: 1970670598291982290})) + require.NoError(t, err) + require.Contains(t, string(encoded), `"id":"1970670598291982290"`) + require.Contains(t, string(encoded), `"errors":[]`) +} + +func TestDeadLetterJobListRequest(t *testing.T) { + t.Parallel() + for _, query := range []string{"limit=invalid", "limit=1.5", "limit=9223372036854775808", "ids=bad", "ids=1.5", "ids=9223372036854775808", "priorities=bad", "priorities=32768"} { + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/?"+query, nil) + require.Error(t, (&deadLetterJobListRequest{}).ExtractRaw(req)) + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/?limit=40&ids=1970670598291982290&ids=9223372036854775807&kinds=report&kinds=email&priorities=1&priorities=3&queues=mail&tags=urgent&tags=batch", nil) + var parsed deadLetterJobListRequest + require.NoError(t, parsed.ExtractRaw(req)) + require.Equal(t, new(40), parsed.Limit) + require.Equal(t, []int64{1970670598291982290, 9223372036854775807}, parsed.IDs) + require.Equal(t, []int16{1, 3}, parsed.Priorities) + require.Equal(t, []string{"report", "email"}, parsed.Kinds) + require.Equal(t, []string{"mail"}, parsed.Queues) + require.Equal(t, []string{"urgent", "batch"}, parsed.Tags) +} + +func TestDeadLetterAutocomplete(t *testing.T) { + t.Parallel() + ctx := t.Context() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterAutocompleteEndpoint) + job := insertDeadLetterJob(ctx, t, bundle) + for _, tt := range []struct { + request deadLetterAutocompleteRequest + want []string + }{ + {request: deadLetterAutocompleteRequest{Facet: "job_kind"}, want: []string{job.Kind}}, + {request: deadLetterAutocompleteRequest{Facet: "queue_name", Match: "MA"}, want: []string{"mail"}}, + {request: deadLetterAutocompleteRequest{Facet: "queue_name", Exclude: []string{"mail"}}, want: []string{}}, + } { + resp, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &tt.request) + require.NoError(t, err) + got := make([]string, 0, len(resp.Data)) + for _, value := range resp.Data { + got = append(got, *value) + } + require.Equal(t, tt.want, got) + } + for _, facet := range []string{"", "tags", "id"} { + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterAutocompleteRequest{Facet: facet}) + require.Error(t, err) + } +} diff --git a/riverproui/pro_handler_test.go b/riverproui/pro_handler_test.go index e0f55d64..9adf41d0 100644 --- a/riverproui/pro_handler_test.go +++ b/riverproui/pro_handler_test.go @@ -10,6 +10,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -20,6 +21,7 @@ import ( "github.com/riverqueue/river/riverdbtest" "github.com/riverqueue/river/riverdriver" "github.com/riverqueue/river/rivershared/riversharedtest" + "github.com/riverqueue/river/rivertype" "riverqueue.com/riverpro" "riverqueue.com/riverpro/driver" @@ -167,6 +169,15 @@ func runProHandlerRequests(t *testing.T, schema string, exec riverdriver.Executo "workflow_id": workflowID2.String(), })}) + deadJob := testfactory.Job(ctx, t, exec, &testfactory.JobOpts{State: new(rivertype.JobStateDiscarded), FinalizedAt: new(time.Now())}) + _, err := proExec.JobDeadLetterMoveByID(ctx, &driver.JobDeadLetterMoveByIDParams{ID: deadJob.ID}) + require.NoError(t, err) + makeAPICall(t, "DeadLetterJobList", http.MethodGet, "/api/pro/dead-letter-jobs", nil) + makeAPICall(t, "DeadLetterJobListFiltered", http.MethodGet, fmt.Sprintf("/api/pro/dead-letter-jobs?ids=%d&priorities=1&queues=default", deadJob.ID), nil) + makeAPICall(t, "DeadLetterAutocomplete", http.MethodGet, "/api/pro/dead-letter-jobs/autocomplete?facet=job_kind&match=fake&exclude=other", nil) + makeAPICall(t, "DeadLetterJobGet", http.MethodGet, fmt.Sprintf("/api/pro/dead-letter-jobs/%d", deadJob.ID), nil) + makeAPICall(t, "DeadLetterJobRetry", http.MethodPost, fmt.Sprintf("/api/pro/dead-letter-jobs/%d/retry", deadJob.ID), nil) + // Verify OSS features endpoint is mounted and returns success even w/ Pro bundle: makeAPICall(t, "FeaturesGet", http.MethodGet, "/api/features", nil) @@ -248,6 +259,7 @@ func TestProMountedEndpointResponses(t *testing.T) { // Static flags always true; dynamic flags should also be true because pro migrations run for tests expectedExtensions := map[string]bool{ + "dead_letter_jobs": true, // dynamic "durable_periodic_jobs": true, // dynamic "has_producer_table": true, // dynamic "has_sequence_table": true, // dynamic diff --git a/src/components/DeadLetterJobDetail.tsx b/src/components/DeadLetterJobDetail.tsx new file mode 100644 index 00000000..825df79a --- /dev/null +++ b/src/components/DeadLetterJobDetail.tsx @@ -0,0 +1,89 @@ +import { ArrowUturnLeftIcon } from "@heroicons/react/24/outline"; +import { Job } from "@services/jobs"; + +import { Button } from "./Button"; +import JobAttempts from "./JobAttempts"; +import JSONTextView from "./JSONTextView"; +import JSONView from "./JSONView"; +import RelativeTimeFormatter from "./RelativeTimeFormatter"; +import TopNavTitleOnly from "./TopNavTitleOnly"; + +export default function DeadLetterJobDetail({ + job, + retry, + retryPending, +}: { + job: Job; + retry: () => void; + retryPending: boolean; +}) { + return ( + <> + +
+ +
+
+

{job.kind}

+

+ ID: {job.id.toString()} +

+
+ +
+

+ Retrying removes this job from the dead letter queue and enqueues a + new job with the same args and a fresh attempt history. +

+
+
+
Queue
+
{job.queue}
+
+
+
Attempts
+
+ {job.attempt} / {job.maxAttempts} +
+
+
+
Created
+
+ +
+
+
+
Discarded
+
+ {job.finalizedAt ? ( + + ) : ( + "–" + )} +
+
+
+
+
+
Args
+
+ +
+
+
+
Metadata
+
+ +
+
+ +
+
+ + ); +} diff --git a/src/components/DeadLetterJobList.tsx b/src/components/DeadLetterJobList.tsx new file mode 100644 index 00000000..61312a92 --- /dev/null +++ b/src/components/DeadLetterJobList.tsx @@ -0,0 +1,158 @@ +import { fetchSuggestions } from "@components/job-search/api"; +import { + Filter, + FilterTypeId, + JobSearch, +} from "@components/job-search/JobSearch"; +import { + DeadLetterJobListItem, + fetchDeadLetterSuggestions, +} from "@services/deadLetterJobs"; +import { Link } from "@tanstack/react-router"; + +import { Button } from "./Button"; +import RelativeTimeFormatter from "./RelativeTimeFormatter"; +import TopNavTitleOnly from "./TopNavTitleOnly"; + +export default function DeadLetterJobList({ + canShowFewer, + canShowMore, + filterError, + initialFilters, + jobs, + loading, + onFiltersChange, + showFewer, + showMore, +}: { + canShowFewer: boolean; + canShowMore: boolean; + filterError?: string; + initialFilters: Filter[]; + jobs: DeadLetterJobListItem[]; + loading: boolean; + onFiltersChange: (filters: Filter[]) => void; + showFewer: () => void; + showMore: () => void; +}) { + return ( + <> + +
+

+ Discarded jobs retained in the dead letter queue. Select a job to + inspect its failures and retry it. +

+ + {filterError && ( +

+ {filterError} +

+ )} + {loading ? ( +

Loading…

+ ) : jobs.length === 0 ? ( +

+ {initialFilters.length + ? "No dead letter jobs match these filters." + : "No dead letter jobs."} +

+ ) : ( + <> +
+ + + + + + + + + + + {jobs.map((job) => ( + + + + + + + ))} + +
+ Job + + Queue + + Attempts + + Discarded +
+ + {job.kind}{" "} + #{job.id.toString()} + + {job.queue} + {job.attempt} / {job.maxAttempts} + + {job.finalizedAt ? ( + + ) : ( + "–" + )} +
+
+ + + )} +
+ + ); +} + +async function fetchSuggestionsForDeadLetters( + type: FilterTypeId, + query: string, + selectedValues: string[], +): Promise { + if (type === FilterTypeId.KIND || type === FilterTypeId.QUEUE) { + return fetchDeadLetterSuggestions( + type === FilterTypeId.KIND ? "job_kind" : "queue_name", + query, + selectedValues, + ); + } + return fetchSuggestions(type, query, selectedValues); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 39844594..57c7e9c0 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -8,6 +8,7 @@ import { TransitionChild, } from "@headlessui/react"; import { + ArchiveBoxIcon, CalendarDaysIcon, Cog6ToothIcon, InboxStackIcon, @@ -15,7 +16,7 @@ import { RectangleGroupIcon, XMarkIcon, } from "@heroicons/react/24/outline"; -import { Link } from "@tanstack/react-router"; +import { Link, useRouterState } from "@tanstack/react-router"; import { Fragment, PropsWithChildren, useMemo } from "react"; type LayoutProps = PropsWithChildren; @@ -24,6 +25,9 @@ const Layout = ({ children }: LayoutProps) => { const { open: sidebarOpen, setOpen: setSidebarOpen } = useSidebarSetting(); const { features } = useFeatures(); + const inJobsPane = useRouterState({ + select: ({ matches }) => matches.some((match) => match.routeId === "/jobs"), + }); const navigation = useMemo( () => @@ -32,9 +36,26 @@ const Layout = ({ children }: LayoutProps) => { href: "/jobs", icon: QueueListIcon, name: "Jobs", - search: {}, + // Explicitly clear retained Jobs filters when entering from another pane. + search: inJobsPane + ? {} + : { + id: undefined, + kind: undefined, + limit: undefined, + priority: undefined, + queue: undefined, + tags: undefined, + }, }, { href: "/queues", icon: InboxStackIcon, name: "Queues" }, + { + hidden: !features.deadLetterJobs, + href: "/dead-letter-jobs", + icon: ArchiveBoxIcon, + name: "Dead Letters", + search: {}, + }, { hidden: !features.workflowQueries, href: "/workflows", @@ -48,7 +69,12 @@ const Layout = ({ children }: LayoutProps) => { name: "Periodic Jobs", }, ].filter((item) => item.hidden === undefined || item.hidden === false), - [features.durablePeriodicJobs, features.workflowQueries], + [ + features.deadLetterJobs, + features.durablePeriodicJobs, + features.workflowQueries, + inJobsPane, + ], ); return ( diff --git a/src/components/WorkflowDetail.test.tsx b/src/components/WorkflowDetail.test.tsx index 7d6d4d32..06afe36e 100644 --- a/src/components/WorkflowDetail.test.tsx +++ b/src/components/WorkflowDetail.test.tsx @@ -41,6 +41,7 @@ vi.mock("@components/workflow-diagram/WorkflowDiagram", () => ({ })); const features: Features = { + deadLetterJobs: false, durablePeriodicJobs: false, hasProducerTable: true, hasSequenceTable: false, diff --git a/src/components/job-search/JobSearch.test.tsx b/src/components/job-search/JobSearch.test.tsx index 36e83522..684081b0 100644 --- a/src/components/job-search/JobSearch.test.tsx +++ b/src/components/job-search/JobSearch.test.tsx @@ -121,6 +121,55 @@ describe("JobSearch", () => { expect(searchInput).toHaveAttribute("autoComplete", "off"); }); + it("preserves ongoing typing when a parent acknowledges an earlier filter change", async () => { + const onFiltersChange = vi.fn(); + const { rerender } = render( + , + ); + const input = screen.getByTestId("job-search-input"); + fireEvent.change(input, { target: { value: "kind:batch" } }); + await waitFor(() => + expect(onFiltersChange).toHaveBeenCalledWith([ + expect.objectContaining({ values: ["batch"] }), + ]), + ); + fireEvent.change(input, { target: { value: "kind:batch queue:ma" } }); + rerender( + , + ); + expect(input).toHaveValue("kind:batch queue:ma"); + }); + + it("keeps initial filters uncontrolled unless synchronization is enabled", () => { + const { rerender } = render(); + const input = screen.getByTestId("job-search-input"); + fireEvent.change(input, { target: { value: "kind:batch" } }); + rerender( + , + ); + expect(input).toHaveValue("kind:batch"); + }); + it("notifies parent of filter changes", async () => { const onFiltersChange = vi.fn(); render(); diff --git a/src/components/job-search/JobSearch.tsx b/src/components/job-search/JobSearch.tsx index edb10e44..fc7b2155 100644 --- a/src/components/job-search/JobSearch.tsx +++ b/src/components/job-search/JobSearch.tsx @@ -39,6 +39,8 @@ export interface JobSearchProps { * @param filters - The updated list of filters */ onFiltersChange?: (filters: JobFilter[]) => void; + /** Restore external filter changes, such as browser Back, without resetting ongoing edits. */ + syncInitialFilters?: boolean; } export function JobSearch({ @@ -46,6 +48,7 @@ export function JobSearch({ fetchSuggestions = defaultFetchSuggestions, initialFilters = [], onFiltersChange, + syncInitialFilters = false, }: JobSearchProps) { const inputRef = useRef(null); const containerRef = useRef(null); @@ -67,6 +70,7 @@ export function JobSearch({ fetchSuggestions, initialFilters, onFiltersChange, + syncInitialFilters, }); // Handle clicking outside to close suggestions @@ -180,6 +184,7 @@ export function JobSearch({ {inputValue && (
+
+ + ) : ( + + ), + component: DeadLetterJobComponent, +}); + +export function DeadLetterJobComponent() { + const { jobId } = Route.useParams(); + const { jobQueryOptions } = Route.useRouteContext(); + const navigate = Route.useNavigate(); + const queryClient = useQueryClient(); + const { intervalMs } = useRefreshSetting(); + const retry = useMutation({ + mutationFn: retryDeadLetterJob, + throwOnError: false, + onMutate: () => + queryClient.cancelQueries({ queryKey: getDeadLetterJobKey(jobId) }), + onError: (error) => { + toastError({ message: "Job retry failed", subtext: error.message }); + }, + onSuccess: async (job) => { + queryClient.setQueryData(getJobKey(job.id), job); + toastSuccess({ message: "Job enqueued for retry", duration: 2000 }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: listDeadLetterJobsKey() }), + queryClient.invalidateQueries({ queryKey: ["listJobs"] }), + queryClient.invalidateQueries({ queryKey: countsByStateKey() }), + ]); + await navigate({ to: "/jobs/$jobId", params: { jobId: job.id } }); + queryClient.removeQueries({ queryKey: getDeadLetterJobKey(jobId) }); + }, + }); + const query = useQuery({ + ...jobQueryOptions, + ...refreshQueryOptions(intervalMs), + // Retrying removes this row. Don't poll it while navigating to the new job. + enabled: !retry.isPending && !retry.isSuccess, + throwOnError: true, + }); + + if (!query.data) return

Loading…

; + return ( + retry.mutate(jobId)} + retryPending={retry.isPending || retry.isSuccess} + /> + ); +} diff --git a/src/routes/dead-letter-jobs/index.schema.ts b/src/routes/dead-letter-jobs/index.schema.ts new file mode 100644 index 00000000..57bf8455 --- /dev/null +++ b/src/routes/dead-letter-jobs/index.schema.ts @@ -0,0 +1,94 @@ +import { Filter, FilterTypeId } from "@components/job-search/JobSearch"; +import { AVAILABLE_FILTERS } from "@components/job-search/types"; +import { + DeadLetterJobFilters, + defaultDeadLetterJobsLimit, + maximumDeadLetterJobsLimit, +} from "@services/deadLetterJobs"; +import { z } from "zod"; + +const values = z.union([z.string(), z.array(z.string())]).transform((value) => { + const result = [...new Set(Array.isArray(value) ? value : [value])] + .filter(Boolean) + .sort(); + return result.length ? result : undefined; +}); +const strings = values + .pipe(z.array(z.string().max(1024)).max(100).optional()) + .optional(); +const ids = values + .pipe( + z + .array( + z + .string() + .regex(/^[1-9]\d*$/, "Job IDs must be positive integers.") + .refine( + (id) => + /^[1-9]\d*$/.test(id) && + id.length <= 19 && + BigInt(id) <= 9223372036854775807n, + "Job ID is too large.", + ), + ) + .max(1000) + .optional(), + ) + .optional(); +const priorities = values + .pipe( + z + .array(z.string().regex(/^[1-4]$/, "Priorities must be between 1 and 4.")) + .max(4) + .optional(), + ) + .optional(); + +export const deadLetterJobSearchSchema = z.object({ + id: ids, + kind: strings, + limit: z.coerce + .number() + .int() + .min(defaultDeadLetterJobsLimit) + .max(maximumDeadLetterJobsLimit) + .default(defaultDeadLetterJobsLimit) + .catch(defaultDeadLetterJobsLimit), + priority: priorities, + queue: strings, + tags: strings, +}); + +export type DeadLetterJobSearch = z.output; + +export const searchToDeadLetterFilters = ( + search: DeadLetterJobSearch, +): DeadLetterJobFilters => ({ + ids: search.id, + kinds: search.kind, + priorities: search.priority, + queues: search.queue, + tags: search.tags, +}); + +export const searchToInputFilters = (search: DeadLetterJobSearch): Filter[] => + AVAILABLE_FILTERS.flatMap((filter) => { + const values = search[filter.id]; + return values?.length + ? [{ id: filter.id, match: filter.match, typeId: filter.id, values }] + : []; + }); + +export const inputFiltersToSearch = (filters: Filter[]) => { + const result: Record = { + id: undefined, + kind: undefined, + priority: undefined, + queue: undefined, + tags: undefined, + }; + for (const filter of filters) { + if (filter.values.length) result[filter.typeId] = filter.values; + } + return result; +}; diff --git a/src/routes/dead-letter-jobs/index.tsx b/src/routes/dead-letter-jobs/index.tsx new file mode 100644 index 00000000..e392d753 --- /dev/null +++ b/src/routes/dead-letter-jobs/index.tsx @@ -0,0 +1,103 @@ +import DeadLetterJobList from "@components/DeadLetterJobList"; +import { Filter } from "@components/job-search/JobSearch"; +import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; +import { + defaultDeadLetterJobsLimit, + listDeadLetterJobs, + listDeadLetterJobsKey, + maximumDeadLetterJobsLimit, +} from "@services/deadLetterJobs"; +import { queryOptions, useQuery } from "@tanstack/react-query"; +import { createFileRoute, stripSearchParams } from "@tanstack/react-router"; +import { useCallback, useMemo, useState } from "react"; + +import { + DeadLetterJobSearch, + deadLetterJobSearchSchema, + inputFiltersToSearch, + searchToDeadLetterFilters, + searchToInputFilters, +} from "./index.schema"; + +const jobsQueryOptions = (search: DeadLetterJobSearch) => + queryOptions({ + queryFn: listDeadLetterJobs, + queryKey: listDeadLetterJobsKey( + search.limit, + searchToDeadLetterFilters(search), + ), + }); + +export const Route = createFileRoute("/dead-letter-jobs/")({ + validateSearch: deadLetterJobSearchSchema, + search: { + middlewares: [stripSearchParams({ limit: defaultDeadLetterJobsLimit })], + }, + loaderDeps: ({ search }) => search, + loader: ({ context: { queryClient }, deps: search }) => + queryClient.ensureQueryData(jobsQueryOptions(search)), + component: DeadLetterJobsIndexComponent, +}); + +function DeadLetterJobsIndexComponent() { + const search = Route.useLoaderDeps(); + const { limit } = search; + const [filterError, setFilterError] = useState(); + const initialFilters = useMemo(() => searchToInputFilters(search), [search]); + const navigate = Route.useNavigate(); + const { intervalMs } = useRefreshSetting(); + const query = useQuery({ + ...jobsQueryOptions(search), + ...refreshQueryOptions(intervalMs), + throwOnError: true, + }); + const onFiltersChange = useCallback( + (filters: Filter[]) => { + const parsed = deadLetterJobSearchSchema.safeParse({ + ...search, + ...inputFiltersToSearch(filters), + }); + if (!parsed.success) { + setFilterError(parsed.error.issues[0]?.message ?? "Invalid filters."); + return; + } + setFilterError(undefined); + if (JSON.stringify(parsed.data) === JSON.stringify(search)) return; + void navigate({ replace: true, search: parsed.data }); + }, + [navigate, search], + ); + + return ( + defaultDeadLetterJobsLimit} + canShowMore={ + limit < maximumDeadLetterJobsLimit && (query.data?.length ?? 0) >= limit + } + filterError={filterError} + initialFilters={initialFilters} + jobs={query.data ?? []} + loading={query.isLoading} + onFiltersChange={onFiltersChange} + showFewer={() => + navigate({ + replace: true, + search: (old) => ({ + ...old, + limit: Math.max(defaultDeadLetterJobsLimit, limit - 20), + }), + }) + } + showMore={() => + navigate({ + replace: true, + search: (old) => ({ + ...old, + limit: Math.min(maximumDeadLetterJobsLimit, limit + 20), + }), + }) + } + /> + ); +} diff --git a/src/services/deadLetterJobs.test.ts b/src/services/deadLetterJobs.test.ts new file mode 100644 index 00000000..c4e73566 --- /dev/null +++ b/src/services/deadLetterJobs.test.ts @@ -0,0 +1,127 @@ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, expect, test, vi } from "vitest"; + +import { + fetchDeadLetterSuggestions, + getDeadLetterJob, + getDeadLetterJobKey, + listDeadLetterJobs, + listDeadLetterJobsKey, + retryDeadLetterJob, +} from "./deadLetterJobs"; + +const job = { + args: '{"id":1970670598291982290}', + attempt: 3, + attempted_by: [], + created_at: "2026-10-21T00:00:00Z", + errors: [], + finalized_at: "2026-10-21T10:00:00Z", + id: "1970670598291982290", + kind: "send_email", + max_attempts: 3, + metadata: {}, + priority: 1, + queue: "mail", + scheduled_at: "2026-10-21T00:00:00Z", + state: "discarded", + tags: [], +}; + +afterEach(() => vi.unstubAllGlobals()); + +test("requests a bounded list without losing ID precision", async () => { + const fetch = vi.fn().mockResolvedValue(Response.json({ data: [job] })); + vi.stubGlobal("fetch", fetch); + const client = new QueryClient(); + const jobs = await client.fetchQuery({ + queryFn: listDeadLetterJobs, + queryKey: listDeadLetterJobsKey(40), + }); + expect(jobs[0].id).toBe(1970670598291982290n); + expect(jobs[0].finalizedAt).toEqual(new Date(job.finalized_at)); + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("/pro/dead-letter-jobs?limit=40"), + expect.objectContaining({ signal: expect.any(AbortSignal) }), + ); +}); + +test("gets a dead letter job and retries it using the Pro endpoints", async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(Response.json(job)) + .mockResolvedValueOnce( + Response.json({ + ...job, + attempt: 0, + id: "1970670598291982291", + state: "available", + }), + ); + vi.stubGlobal("fetch", fetch); + const client = new QueryClient(); + const original = await client.fetchQuery({ + queryFn: getDeadLetterJob, + queryKey: getDeadLetterJobKey(BigInt(job.id)), + }); + expect(original.createdAt).toEqual(new Date(job.created_at)); + expect(original.errors).toEqual([]); + expect(original.argsRaw).toBe(job.args); + const retried = await client + .getMutationCache() + .build(client, { mutationFn: retryDeadLetterJob }) + .execute(original.id); + expect(retried.id).toBe(1970670598291982291n); + expect(fetch).toHaveBeenLastCalledWith( + expect.stringContaining(`/pro/dead-letter-jobs/${job.id}/retry`), + expect.objectContaining({ method: "POST" }), + ); +}); + +test("sends repeated filter parameters and separates filtered caches", async () => { + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ data: [] })); + vi.stubGlobal("fetch", fetch); + const client = new QueryClient(); + const filters = { + ids: [job.id, "9223372036854775807"], + kinds: ["send_email", "report"], + priorities: ["1", "3"], + queues: ["mail"], + tags: ["urgent", "customer&partner"], + }; + await client.fetchQuery({ + queryFn: listDeadLetterJobs, + queryKey: listDeadLetterJobsKey(40, filters), + }); + const request = fetch.mock.calls[0][0]; + expect(typeof request).toBe("string"); + const url = new URL(String(request), "http://localhost"); + expect(url.searchParams.get("limit")).toBe("40"); + for (const [key, values] of Object.entries(filters)) + expect(url.searchParams.getAll(key)).toEqual(values); + expect(client.getQueryData(listDeadLetterJobsKey(40))).toBeUndefined(); + expect(client.getQueryData(listDeadLetterJobsKey(40, filters))).toEqual([]); +}); + +test("fetches archive autocomplete with selected values excluded", async () => { + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ data: ["archive_report"] })); + vi.stubGlobal("fetch", fetch); + expect( + await fetchDeadLetterSuggestions("job_kind", "archive_", [ + "archive_email", + "old&job", + ]), + ).toEqual(["archive_report"]); + const url = new URL(String(fetch.mock.calls[0][0]), "http://localhost"); + expect(url.pathname).toBe("/api/pro/dead-letter-jobs/autocomplete"); + expect(url.searchParams.get("facet")).toBe("job_kind"); + expect(url.searchParams.get("match")).toBe("archive_"); + expect(url.searchParams.getAll("exclude")).toEqual([ + "archive_email", + "old&job", + ]); +}); diff --git a/src/services/deadLetterJobs.ts b/src/services/deadLetterJobs.ts new file mode 100644 index 00000000..ea1bcfa8 --- /dev/null +++ b/src/services/deadLetterJobs.ts @@ -0,0 +1,106 @@ +import type { MutationFunction, QueryFunction } from "@tanstack/react-query"; + +import { API } from "@utils/api"; + +import { apiJobToJob, Job, JobFromAPI } from "./jobs"; +import { ListResponse } from "./listResponse"; + +export type DeadLetterJobFilters = { + ids?: string[]; + kinds?: string[]; + priorities?: string[]; + queues?: string[]; + tags?: string[]; +}; +export type DeadLetterJobListItem = Pick< + Job, + "attempt" | "finalizedAt" | "id" | "kind" | "maxAttempts" | "queue" +>; + +type DeadLetterJobFromAPI = { id: string } & Omit; + +type DeadLetterJobListItemFromAPI = { + attempt: number; + finalized_at: null | string; + id: string; + kind: string; + max_attempts: number; + queue: string; +}; + +export const defaultDeadLetterJobsLimit = 20; +export const maximumDeadLetterJobsLimit = 200; + +const toJob = (job: DeadLetterJobFromAPI): Job => + apiJobToJob({ ...job, id: BigInt(job.id) }); + +export const listDeadLetterJobsKey = ( + limit?: number, + filters: DeadLetterJobFilters = {}, +) => + limit === undefined + ? (["listDeadLetterJobs"] as const) + : (["listDeadLetterJobs", { ...filters, limit }] as const); +export const getDeadLetterJobKey = (id: bigint) => + ["getDeadLetterJob", id.toString()] as const; + +export const listDeadLetterJobs: QueryFunction< + DeadLetterJobListItem[], + ReturnType +> = async ({ queryKey, signal }) => { + const limit = queryKey[1]?.limit ?? defaultDeadLetterJobsLimit; + const query = new URLSearchParams({ limit: String(limit) }); + const filters = queryKey[1]; + for (const key of ["ids", "kinds", "priorities", "queues", "tags"] as const) { + filters?.[key]?.forEach((value) => query.append(key, value)); + } + const response = await API.get>( + { + path: "/pro/dead-letter-jobs", + query, + }, + { signal }, + ); + return response.data.map((job) => ({ + attempt: job.attempt, + finalizedAt: job.finalized_at ? new Date(job.finalized_at) : undefined, + id: BigInt(job.id), + kind: job.kind, + maxAttempts: job.max_attempts, + queue: job.queue, + })); +}; + +export const getDeadLetterJob: QueryFunction< + Job, + ReturnType +> = async ({ queryKey: [, id], signal }) => { + return toJob( + await API.get( + { path: `/pro/dead-letter-jobs/${id}` }, + { signal }, + ), + ); +}; + +export const retryDeadLetterJob: MutationFunction = async (id) => { + return toJob( + await API.post( + `/pro/dead-letter-jobs/${id}/retry`, + ), + ); +}; + +export async function fetchDeadLetterSuggestions( + facet: "job_kind" | "queue_name", + match: string, + exclude: string[], +): Promise { + const query = new URLSearchParams({ facet, match }); + exclude.forEach((value) => query.append("exclude", value)); + const response = await API.get>({ + path: "/pro/dead-letter-jobs/autocomplete", + query, + }); + return response.data; +} diff --git a/src/services/features.test.ts b/src/services/features.test.ts index e1b64284..a33d9e70 100644 --- a/src/services/features.test.ts +++ b/src/services/features.test.ts @@ -6,6 +6,7 @@ describe("apiFeaturesToFeatures", () => { it("converts API features to frontend features", () => { const apiFeatures = { extensions: { + dead_letter_jobs: true, durable_periodic_jobs: true, has_producer_table: true, producer_queries: true, @@ -15,6 +16,7 @@ describe("apiFeaturesToFeatures", () => { } as const; const expected = { + deadLetterJobs: true, durablePeriodicJobs: true, hasProducerTable: true, hasSequenceTable: false, @@ -38,6 +40,7 @@ describe("apiFeaturesToFeatures", () => { } as const; const expected = { + deadLetterJobs: false, durablePeriodicJobs: false, hasProducerTable: false, hasSequenceTable: false, diff --git a/src/services/features.ts b/src/services/features.ts index e39ac40e..20254d8f 100644 --- a/src/services/features.ts +++ b/src/services/features.ts @@ -16,6 +16,7 @@ type FeaturesFromAPI = { }; const KNOWN_EXTENSIONS = [ + "dead_letter_jobs", "durable_periodic_jobs", "producer_queries", "workflow_queries", diff --git a/src/test/utils/features.ts b/src/test/utils/features.ts index ca04b79f..aed5d078 100644 --- a/src/test/utils/features.ts +++ b/src/test/utils/features.ts @@ -3,6 +3,7 @@ import type { Features } from "@services/features"; export const createFeatures = ( overrides: Partial = {}, ): Features => ({ + deadLetterJobs: false, durablePeriodicJobs: false, hasProducerTable: false, hasSequenceTable: false, From 01206865e0ecd1c410dd8e5ef48d35de92a803bc Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Sat, 24 Oct 2026 14:16:15 -0500 Subject: [PATCH 2/3] fix dead letter browsing and queries (#712) Reuse the Jobs layouts for consistent dead letter list and detail pages, with resilient filters, refresh errors, and not-found states. Show newest dead letters first, use primary-key ID lookups, and align query validation and autocomplete across Postgres and SQLite. Cancel superseded search suggestions and share common UI components. --- CHANGELOG.md | 5 +- .../river_ui_driver_interface.go | 79 ++++- .../riveruidrivertest/job_dead_letter.go | 152 +++++++-- .../riveruidrivertest/riveruidrivertest.go | 1 + .../internal/dbsqlc/river_job_dead_letter.sql | 60 +++- .../dbsqlc/river_job_dead_letter.sql.go | 75 ++++- .../internal/dbsqlc/river_queries.go | 8 + .../internal/dbsqlc/river_queries_test.go | 11 + .../river_ui_postgres_driver.go | 26 +- .../internal/dbsqlc/river_job_dead_letter.sql | 45 ++- .../dbsqlc/river_job_dead_letter.sql.go | 60 +++- .../internal/dbsqlc/river_queries.go | 8 + .../internal/dbsqlc/river_queries_test.go | 11 + .../riveruisqlite/river_ui_sqlite_driver.go | 26 +- riverproui/endpoints.go | 5 +- .../prohandler/dead_letter_endpoints.go | 91 ++++- .../prohandler/dead_letter_endpoints_test.go | 317 ++++++++++++++++-- src/components/CopyableHeadingText.tsx | 62 ++++ src/components/DeadLetterJobDetail.stories.ts | 29 ++ src/components/DeadLetterJobDetail.tsx | 90 +---- src/components/DeadLetterJobList.stories.ts | 57 ++++ src/components/DeadLetterJobList.tsx | 231 +++++++------ src/components/JobDetail.tsx | 57 +++- src/components/JobList.tsx | 64 +--- src/components/JobNotFound.tsx | 38 +-- src/components/Layout.tsx | 28 +- src/components/ListEmptyState.tsx | 37 ++ src/components/ListPagination.tsx | 39 +++ src/components/NotFoundPage.tsx | 40 +++ src/components/WorkflowDetail.tsx | 57 +--- src/components/job-search/JobSearch.test.tsx | 23 ++ src/components/job-search/JobSearch.tsx | 2 + src/components/job-search/api.ts | 9 +- src/components/job-search/hooks.ts | 10 + src/routes/__root.tsx | 16 + src/routes/dead-letter-jobs.test.tsx | 62 +++- src/routes/dead-letter-jobs/$jobId.tsx | 29 +- src/routes/dead-letter-jobs/index.schema.ts | 11 + src/routes/dead-letter-jobs/index.tsx | 9 +- src/routes/jobs/index.schema.ts | 11 + src/routes/jobs/index.tsx | 8 +- src/services/deadLetterJobs.ts | 9 +- src/services/jobs.ts | 3 + 43 files changed, 1523 insertions(+), 488 deletions(-) create mode 100644 src/components/CopyableHeadingText.tsx create mode 100644 src/components/DeadLetterJobDetail.stories.ts create mode 100644 src/components/DeadLetterJobList.stories.ts create mode 100644 src/components/ListEmptyState.tsx create mode 100644 src/components/ListPagination.tsx create mode 100644 src/components/NotFoundPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 08615905..ccf6a658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add a Pro-only dead letter queue panel with ID, kind, queue, priority, and tag filters, dead letter autocomplete, bounded database queries, and More/Fewer controls to inspect discarded jobs and retry them as new jobs. [PR #711](https://github.com/riverqueue/riverui/pull/711). +- Add a Pro-only dead letter queue panel that lists discarded jobs newest first, with ID, kind, queue, priority, and tag filters, dead letter autocomplete, and More/Fewer controls to inspect jobs and retry them as new jobs. [PR #711](https://github.com/riverqueue/riverui/pull/711). ### Changed @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Show a "Page not found" page with a link back to Jobs for unknown URLs, instead of a blank page. [PR #711](https://github.com/riverqueue/riverui/pull/711). +- Keep a long job kind on one line in the job detail heading, with a tooltip and a copy button for the full kind. [PR #711](https://github.com/riverqueue/riverui/pull/711). +- Cancel job search suggestion requests that a newer keystroke has replaced. [PR #711](https://github.com/riverqueue/riverui/pull/711). - Clear filters and pagination when switching between Jobs and Dead Letters, while preserving them when switching job states within Jobs. [PR #711](https://github.com/riverqueue/riverui/pull/711). - Show queues whose concurrency metadata is missing, `null`, or `{}` as having no concurrency override, matching how River Pro interprets them, and stop showing a zero concurrency config for producers that report none. [PR #704](https://github.com/riverqueue/riverui/pull/704). - Reject negative queue concurrency `global_limit` and `local_limit` values with a 400 error instead of storing them. [PR #704](https://github.com/riverqueue/riverui/pull/704). diff --git a/internal/riveruidriver/river_ui_driver_interface.go b/internal/riveruidriver/river_ui_driver_interface.go index 97826751..0f694c62 100644 --- a/internal/riveruidriver/river_ui_driver_interface.go +++ b/internal/riveruidriver/river_ui_driver_interface.go @@ -5,7 +5,11 @@ package riveruidriver import ( + "cmp" "context" + "errors" + "fmt" + "slices" "time" "github.com/riverqueue/river/riverdriver" @@ -28,12 +32,41 @@ type Executor interface { // JobCountEstimate returns inexpensive database estimates where supported. // Drivers without an estimate strategy return an empty map. JobCountEstimate(ctx context.Context, params *JobCountEstimateParams) (map[rivertype.JobState]JobCountEstimateResult, error) - // JobDeadLetterList returns at most Max jobs, ordered by ID ascending. - // Only Pro endpoints call this query; it requires the dead letter table. + // JobDeadLetterList returns at most Max jobs, ordered by ID descending so + // the newest dead letter jobs come first and stay reachable when more jobs + // match than Max. Only Pro endpoints call this query; it requires the dead + // letter table. JobDeadLetterList(ctx context.Context, params *JobDeadLetterListParams) ([]*JobDeadLetterListResult, error) + // JobDeadLetterAutocomplete returns at most Max distinct values of the + // facet that start with Match and aren't in Exclude. Every driver compares + // and orders values the same way regardless of database collation: Match + // ignores the case of ASCII letters only, and values sort by their UTF-8 + // bytes. Only Pro endpoints call this query; it requires the dead letter + // table. JobDeadLetterAutocomplete(ctx context.Context, params *JobDeadLetterAutocompleteParams) ([]string, error) } +const ( + // JobDeadLetterListMaxDefault is the number of jobs to list when the caller + // has no preference. + JobDeadLetterListMaxDefault = 20 + // JobDeadLetterListMaxLimit is the largest Max that JobDeadLetterList + // accepts. + JobDeadLetterListMaxLimit = 200 + // JobDeadLetterAutocompleteMaxLimit is the largest Max that + // JobDeadLetterAutocomplete accepts. + JobDeadLetterAutocompleteMaxLimit = 100 +) + +// JobDeadLetterFacet is a job attribute that JobDeadLetterAutocomplete can +// suggest values for. The queries compare against these values literally. +type JobDeadLetterFacet string + +const ( + JobDeadLetterFacetJobKind JobDeadLetterFacet = "job_kind" + JobDeadLetterFacetQueueName JobDeadLetterFacet = "queue_name" +) + // JobDeadLetterFilters matches any value within each populated filter and // requires all populated filters to match. Tags match any exact tag. type JobDeadLetterFilters struct { @@ -46,20 +79,46 @@ type JobDeadLetterFilters struct { type JobDeadLetterAutocompleteParams struct { Exclude []string - Facet string + Facet JobDeadLetterFacet Match string Max int Schema string } +// Validate reports whether the params are ones that every driver accepts. +// Drivers call it before querying so they all reject the same inputs. +func (p *JobDeadLetterAutocompleteParams) Validate() error { + switch p.Facet { + case JobDeadLetterFacetJobKind, JobDeadLetterFacetQueueName: + default: + return errors.New("invalid dead letter autocomplete facet") + } + if p.Max < 1 || p.Max > JobDeadLetterAutocompleteMaxLimit { + return fmt.Errorf("dead letter autocomplete max must be between 1 and %d", JobDeadLetterAutocompleteMaxLimit) + } + return nil +} + type JobDeadLetterListParams struct { Filters JobDeadLetterFilters Max int Schema string } +// Validate reports whether the params are ones that every driver accepts. +// Drivers call it before querying so they all reject the same inputs. +func (p *JobDeadLetterListParams) Validate() error { + if p.Max < 1 || p.Max > JobDeadLetterListMaxLimit { + return fmt.Errorf("dead letter list max must be between 1 and %d", JobDeadLetterListMaxLimit) + } + return nil +} + // JobDeadLetterListResult contains only fields displayed in the list. Args, // metadata, and attempt history are loaded separately when opening a job. +// +// FinalizedAt is in UTC. Its precision is the one difference between drivers: +// PostgreSQL returns microseconds while SQLite returns milliseconds. type JobDeadLetterListResult struct { ID int64 `json:"id"` Attempt int `json:"attempt"` @@ -69,6 +128,20 @@ type JobDeadLetterListResult struct { Queue string `json:"queue"` } +// NormalizeJobDeadLetterListResults puts decoded jobs into the form that every +// driver returns. Jobs are ordered by ID descending, because not every database +// guarantees the order of elements within an aggregated JSON array. Timestamps +// are in UTC, because PostgreSQL renders them in the session's time zone while +// SQLite renders them in UTC. +func NormalizeJobDeadLetterListResults(jobs []*JobDeadLetterListResult) { + for _, job := range jobs { + if job.FinalizedAt != nil { + *job.FinalizedAt = job.FinalizedAt.UTC() + } + } + slices.SortFunc(jobs, func(a, b *JobDeadLetterListResult) int { return cmp.Compare(b.ID, a.ID) }) +} + type JobCountByAllStatesCappedParams struct { // Max must be positive and less than math.MaxInt32 so callers get the same // behavior from every database implementation. diff --git a/internal/riveruidriver/riveruidrivertest/job_dead_letter.go b/internal/riveruidriver/riveruidrivertest/job_dead_letter.go index 302cc9e3..2051906b 100644 --- a/internal/riveruidriver/riveruidrivertest/job_dead_letter.go +++ b/internal/riveruidriver/riveruidrivertest/job_dead_letter.go @@ -3,6 +3,7 @@ package riveruidrivertest import ( "context" "fmt" + "slices" "strings" "testing" "time" @@ -16,6 +17,21 @@ import ( "riverqueue.com/riverui/internal/riveruidriver" ) +// createJobDeadLetterTable creates the dead letter table in the test schema and +// returns the prefix that qualifies table names in it. OSS test schemas don't +// include Pro tables, so reproduce only the columns these queries need, +// preserving each database's timestamp column type. +func createJobDeadLetterTable(ctx context.Context, t *testing.T, executor riveruidriver.Executor, schema string) string { + t.Helper() + + prefix := "" + if schema != "" { + prefix = dbutil.SafeIdentifier(schema) + "." + } + require.NoError(t, executor.Exec(ctx, "CREATE TABLE "+prefix+"river_job_dead_letter AS SELECT id, attempt, finalized_at, kind, max_attempts, queue, priority, tags FROM "+prefix+"river_job WHERE 1 = 0")) + return prefix +} + func exerciseJobDeadLetterList( ctx context.Context, t *testing.T, @@ -31,43 +47,37 @@ func exerciseJobDeadLetterList( originalKind = "send_email" customerTag = "customer" emailKind = "archive_email" - kindFacet = "job_kind" + kindFacet = riveruidriver.JobDeadLetterFacetJobKind ) t.Run("JobDeadLetterList", func(t *testing.T) { t.Parallel() executor, schema := executorWithSchema(ctx, t, driver, executorWithTx) - prefix := "" - if schema != "" { - prefix = dbutil.SafeIdentifier(schema) + "." - } - // OSS test schemas don't include Pro tables. Reproduce only the columns - // this query needs, preserving each database's timestamp column type. - require.NoError(t, executor.Exec(ctx, "CREATE TABLE "+prefix+"river_job_dead_letter AS SELECT id, attempt, finalized_at, kind, max_attempts, queue, priority, tags FROM "+prefix+"river_job WHERE 1 = 0")) + prefix := createJobDeadLetterTable(ctx, t, executor, schema) testfactory.Job(ctx, t, executor, &testfactory.JobOpts{Schema: schema}) jobs, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Max: 20, Schema: schema}) require.NoError(t, err) require.Empty(t, jobs, "live jobs must not appear in the dead letter list") require.NotNil(t, jobs) - // Deliberately insert in reverse order, with IDs beyond JavaScript's + // Deliberately insert out of result order, with IDs beyond JavaScript's // exact integer range, and enough rows to exceed the maximum UI limit. const firstID int64 = 1970670598291982290 rows := make([]string, 0, 205) - for i := 204; i >= 0; i-- { + for i := range 205 { finalizedAt := "'2026-10-21 10:02:03.456+00:00'" - if i == 0 { + if i == 204 { finalizedAt = "NULL" } rows = append(rows, fmt.Sprintf("(%d, 3, %s, 'send_email', 5, 'mail')", firstID+int64(i), finalizedAt)) } require.NoError(t, executor.Exec(ctx, "INSERT INTO "+prefix+"river_job_dead_letter (id, attempt, finalized_at, kind, max_attempts, queue) VALUES "+strings.Join(rows, ","))) - for _, limit := range []int{1, 20, 40, 200} { + for _, limit := range []int{1, riveruidriver.JobDeadLetterListMaxDefault, 40, riveruidriver.JobDeadLetterListMaxLimit} { jobs, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Max: limit, Schema: schema}) require.NoError(t, err) require.Len(t, jobs, limit) for i, job := range jobs { - require.Equal(t, firstID+int64(i), job.ID) + require.Equal(t, firstID+204-int64(i), job.ID, "newest jobs must come first") require.Equal(t, 3, job.Attempt) require.Equal(t, 5, job.MaxAttempts) require.Equal(t, originalKind, job.Kind) @@ -77,21 +87,22 @@ func exerciseJobDeadLetterList( } else { require.NotNil(t, job.FinalizedAt) require.True(t, job.FinalizedAt.Equal(time.Date(2026, time.October, 21, 10, 2, 3, 456_000_000, time.UTC))) + require.Equal(t, time.UTC, job.FinalizedAt.Location(), "timestamps must be in UTC on every driver") } } } - for _, limit := range []int{-1, 0, 201} { + for _, limit := range []int{-1, 0, riveruidriver.JobDeadLetterListMaxLimit + 1} { _, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Max: limit, Schema: schema}) require.EqualError(t, err, "dead letter list max must be between 1 and 200") } // Filtering must happen before limiting, without rounding large IDs. jobs, err = executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{ - Filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{firstID + 204}}, Max: 1, Schema: schema, + Filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{firstID}}, Max: 1, Schema: schema, }) require.NoError(t, err) require.Len(t, jobs, 1) - require.Equal(t, firstID+204, jobs[0].ID) + require.Equal(t, firstID, jobs[0].ID) archive := func(kind, queue string, priority int, tags []string) int64 { job := testfactory.Job(ctx, t, executor, &testfactory.JobOpts{ @@ -108,16 +119,18 @@ func exerciseJobDeadLetterList( filters riveruidriver.JobDeadLetterFilters want []int64 }{ - {name: "IDs", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{second, first}}, want: []int64{first, second}}, - {name: "Kinds", filters: riveruidriver.JobDeadLetterFilters{Kinds: []string{emailKind, reportKind}}, want: []int64{first, second, third}}, + {name: "IDs", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{first, second}}, want: []int64{second, first}}, + {name: "IDsWithOtherFilters", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{first, second, third}, Queues: []string{mailQueue}}, want: []int64{third, first}}, + {name: "IDsNoMatches", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{firstID - 1}}, want: []int64{}}, + {name: "Kinds", filters: riveruidriver.JobDeadLetterFilters{Kinds: []string{emailKind, reportKind}}, want: []int64{third, second, first}}, {name: "Queues", filters: riveruidriver.JobDeadLetterFilters{Queues: []string{reportsQueue}}, want: []int64{second}}, - {name: "Priorities", filters: riveruidriver.JobDeadLetterFilters{Priorities: []int16{1, 3}}, want: []int64{first, second}}, - {name: "TagsMatchAny", filters: riveruidriver.JobDeadLetterFilters{Tags: []string{urgentTag, "batch"}}, want: []int64{first, second}}, + {name: "Priorities", filters: riveruidriver.JobDeadLetterFilters{Priorities: []int16{1, 3}}, want: []int64{second, first}}, + {name: "TagsMatchAny", filters: riveruidriver.JobDeadLetterFilters{Tags: []string{urgentTag, "batch"}}, want: []int64{second, first}}, {name: "TagsAreExact", filters: riveruidriver.JobDeadLetterFilters{Tags: []string{urgentTag}}, want: []int64{first}}, {name: "Combined", filters: riveruidriver.JobDeadLetterFilters{Kinds: []string{emailKind, reportKind}, Queues: []string{mailQueue}, Priorities: []int16{1, 2}, Tags: []string{customerTag}}, want: []int64{first}}, {name: "NoMatches", filters: riveruidriver.JobDeadLetterFilters{Kinds: []string{"missing"}}, want: []int64{}}, {name: "LiteralValues", filters: riveruidriver.JobDeadLetterFilters{Tags: []string{"urgent' OR 1=1 --"}}, want: []int64{}}, - {name: "EmptyFilters", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{}, Kinds: []string{}, Queues: []string{}, Tags: []string{}, Priorities: []int16{}}, want: []int64{first, second, third}}, + {name: "EmptyFilters", filters: riveruidriver.JobDeadLetterFilters{IDs: []int64{}, Kinds: []string{}, Queues: []string{}, Tags: []string{}, Priorities: []int16{}}, want: []int64{firstID + 204, firstID + 203, firstID + 202}}, } { t.Run(tt.name, func(t *testing.T) { jobs, err := executor.JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{Filters: tt.filters, Max: 3, Schema: schema}) @@ -132,7 +145,7 @@ func exerciseJobDeadLetterList( for _, tt := range []struct { name string - facet string + facet riveruidriver.JobDeadLetterFacet match string exclude []string max int @@ -142,7 +155,7 @@ func exerciseJobDeadLetterList( {name: "PrefixCaseInsensitive", facet: kindFacet, match: "ARCHIVE_", max: 100, want: []string{emailKind, reportKind}}, {name: "Exclude", facet: kindFacet, match: "archive_", exclude: []string{reportKind}, max: 100, want: []string{emailKind}}, {name: "Limit", facet: kindFacet, max: 1, want: []string{emailKind}}, - {name: "Queues", facet: "queue_name", max: 100, want: []string{mailQueue, reportsQueue}}, + {name: "Queues", facet: riveruidriver.JobDeadLetterFacetQueueName, max: 100, want: []string{mailQueue, reportsQueue}}, {name: "NoLiveJobs", facet: kindFacet, match: "fake_job", max: 100, want: []string{}}, {name: "LiteralPrefix", facet: kindFacet, match: "%", max: 100, want: []string{}}, } { @@ -154,3 +167,96 @@ func exerciseJobDeadLetterList( } }) } + +// exerciseJobDeadLetterAutocomplete proves that every driver matches and orders +// suggestions the same way for text where database collations disagree: +// mixed-case ASCII and non-ASCII letters. +func exerciseJobDeadLetterAutocomplete( + ctx context.Context, + t *testing.T, + databaseName string, + driver riveruidriver.Driver, + executorWithTx func(ctx context.Context, t *testing.T) (riverdriver.Executor, string), +) { + t.Helper() + const ( + kindFacet = riveruidriver.JobDeadLetterFacetJobKind + queueFacet = riveruidriver.JobDeadLetterFacetQueueName + ) + t.Run("JobDeadLetterAutocomplete", func(t *testing.T) { + t.Parallel() + executor, schema := executorWithSchema(ctx, t, driver, executorWithTx) + prefix := createJobDeadLetterTable(ctx, t, executor, schema) + + if databaseName == riverdriver.DatabaseNamePostgres { + // Give the columns a linguistic collation where one is available so + // the query is shown not to depend on the collation of the database + // or its columns. Unlike "C", this collation folds non-ASCII letters + // in lower() and sorts letters of both cases together. + var hasICU bool + require.NoError(t, executor.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM pg_collation WHERE collname = 'und-x-icu')").Scan(&hasICU)) + if hasICU { + require.NoError(t, executor.Exec(ctx, "ALTER TABLE "+prefix+`river_job_dead_letter ALTER COLUMN kind TYPE text COLLATE "und-x-icu", ALTER COLUMN queue TYPE text COLLATE "und-x-icu"`)) + } + } + + // Listed in the byte order that every driver must return. A linguistic + // collation would instead return alpha, école, École, zebra, Zeta. + const ( + zeta = "Zeta" + alpha = "alpha" + zebra = "zebra" + ecoleUpper = "École" + ecoleLower = "école" + ) + kinds := []string{zeta, alpha, zebra, ecoleUpper, ecoleLower} + rows := make([]string, 0, len(kinds)) + for i, kind := range slices.Backward(kinds) { + rows = append(rows, fmt.Sprintf("(%d, 3, NULL, '%s', 5, '%s')", i+1, kind, strings.ToUpper(kind))) + } + require.NoError(t, executor.Exec(ctx, "INSERT INTO "+prefix+"river_job_dead_letter (id, attempt, finalized_at, kind, max_attempts, queue) VALUES "+strings.Join(rows, ","))) + + for _, tt := range []struct { + name string + facet riveruidriver.JobDeadLetterFacet + match string + exclude []string + max int + want []string + }{ + {name: "ByteOrder", facet: kindFacet, max: 100, want: kinds}, + {name: "ByteOrderQueues", facet: queueFacet, max: 100, want: []string{"ALPHA", "ZEBRA", "ZETA", "ÉCOLE"}}, + {name: "LimitFollowsByteOrder", facet: kindFacet, max: 2, want: []string{zeta, alpha}}, + {name: "MixedCaseMatch", facet: kindFacet, match: "zE", max: 100, want: []string{zeta, zebra}}, + {name: "MixedCaseMatchUpper", facet: kindFacet, match: "ALPHA", max: 100, want: []string{alpha}}, + {name: "MixedCaseMatchQueues", facet: queueFacet, match: "zeb", max: 100, want: []string{"ZEBRA"}}, + {name: "NonASCIILower", facet: kindFacet, match: "é", max: 100, want: []string{ecoleLower}}, + {name: "NonASCIIUpper", facet: kindFacet, match: "É", max: 100, want: []string{ecoleUpper}}, + {name: "NonASCIIFoldsOnlyASCII", facet: kindFacet, match: "éCOLE", max: 100, want: []string{ecoleLower}}, + {name: "NonASCIIFoldsOnlyASCIIQueues", facet: queueFacet, match: "éc", max: 100, want: []string{}}, + {name: "NonASCIIExclude", facet: kindFacet, exclude: []string{ecoleUpper, "ZETA"}, max: 100, want: []string{zeta, alpha, zebra, ecoleLower}}, + } { + t.Run(tt.name, func(t *testing.T) { + values, err := executor.JobDeadLetterAutocomplete(ctx, &riveruidriver.JobDeadLetterAutocompleteParams{Facet: tt.facet, Match: tt.match, Exclude: tt.exclude, Max: tt.max, Schema: schema}) + require.NoError(t, err) + require.Equal(t, tt.want, values) + }) + } + + // Drivers guard their own inputs rather than relying on the caller's + // validation, and reject them before running a query. + for _, facet := range []riveruidriver.JobDeadLetterFacet{"", "tags", "kind", "JOB_KIND"} { + _, err := executor.JobDeadLetterAutocomplete(ctx, &riveruidriver.JobDeadLetterAutocompleteParams{Facet: facet, Max: 100, Schema: schema}) + require.EqualError(t, err, "invalid dead letter autocomplete facet", "facet %q", facet) + } + for _, limit := range []int{-1, 0, riveruidriver.JobDeadLetterAutocompleteMaxLimit + 1} { + _, err := executor.JobDeadLetterAutocomplete(ctx, &riveruidriver.JobDeadLetterAutocompleteParams{Facet: kindFacet, Max: limit, Schema: schema}) + require.EqualError(t, err, "dead letter autocomplete max must be between 1 and 100", "max %d", limit) + } + for _, limit := range []int{1, riveruidriver.JobDeadLetterAutocompleteMaxLimit} { + values, err := executor.JobDeadLetterAutocomplete(ctx, &riveruidriver.JobDeadLetterAutocompleteParams{Facet: kindFacet, Max: limit, Schema: schema}) + require.NoError(t, err) + require.Len(t, values, min(limit, len(kinds))) + } + }) +} diff --git a/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go b/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go index 1fbb6632..e58b7386 100644 --- a/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go +++ b/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go @@ -29,6 +29,7 @@ func Exercise( exerciseJobCountByAllStatesCapped(ctx, t, driver, executorWithTx) exerciseJobCountEstimate(ctx, t, databaseName, driver, executorWithTx) exerciseJobDeadLetterList(ctx, t, driver, executorWithTx) + exerciseJobDeadLetterAutocomplete(ctx, t, databaseName, driver, executorWithTx) } func exerciseGetExecutor( diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql index f9f2bf5b..5c43ab08 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql @@ -12,9 +12,12 @@ CREATE TABLE river_job_dead_letter ( ); -- name: JobDeadLetterList :one --- Apply the limit before aggregation so neither the database nor the browser --- materializes the entire queue. River's common executor exposes QueryRow, --- so return the bounded set as one JSON value. +-- Return the newest jobs first. Apply the limit before aggregation so neither +-- the database nor the browser materializes the entire queue. River's common +-- executor exposes QueryRow, so return the bounded set as one JSON value. +-- +-- This query ignores the filter's IDs; the driver sends ID lookups to +-- JobDeadLetterListByIDs instead. SELECT coalesce(json_agg(json_build_object( 'id', limited.id, 'attempt', limited.attempt, @@ -22,27 +25,60 @@ SELECT coalesce(json_agg(json_build_object( 'kind', limited.kind, 'max_attempts', limited.max_attempts, 'queue', limited.queue -) ORDER BY limited.id), '[]'::json)::text AS jobs +) ORDER BY limited.id DESC), '[]'::json)::text AS jobs FROM ( SELECT id, attempt, finalized_at, kind, max_attempts, queue FROM /* TEMPLATE: schema */river_job_dead_letter - WHERE (NOT jsonb_exists(@filters::jsonb, 'ids') OR id IN (SELECT value::bigint FROM jsonb_array_elements_text(@filters::jsonb -> 'ids'))) + WHERE (NOT jsonb_exists(@filters::jsonb, 'kinds') OR kind IN (SELECT value::text FROM jsonb_array_elements_text(@filters::jsonb -> 'kinds'))) + AND (NOT jsonb_exists(@filters::jsonb, 'priorities') OR priority IN (SELECT value::smallint FROM jsonb_array_elements_text(@filters::jsonb -> 'priorities'))) + AND (NOT jsonb_exists(@filters::jsonb, 'queues') OR queue IN (SELECT value::text FROM jsonb_array_elements_text(@filters::jsonb -> 'queues'))) + AND (NOT jsonb_exists(@filters::jsonb, 'tags') OR tags && ARRAY(SELECT jsonb_array_elements_text(@filters::jsonb -> 'tags'))::varchar[]) + ORDER BY id DESC + LIMIT @max::int +) AS limited; + +-- name: JobDeadLetterListByIDs :one +-- JobDeadLetterList for filters that include IDs. The ID predicate must stand +-- alone as a top-level condition: written as "no IDs requested OR id IN (...)" +-- the planner can't turn it into an index condition and reads the whole +-- table. Collapsing the requested IDs to an array lets it probe the primary +-- key once per ID, whether the statement runs with a custom or generic plan. +SELECT coalesce(json_agg(json_build_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', limited.finalized_at, + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +) ORDER BY limited.id DESC), '[]'::json)::text AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE id = ANY (ARRAY(SELECT value::bigint FROM jsonb_array_elements_text(@filters::jsonb -> 'ids'))) AND (NOT jsonb_exists(@filters::jsonb, 'kinds') OR kind IN (SELECT value::text FROM jsonb_array_elements_text(@filters::jsonb -> 'kinds'))) AND (NOT jsonb_exists(@filters::jsonb, 'priorities') OR priority IN (SELECT value::smallint FROM jsonb_array_elements_text(@filters::jsonb -> 'priorities'))) AND (NOT jsonb_exists(@filters::jsonb, 'queues') OR queue IN (SELECT value::text FROM jsonb_array_elements_text(@filters::jsonb -> 'queues'))) AND (NOT jsonb_exists(@filters::jsonb, 'tags') OR tags && ARRAY(SELECT jsonb_array_elements_text(@filters::jsonb -> 'tags'))::varchar[]) - ORDER BY id + ORDER BY id DESC LIMIT @max::int ) AS limited; -- name: JobDeadLetterAutocomplete :one -- Suggest values from the archive, including kinds and queues with no live jobs. -SELECT coalesce(json_agg(suggestions.value ORDER BY suggestions.value), '[]'::json)::text AS values +-- +-- The "C" collation makes matching and ordering independent of the database's +-- collation and identical to SQLite: lower() folds only ASCII letters, and +-- values sort by their bytes. The ordering matters before the limit as well, +-- because it decides which suggestions are returned. +SELECT coalesce(json_agg(suggestions.value ORDER BY suggestions.value COLLATE "C"), '[]'::json)::text AS values FROM ( - SELECT DISTINCT CASE WHEN @facet::text = 'job_kind' THEN kind ELSE queue END AS value - FROM /* TEMPLATE: schema */river_job_dead_letter - WHERE left(lower(CASE WHEN @facet::text = 'job_kind' THEN kind ELSE queue END), length(@match::text)) = lower(@match::text) - AND (CASE WHEN @facet::text = 'job_kind' THEN kind ELSE queue END) NOT IN (SELECT jsonb_array_elements_text(@exclude::jsonb)) - ORDER BY value + SELECT DISTINCT candidates.value + FROM ( + SELECT (CASE WHEN @facet::text = 'job_kind' THEN kind ELSE queue END) COLLATE "C" AS value + FROM /* TEMPLATE: schema */river_job_dead_letter + ) AS candidates + WHERE left(lower(candidates.value COLLATE "C"), length(@match::text)) = lower((@match::text) COLLATE "C") + AND candidates.value NOT IN (SELECT excluded.value COLLATE "C" FROM jsonb_array_elements_text(@exclude::jsonb) AS excluded(value)) + ORDER BY candidates.value LIMIT @max::int ) AS suggestions; diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go index add724a0..7451eb97 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go @@ -10,13 +10,16 @@ import ( ) const jobDeadLetterAutocomplete = `-- name: JobDeadLetterAutocomplete :one -SELECT coalesce(json_agg(suggestions.value ORDER BY suggestions.value), '[]'::json)::text AS values +SELECT coalesce(json_agg(suggestions.value ORDER BY suggestions.value COLLATE "C"), '[]'::json)::text AS values FROM ( - SELECT DISTINCT CASE WHEN $1::text = 'job_kind' THEN kind ELSE queue END AS value - FROM /* TEMPLATE: schema */river_job_dead_letter - WHERE left(lower(CASE WHEN $1::text = 'job_kind' THEN kind ELSE queue END), length($2::text)) = lower($2::text) - AND (CASE WHEN $1::text = 'job_kind' THEN kind ELSE queue END) NOT IN (SELECT jsonb_array_elements_text($3::jsonb)) - ORDER BY value + SELECT DISTINCT candidates.value + FROM ( + SELECT (CASE WHEN $1::text = 'job_kind' THEN kind ELSE queue END) COLLATE "C" AS value + FROM /* TEMPLATE: schema */river_job_dead_letter + ) AS candidates + WHERE left(lower(candidates.value COLLATE "C"), length($2::text)) = lower(($2::text) COLLATE "C") + AND candidates.value NOT IN (SELECT excluded.value COLLATE "C" FROM jsonb_array_elements_text($3::jsonb) AS excluded(value)) + ORDER BY candidates.value LIMIT $4::int ) AS suggestions ` @@ -29,6 +32,11 @@ type JobDeadLetterAutocompleteParams struct { } // Suggest values from the archive, including kinds and queues with no live jobs. +// +// The "C" collation makes matching and ordering independent of the database's +// collation and identical to SQLite: lower() folds only ASCII letters, and +// values sort by their bytes. The ordering matters before the limit as well, +// because it decides which suggestions are returned. func (q *Queries) JobDeadLetterAutocomplete(ctx context.Context, db DBTX, arg *JobDeadLetterAutocompleteParams) (string, error) { row := db.QueryRow(ctx, jobDeadLetterAutocomplete, arg.Facet, @@ -49,16 +57,15 @@ SELECT coalesce(json_agg(json_build_object( 'kind', limited.kind, 'max_attempts', limited.max_attempts, 'queue', limited.queue -) ORDER BY limited.id), '[]'::json)::text AS jobs +) ORDER BY limited.id DESC), '[]'::json)::text AS jobs FROM ( SELECT id, attempt, finalized_at, kind, max_attempts, queue FROM /* TEMPLATE: schema */river_job_dead_letter - WHERE (NOT jsonb_exists($1::jsonb, 'ids') OR id IN (SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb -> 'ids'))) - AND (NOT jsonb_exists($1::jsonb, 'kinds') OR kind IN (SELECT value::text FROM jsonb_array_elements_text($1::jsonb -> 'kinds'))) + WHERE (NOT jsonb_exists($1::jsonb, 'kinds') OR kind IN (SELECT value::text FROM jsonb_array_elements_text($1::jsonb -> 'kinds'))) AND (NOT jsonb_exists($1::jsonb, 'priorities') OR priority IN (SELECT value::smallint FROM jsonb_array_elements_text($1::jsonb -> 'priorities'))) AND (NOT jsonb_exists($1::jsonb, 'queues') OR queue IN (SELECT value::text FROM jsonb_array_elements_text($1::jsonb -> 'queues'))) AND (NOT jsonb_exists($1::jsonb, 'tags') OR tags && ARRAY(SELECT jsonb_array_elements_text($1::jsonb -> 'tags'))::varchar[]) - ORDER BY id + ORDER BY id DESC LIMIT $2::int ) AS limited ` @@ -68,12 +75,54 @@ type JobDeadLetterListParams struct { Max int32 } -// Apply the limit before aggregation so neither the database nor the browser -// materializes the entire queue. River's common executor exposes QueryRow, -// so return the bounded set as one JSON value. +// Return the newest jobs first. Apply the limit before aggregation so neither +// the database nor the browser materializes the entire queue. River's common +// executor exposes QueryRow, so return the bounded set as one JSON value. +// +// This query ignores the filter's IDs; the driver sends ID lookups to +// JobDeadLetterListByIDs instead. func (q *Queries) JobDeadLetterList(ctx context.Context, db DBTX, arg *JobDeadLetterListParams) (string, error) { row := db.QueryRow(ctx, jobDeadLetterList, arg.Filters, arg.Max) var jobs string err := row.Scan(&jobs) return jobs, err } + +const jobDeadLetterListByIDs = `-- name: JobDeadLetterListByIDs :one +SELECT coalesce(json_agg(json_build_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', limited.finalized_at, + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +) ORDER BY limited.id DESC), '[]'::json)::text AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE id = ANY (ARRAY(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb -> 'ids'))) + AND (NOT jsonb_exists($1::jsonb, 'kinds') OR kind IN (SELECT value::text FROM jsonb_array_elements_text($1::jsonb -> 'kinds'))) + AND (NOT jsonb_exists($1::jsonb, 'priorities') OR priority IN (SELECT value::smallint FROM jsonb_array_elements_text($1::jsonb -> 'priorities'))) + AND (NOT jsonb_exists($1::jsonb, 'queues') OR queue IN (SELECT value::text FROM jsonb_array_elements_text($1::jsonb -> 'queues'))) + AND (NOT jsonb_exists($1::jsonb, 'tags') OR tags && ARRAY(SELECT jsonb_array_elements_text($1::jsonb -> 'tags'))::varchar[]) + ORDER BY id DESC + LIMIT $2::int +) AS limited +` + +type JobDeadLetterListByIDsParams struct { + Filters []byte + Max int32 +} + +// JobDeadLetterList for filters that include IDs. The ID predicate must stand +// alone as a top-level condition: written as "no IDs requested OR id IN (...)" +// the planner can't turn it into an index condition and reads the whole +// table. Collapsing the requested IDs to an array lets it probe the primary +// key once per ID, whether the statement runs with a custom or generic plan. +func (q *Queries) JobDeadLetterListByIDs(ctx context.Context, db DBTX, arg *JobDeadLetterListByIDsParams) (string, error) { + row := db.QueryRow(ctx, jobDeadLetterListByIDs, arg.Filters, arg.Max) + var jobs string + err := row.Scan(&jobs) + return jobs, err +} diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go index 6021c0d2..f28f55f1 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go @@ -95,6 +95,14 @@ func JobDeadLetterListRiver(ctx context.Context, db RowQuerier, maxRows int, fil return jobs, nil } +func JobDeadLetterListByIDsRiver(ctx context.Context, db RowQuerier, maxRows int, filters string) (string, error) { + var jobs string + if err := db.QueryRow(ctx, jobDeadLetterListByIDs, filters, maxRows).Scan(&jobs); err != nil { + return "", err + } + return jobs, nil +} + func JobDeadLetterAutocompleteRiver(ctx context.Context, db RowQuerier, facet, match, exclude string, maxRows int) (string, error) { var values string if err := db.QueryRow(ctx, jobDeadLetterAutocomplete, facet, match, exclude, maxRows).Scan(&values); err != nil { diff --git a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries_test.go b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries_test.go index b7e94119..fdb8c45b 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries_test.go +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/require" "github.com/riverqueue/river/rivertype" + + "riverqueue.com/riverui/internal/riveruidriver" ) func TestJobCountEstimateQuery(t *testing.T) { @@ -29,3 +31,12 @@ func TestJobCountByAllStatesCappedQueryStates(t *testing.T) { require.Contains(t, jobCountByAllStatesCapped, "state = '"+string(state)+"'") } } + +func TestJobDeadLetterAutocompleteQueryFacets(t *testing.T) { + t.Parallel() + + // The query selects kinds for the job kind facet and queues for anything + // else, so its literal must stay in step with the driver's facet values. + require.Contains(t, jobDeadLetterAutocomplete, "= '"+string(riveruidriver.JobDeadLetterFacetJobKind)+"' THEN kind ELSE queue END") + require.NotContains(t, jobDeadLetterAutocomplete, string(riveruidriver.JobDeadLetterFacetQueueName)) +} diff --git a/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go b/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go index d62d5ada..04157ecf 100644 --- a/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go +++ b/internal/riveruidriver/riveruipostgres/river_ui_postgres_driver.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "math" + "slices" "time" "github.com/riverqueue/river/riverdriver" @@ -141,14 +142,20 @@ func (w riverExecutorWrapper) QueryRow(ctx context.Context, query string, args . } func (e *Executor) JobDeadLetterList(ctx context.Context, params *riveruidriver.JobDeadLetterListParams) ([]*riveruidriver.JobDeadLetterListResult, error) { - if params.Max < 1 || params.Max > 200 { - return nil, errors.New("dead letter list max must be between 1 and 200") + if err := params.Validate(); err != nil { + return nil, err } filters, err := json.Marshal(params.Filters) if err != nil { return nil, fmt.Errorf("error encoding dead letter filters: %w", err) } - rawJobs, err := dbsqlc.JobDeadLetterListRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Max, string(filters)) + // ID lookups use their own query so the database can search the primary + // key instead of reading the whole table. + listQuery := dbsqlc.JobDeadLetterListRiver + if len(params.Filters.IDs) > 0 { + listQuery = dbsqlc.JobDeadLetterListByIDsRiver + } + rawJobs, err := listQuery(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Max, string(filters)) if err != nil { return nil, fmt.Errorf("error listing dead letter jobs: %w", err) } @@ -156,21 +163,19 @@ func (e *Executor) JobDeadLetterList(ctx context.Context, params *riveruidriver. if err := json.Unmarshal([]byte(rawJobs), &jobs); err != nil { return nil, fmt.Errorf("error decoding dead letter jobs: %w", err) } + riveruidriver.NormalizeJobDeadLetterListResults(jobs) return jobs, nil } func (e *Executor) JobDeadLetterAutocomplete(ctx context.Context, params *riveruidriver.JobDeadLetterAutocompleteParams) ([]string, error) { - if params.Facet != "job_kind" && params.Facet != "queue_name" { - return nil, errors.New("invalid dead letter autocomplete facet") - } - if params.Max < 1 || params.Max > 100 { - return nil, errors.New("dead letter autocomplete max must be between 1 and 100") + if err := params.Validate(); err != nil { + return nil, err } exclude, err := json.Marshal(append([]string{}, params.Exclude...)) if err != nil { return nil, fmt.Errorf("error encoding dead letter exclusions: %w", err) } - rawValues, err := dbsqlc.JobDeadLetterAutocompleteRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Facet, params.Match, string(exclude), params.Max) + rawValues, err := dbsqlc.JobDeadLetterAutocompleteRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, string(params.Facet), params.Match, string(exclude), params.Max) if err != nil { return nil, fmt.Errorf("error autocompleting dead letter jobs: %w", err) } @@ -178,5 +183,8 @@ func (e *Executor) JobDeadLetterAutocomplete(ctx context.Context, params *riveru if err := json.Unmarshal([]byte(rawValues), &values); err != nil { return nil, fmt.Errorf("error decoding dead letter suggestions: %w", err) } + // Byte order matches the queries' ordering; sorting here keeps the result + // deterministic where the database doesn't guarantee aggregate order. + slices.Sort(values) return values, nil } diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql index 6295e003..e837a095 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql @@ -12,9 +12,15 @@ CREATE TABLE river_job_dead_letter ( ); -- name: JobDeadLetterList :one --- Apply the limit before aggregation so neither the database nor the browser --- materializes the entire queue. Normalize SQLite timestamps to RFC 3339 for --- the shared result decoder; strftime preserves NULL timestamps. +-- Return the newest jobs first. Apply the limit before aggregation so neither +-- the database nor the browser materializes the entire queue. SQLite doesn't +-- guarantee that json_group_array preserves the subquery's order, so the +-- driver sorts the decoded rows. Normalize SQLite timestamps to RFC 3339 for +-- the shared result decoder; strftime preserves NULL timestamps. Its %f keeps +-- milliseconds, where PostgreSQL returns microseconds. +-- +-- This query ignores the filter's IDs; the driver sends ID lookups to +-- JobDeadLetterListByIDs instead. SELECT CAST(json_group_array(json_object( 'id', limited.id, 'attempt', limited.attempt, @@ -26,17 +32,46 @@ SELECT CAST(json_group_array(json_object( FROM ( SELECT id, attempt, finalized_at, kind, max_attempts, queue FROM /* TEMPLATE: schema */river_job_dead_letter - WHERE (json_type(CAST(@filters AS text), '$.ids') IS NULL OR id IN (SELECT CAST(value AS integer) FROM json_each(CAST(@filters AS text), '$.ids'))) + WHERE (json_type(CAST(@filters AS text), '$.kinds') IS NULL OR kind IN (SELECT CAST(value AS text) FROM json_each(CAST(@filters AS text), '$.kinds'))) + AND (json_type(CAST(@filters AS text), '$.priorities') IS NULL OR priority IN (SELECT CAST(value AS integer) FROM json_each(CAST(@filters AS text), '$.priorities'))) + AND (json_type(CAST(@filters AS text), '$.queues') IS NULL OR queue IN (SELECT CAST(value AS text) FROM json_each(CAST(@filters AS text), '$.queues'))) + AND (json_type(CAST(@filters AS text), '$.tags') IS NULL OR EXISTS (SELECT 1 FROM json_each(river_job_dead_letter.tags) AS job_tag JOIN json_each(CAST(@filters AS text), '$.tags') AS filter_tag ON job_tag.value = filter_tag.value)) + ORDER BY id DESC + LIMIT @max +) AS limited; + +-- name: JobDeadLetterListByIDs :one +-- JobDeadLetterList for filters that include IDs. The ID predicate must stand +-- alone as a top-level condition: written as "no IDs requested OR id IN (...)" +-- SQLite scans the whole table instead of searching the primary key. +SELECT CAST(json_group_array(json_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', strftime('%Y-%m-%dT%H:%M:%fZ', limited.finalized_at), + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +)) AS text) AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE id IN (SELECT CAST(value AS integer) FROM json_each(CAST(@filters AS text), '$.ids')) AND (json_type(CAST(@filters AS text), '$.kinds') IS NULL OR kind IN (SELECT CAST(value AS text) FROM json_each(CAST(@filters AS text), '$.kinds'))) AND (json_type(CAST(@filters AS text), '$.priorities') IS NULL OR priority IN (SELECT CAST(value AS integer) FROM json_each(CAST(@filters AS text), '$.priorities'))) AND (json_type(CAST(@filters AS text), '$.queues') IS NULL OR queue IN (SELECT CAST(value AS text) FROM json_each(CAST(@filters AS text), '$.queues'))) AND (json_type(CAST(@filters AS text), '$.tags') IS NULL OR EXISTS (SELECT 1 FROM json_each(river_job_dead_letter.tags) AS job_tag JOIN json_each(CAST(@filters AS text), '$.tags') AS filter_tag ON job_tag.value = filter_tag.value)) - ORDER BY id + ORDER BY id DESC LIMIT @max ) AS limited; -- name: JobDeadLetterAutocomplete :one -- Suggest values from the archive, including kinds and queues with no live jobs. +-- +-- SQLite's built-in lower() folds only ASCII letters and its default BINARY +-- collation sorts values by their bytes. The PostgreSQL query pins the "C" +-- collation to behave the same way. A SQLite build that loads the ICU +-- extension replaces lower() with a Unicode-aware version and would also fold +-- non-ASCII letters. SELECT CAST(json_group_array(suggestions.value) AS text) AS values_json FROM ( SELECT DISTINCT candidates.value diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go index 84d6d0a3..3dadb5a5 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go @@ -32,6 +32,12 @@ type JobDeadLetterAutocompleteParams struct { } // Suggest values from the archive, including kinds and queues with no live jobs. +// +// SQLite's built-in lower() folds only ASCII letters and its default BINARY +// collation sorts values by their bytes. The PostgreSQL query pins the "C" +// collation to behave the same way. A SQLite build that loads the ICU +// extension replaces lower() with a Unicode-aware version and would also fold +// non-ASCII letters. func (q *Queries) JobDeadLetterAutocomplete(ctx context.Context, db DBTX, arg *JobDeadLetterAutocompleteParams) (string, error) { row := db.QueryRowContext(ctx, jobDeadLetterAutocomplete, arg.Facet, @@ -56,12 +62,11 @@ SELECT CAST(json_group_array(json_object( FROM ( SELECT id, attempt, finalized_at, kind, max_attempts, queue FROM /* TEMPLATE: schema */river_job_dead_letter - WHERE (json_type(CAST(?1 AS text), '$.ids') IS NULL OR id IN (SELECT CAST(value AS integer) FROM json_each(CAST(?1 AS text), '$.ids'))) - AND (json_type(CAST(?1 AS text), '$.kinds') IS NULL OR kind IN (SELECT CAST(value AS text) FROM json_each(CAST(?1 AS text), '$.kinds'))) + WHERE (json_type(CAST(?1 AS text), '$.kinds') IS NULL OR kind IN (SELECT CAST(value AS text) FROM json_each(CAST(?1 AS text), '$.kinds'))) AND (json_type(CAST(?1 AS text), '$.priorities') IS NULL OR priority IN (SELECT CAST(value AS integer) FROM json_each(CAST(?1 AS text), '$.priorities'))) AND (json_type(CAST(?1 AS text), '$.queues') IS NULL OR queue IN (SELECT CAST(value AS text) FROM json_each(CAST(?1 AS text), '$.queues'))) AND (json_type(CAST(?1 AS text), '$.tags') IS NULL OR EXISTS (SELECT 1 FROM json_each(river_job_dead_letter.tags) AS job_tag JOIN json_each(CAST(?1 AS text), '$.tags') AS filter_tag ON job_tag.value = filter_tag.value)) - ORDER BY id + ORDER BY id DESC LIMIT ?2 ) AS limited ` @@ -71,12 +76,55 @@ type JobDeadLetterListParams struct { Max int64 } -// Apply the limit before aggregation so neither the database nor the browser -// materializes the entire queue. Normalize SQLite timestamps to RFC 3339 for -// the shared result decoder; strftime preserves NULL timestamps. +// Return the newest jobs first. Apply the limit before aggregation so neither +// the database nor the browser materializes the entire queue. SQLite doesn't +// guarantee that json_group_array preserves the subquery's order, so the +// driver sorts the decoded rows. Normalize SQLite timestamps to RFC 3339 for +// the shared result decoder; strftime preserves NULL timestamps. Its %f keeps +// milliseconds, where PostgreSQL returns microseconds. +// +// This query ignores the filter's IDs; the driver sends ID lookups to +// JobDeadLetterListByIDs instead. func (q *Queries) JobDeadLetterList(ctx context.Context, db DBTX, arg *JobDeadLetterListParams) (string, error) { row := db.QueryRowContext(ctx, jobDeadLetterList, arg.Filters, arg.Max) var jobs string err := row.Scan(&jobs) return jobs, err } + +const jobDeadLetterListByIDs = `-- name: JobDeadLetterListByIDs :one +SELECT CAST(json_group_array(json_object( + 'id', limited.id, + 'attempt', limited.attempt, + 'finalized_at', strftime('%Y-%m-%dT%H:%M:%fZ', limited.finalized_at), + 'kind', limited.kind, + 'max_attempts', limited.max_attempts, + 'queue', limited.queue +)) AS text) AS jobs +FROM ( + SELECT id, attempt, finalized_at, kind, max_attempts, queue + FROM /* TEMPLATE: schema */river_job_dead_letter + WHERE id IN (SELECT CAST(value AS integer) FROM json_each(CAST(?1 AS text), '$.ids')) + AND (json_type(CAST(?1 AS text), '$.kinds') IS NULL OR kind IN (SELECT CAST(value AS text) FROM json_each(CAST(?1 AS text), '$.kinds'))) + AND (json_type(CAST(?1 AS text), '$.priorities') IS NULL OR priority IN (SELECT CAST(value AS integer) FROM json_each(CAST(?1 AS text), '$.priorities'))) + AND (json_type(CAST(?1 AS text), '$.queues') IS NULL OR queue IN (SELECT CAST(value AS text) FROM json_each(CAST(?1 AS text), '$.queues'))) + AND (json_type(CAST(?1 AS text), '$.tags') IS NULL OR EXISTS (SELECT 1 FROM json_each(river_job_dead_letter.tags) AS job_tag JOIN json_each(CAST(?1 AS text), '$.tags') AS filter_tag ON job_tag.value = filter_tag.value)) + ORDER BY id DESC + LIMIT ?2 +) AS limited +` + +type JobDeadLetterListByIDsParams struct { + Filters string + Max int64 +} + +// JobDeadLetterList for filters that include IDs. The ID predicate must stand +// alone as a top-level condition: written as "no IDs requested OR id IN (...)" +// SQLite scans the whole table instead of searching the primary key. +func (q *Queries) JobDeadLetterListByIDs(ctx context.Context, db DBTX, arg *JobDeadLetterListByIDsParams) (string, error) { + row := db.QueryRowContext(ctx, jobDeadLetterListByIDs, arg.Filters, arg.Max) + var jobs string + err := row.Scan(&jobs) + return jobs, err +} diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go index 7376e0b9..71023799 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go @@ -45,6 +45,14 @@ func JobDeadLetterListRiver(ctx context.Context, db RowQuerier, maxRows int, fil return jobs, nil } +func JobDeadLetterListByIDsRiver(ctx context.Context, db RowQuerier, maxRows int, filters string) (string, error) { + var jobs string + if err := db.QueryRow(ctx, jobDeadLetterListByIDs, filters, maxRows).Scan(&jobs); err != nil { + return "", err + } + return jobs, nil +} + func JobDeadLetterAutocompleteRiver(ctx context.Context, db RowQuerier, facet, match, exclude string, maxRows int) (string, error) { var values string if err := db.QueryRow(ctx, jobDeadLetterAutocomplete, facet, match, exclude, maxRows).Scan(&values); err != nil { diff --git a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries_test.go b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries_test.go index 9baae87d..e7a92900 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries_test.go +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries_test.go @@ -6,6 +6,8 @@ import ( "github.com/stretchr/testify/require" "github.com/riverqueue/river/rivertype" + + "riverqueue.com/riverui/internal/riveruidriver" ) func TestJobCountByAllStatesCappedQueryStates(t *testing.T) { @@ -23,3 +25,12 @@ func TestJobCountEstimateStat4Query(t *testing.T) { require.Contains(t, jobCountEstimateStat4, "river_job_prioritized_fetching_index") require.Contains(t, jobCountEstimateStat4, "group_concat") } + +func TestJobDeadLetterAutocompleteQueryFacets(t *testing.T) { + t.Parallel() + + // The query selects kinds for the job kind facet and queues for anything + // else, so its literal must stay in step with the driver's facet values. + require.Contains(t, jobDeadLetterAutocomplete, "= '"+string(riveruidriver.JobDeadLetterFacetJobKind)+"' THEN kind ELSE queue END") + require.NotContains(t, jobDeadLetterAutocomplete, string(riveruidriver.JobDeadLetterFacetQueueName)) +} diff --git a/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go b/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go index bb4ec784..8e4a6096 100644 --- a/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go +++ b/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "math" + "slices" "strconv" "strings" @@ -243,14 +244,20 @@ func (w riverExecutorWrapper) QueryRow(ctx context.Context, query string, args . } func (e *Executor) JobDeadLetterList(ctx context.Context, params *riveruidriver.JobDeadLetterListParams) ([]*riveruidriver.JobDeadLetterListResult, error) { - if params.Max < 1 || params.Max > 200 { - return nil, errors.New("dead letter list max must be between 1 and 200") + if err := params.Validate(); err != nil { + return nil, err } filters, err := json.Marshal(params.Filters) if err != nil { return nil, fmt.Errorf("error encoding dead letter filters: %w", err) } - rawJobs, err := dbsqlc.JobDeadLetterListRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Max, string(filters)) + // ID lookups use their own query so the database can search the primary + // key instead of reading the whole table. + listQuery := dbsqlc.JobDeadLetterListRiver + if len(params.Filters.IDs) > 0 { + listQuery = dbsqlc.JobDeadLetterListByIDsRiver + } + rawJobs, err := listQuery(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Max, string(filters)) if err != nil { return nil, fmt.Errorf("error listing dead letter jobs: %w", err) } @@ -258,21 +265,19 @@ func (e *Executor) JobDeadLetterList(ctx context.Context, params *riveruidriver. if err := json.Unmarshal([]byte(rawJobs), &jobs); err != nil { return nil, fmt.Errorf("error decoding dead letter jobs: %w", err) } + riveruidriver.NormalizeJobDeadLetterListResults(jobs) return jobs, nil } func (e *Executor) JobDeadLetterAutocomplete(ctx context.Context, params *riveruidriver.JobDeadLetterAutocompleteParams) ([]string, error) { - if params.Facet != "job_kind" && params.Facet != "queue_name" { - return nil, errors.New("invalid dead letter autocomplete facet") - } - if params.Max < 1 || params.Max > 100 { - return nil, errors.New("dead letter autocomplete max must be between 1 and 100") + if err := params.Validate(); err != nil { + return nil, err } exclude, err := json.Marshal(append([]string{}, params.Exclude...)) if err != nil { return nil, fmt.Errorf("error encoding dead letter exclusions: %w", err) } - rawValues, err := dbsqlc.JobDeadLetterAutocompleteRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, params.Facet, params.Match, string(exclude), params.Max) + rawValues, err := dbsqlc.JobDeadLetterAutocompleteRiver(schemaTemplateParam(ctx, params.Schema), e.dbtx, string(params.Facet), params.Match, string(exclude), params.Max) if err != nil { return nil, fmt.Errorf("error autocompleting dead letter jobs: %w", err) } @@ -280,5 +285,8 @@ func (e *Executor) JobDeadLetterAutocomplete(ctx context.Context, params *riveru if err := json.Unmarshal([]byte(rawValues), &values); err != nil { return nil, fmt.Errorf("error decoding dead letter suggestions: %w", err) } + // Byte order matches the queries' ordering; sorting here keeps the result + // deterministic where the database doesn't guarantee aggregate order. + slices.Sort(values) return values, nil } diff --git a/riverproui/endpoints.go b/riverproui/endpoints.go index 220714f9..00cf7f4a 100644 --- a/riverproui/endpoints.go +++ b/riverproui/endpoints.go @@ -90,10 +90,7 @@ func (e *endpoints[TTx]) Extensions(ctx context.Context) (map[string]bool, error } defer execTx.Rollback(ctx) - hasDeadLetterTable, err := execTx.TableExists(ctx, &riverdriver.TableExistsParams{ - Schema: schema, - Table: "river_job_dead_letter", - }) + hasDeadLetterTable, err := prohandler.HasDeadLetterTable(ctx, execTx, schema) if err != nil { return nil, err } diff --git a/riverproui/internal/prohandler/dead_letter_endpoints.go b/riverproui/internal/prohandler/dead_letter_endpoints.go index 9676e0f3..7de38e7d 100644 --- a/riverproui/internal/prohandler/dead_letter_endpoints.go +++ b/riverproui/internal/prohandler/dead_letter_endpoints.go @@ -6,6 +6,9 @@ import ( "fmt" "net/http" "strconv" + "strings" + "time" + "unicode/utf8" "github.com/riverqueue/apiframe/apiendpoint" "github.com/riverqueue/apiframe/apierror" @@ -22,11 +25,21 @@ import ( "riverqueue.com/riverui/internal/riveruidriver/riveruisqlite" ) -func (a *ProAPIBundle[TTx]) requireDeadLetterJobs(ctx context.Context) error { - exists, err := a.DB.TableExists(ctx, &riverdriver.TableExistsParams{ - Schema: a.Client.Schema(), - Table: "river_job_dead_letter", +// deadLetterTableName is the table created by River Pro's dead letter queue +// migration. +const deadLetterTableName = "river_job_dead_letter" + +// HasDeadLetterTable reports whether the schema has the dead letter table, and +// therefore whether the dead letter endpoints can serve requests. +func HasDeadLetterTable(ctx context.Context, exec riverdriver.Executor, schema string) (bool, error) { + return exec.TableExists(ctx, &riverdriver.TableExistsParams{ + Schema: schema, + Table: deadLetterTableName, }) +} + +func (a *ProAPIBundle[TTx]) requireDeadLetterJobs(ctx context.Context) error { + exists, err := HasDeadLetterTable(ctx, a.DB, a.Client.Schema()) if err != nil { return fmt.Errorf("error checking dead letter schema support: %w", err) } @@ -36,11 +49,28 @@ func (a *ProAPIBundle[TTx]) requireDeadLetterJobs(ctx context.Context) error { return nil } -// IDs are strings so JavaScript can preserve the full int64 range. +// deadLetterJobListItem is the public shape of a job in the list response. It +// is declared here rather than built on the driver's result, whose JSON tags +// exist to decode a query and shouldn't define the API. IDs are strings so +// JavaScript can preserve the full int64 range. type deadLetterJobListItem struct { - *riveruidriver.JobDeadLetterListResult + ID int64 `json:"id,string"` + Attempt int `json:"attempt"` + FinalizedAt *time.Time `json:"finalized_at"` + Kind string `json:"kind"` + MaxAttempts int `json:"max_attempts"` + Queue string `json:"queue"` +} - ID int64 `json:"id,string"` +func deadLetterJobListItemFromDriver(job *riveruidriver.JobDeadLetterListResult) *deadLetterJobListItem { + return &deadLetterJobListItem{ + ID: job.ID, + Attempt: job.Attempt, + FinalizedAt: job.FinalizedAt, + Kind: job.Kind, + MaxAttempts: job.MaxAttempts, + Queue: job.Queue, + } } type deadLetterJob struct { @@ -85,6 +115,8 @@ func (*deadLetterJobListEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { return &apiendpoint.EndpointMeta{Pattern: "GET /api/pro/dead-letter-jobs", StatusCode: http.StatusOK} } +// The validate tags must be literals. Limit's maximum mirrors +// riveruidriver.JobDeadLetterListMaxLimit. type deadLetterJobListRequest struct { IDs []int64 `json:"-" validate:"omitempty,max=1000,dive,min=1"` Kinds []string `json:"-" validate:"omitempty,max=100,dive,required,max=1024"` @@ -94,9 +126,31 @@ type deadLetterJobListRequest struct { Limit *int `json:"-" validate:"omitempty,min=1,max=200"` } +// validateDeadLetterText rejects text that the databases can't handle +// consistently. PostgreSQL refuses NUL bytes and invalid UTF-8 in both text and +// jsonb parameters, which would otherwise surface as an internal server error, +// while SQLite accepts them. +func validateDeadLetterText(param string, values ...string) error { + for _, value := range values { + if strings.ContainsRune(value, 0) || !utf8.ValidString(value) { + return apierror.NewBadRequestf("Invalid `%s` value: must be valid UTF-8 without NUL bytes.", param) + } + } + return nil +} + func (req *deadLetterJobListRequest) ExtractRaw(r *http.Request) error { query := r.URL.Query() req.Kinds, req.Queues, req.Tags = query["kinds"], query["queues"], query["tags"] + if err := validateDeadLetterText("kinds", req.Kinds...); err != nil { + return err + } + if err := validateDeadLetterText("queues", req.Queues...); err != nil { + return err + } + if err := validateDeadLetterText("tags", req.Tags...); err != nil { + return err + } for _, value := range query["ids"] { id, err := strconv.ParseInt(value, 10, 64) if err != nil { @@ -127,15 +181,13 @@ func (a *deadLetterJobListEndpoint[TTx]) Execute(ctx context.Context, req *deadL } jobs, err := a.uiDriver.GetExecutor(a.DB).JobDeadLetterList(ctx, &riveruidriver.JobDeadLetterListParams{ Filters: riveruidriver.JobDeadLetterFilters{IDs: req.IDs, Kinds: req.Kinds, Priorities: req.Priorities, Queues: req.Queues, Tags: req.Tags}, - Max: ptrutil.ValOrDefault(req.Limit, 20), + Max: ptrutil.ValOrDefault(req.Limit, riveruidriver.JobDeadLetterListMaxDefault), Schema: a.Client.Schema(), }) if err != nil { return nil, fmt.Errorf("error listing dead letter jobs: %w", err) } - return listResponseFrom(sliceutil.Map(jobs, func(job *riveruidriver.JobDeadLetterListResult) *deadLetterJobListItem { - return &deadLetterJobListItem{JobDeadLetterListResult: job, ID: job.ID} - })), nil + return listResponseFrom(sliceutil.Map(jobs, deadLetterJobListItemFromDriver)), nil } type deadLetterJobRequest struct { @@ -229,16 +281,21 @@ func (*deadLetterAutocompleteEndpoint[TTx]) Meta() *apiendpoint.EndpointMeta { return &apiendpoint.EndpointMeta{Pattern: "GET /api/pro/dead-letter-jobs/autocomplete", StatusCode: http.StatusOK} } +// The validate tags must be literals. Facet's values mirror the +// riveruidriver.JobDeadLetterFacet constants. type deadLetterAutocompleteRequest struct { - Facet string `json:"-" validate:"required,oneof=job_kind queue_name"` - Match string `json:"-" validate:"max=1024"` - Exclude []string `json:"-" validate:"omitempty,max=100,dive,max=1024"` + Facet riveruidriver.JobDeadLetterFacet `json:"-" validate:"required,oneof=job_kind queue_name"` + Match string `json:"-" validate:"max=1024"` + Exclude []string `json:"-" validate:"omitempty,max=100,dive,max=1024"` } func (req *deadLetterAutocompleteRequest) ExtractRaw(r *http.Request) error { query := r.URL.Query() - req.Facet, req.Match, req.Exclude = query.Get("facet"), query.Get("match"), query["exclude"] - return nil + req.Facet, req.Match, req.Exclude = riveruidriver.JobDeadLetterFacet(query.Get("facet")), query.Get("match"), query["exclude"] + if err := validateDeadLetterText("match", req.Match); err != nil { + return err + } + return validateDeadLetterText("exclude", req.Exclude...) } func (a *deadLetterAutocompleteEndpoint[TTx]) Execute(ctx context.Context, req *deadLetterAutocompleteRequest) (*listResponse[string], error) { @@ -246,7 +303,7 @@ func (a *deadLetterAutocompleteEndpoint[TTx]) Execute(ctx context.Context, req * return nil, err } values, err := a.uiDriver.GetExecutor(a.DB).JobDeadLetterAutocomplete(ctx, &riveruidriver.JobDeadLetterAutocompleteParams{ - Facet: req.Facet, Match: req.Match, Exclude: req.Exclude, Max: 100, Schema: a.Client.Schema(), + Facet: req.Facet, Match: req.Match, Exclude: req.Exclude, Max: riveruidriver.JobDeadLetterAutocompleteMaxLimit, Schema: a.Client.Schema(), }) if err != nil { return nil, err diff --git a/riverproui/internal/prohandler/dead_letter_endpoints_test.go b/riverproui/internal/prohandler/dead_letter_endpoints_test.go index f149d285..001cfb80 100644 --- a/riverproui/internal/prohandler/dead_letter_endpoints_test.go +++ b/riverproui/internal/prohandler/dead_letter_endpoints_test.go @@ -6,19 +6,24 @@ import ( "fmt" "net/http" "net/http/httptest" + "reflect" "strings" "testing" "time" "github.com/stretchr/testify/require" + "github.com/riverqueue/apiframe/apiendpoint" + "github.com/riverqueue/apiframe/apierror" "github.com/riverqueue/apiframe/apitest" "github.com/riverqueue/river/riverdriver" + "github.com/riverqueue/river/rivershared/util/dbutil" "github.com/riverqueue/river/rivertype" "riverqueue.com/riverpro/driver" "riverqueue.com/riverui/internal/riverinternaltest/testfactory" + "riverqueue.com/riverui/internal/riveruidriver" ) func insertDeadLetterJob(ctx context.Context, t *testing.T, bundle *setupEndpointTestBundle) *rivertype.JobRow { @@ -68,17 +73,18 @@ func TestDeadLetterJobList(t *testing.T) { encoded, err = json.Marshal(resp) require.NoError(t, err) require.Contains(t, string(encoded), fmt.Sprintf(`"id":"%d"`, job.ID)) + var newest *rivertype.JobRow for range 24 { - insertDeadLetterJob(ctx, t, bundle) + newest = insertDeadLetterJob(ctx, t, bundle) } resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{}) require.NoError(t, err) - require.Len(t, resp.Data, 20) - require.Equal(t, job.ID, resp.Data[0].ID) + require.Len(t, resp.Data, riveruidriver.JobDeadLetterListMaxDefault) + require.Equal(t, newest.ID, resp.Data[0].ID, "newest jobs must come first") resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Limit: new(2)}) require.NoError(t, err) require.Len(t, resp.Data, 2) - require.Less(t, resp.Data[0].ID, resp.Data[1].ID) + require.Greater(t, resp.Data[0].ID, resp.Data[1].ID) resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Limit: new(40)}) require.NoError(t, err) require.Len(t, resp.Data, 25) @@ -91,25 +97,200 @@ func TestDeadLetterJobList(t *testing.T) { resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Queues: []string{"missing"}}) require.NoError(t, err) require.Empty(t, resp.Data) - for _, invalid := range []*deadLetterJobListRequest{ - {IDs: []int64{0}}, - {IDs: []int64{-1}}, - {IDs: make([]int64, 1001)}, - {Priorities: []int16{0}}, - {Priorities: []int16{5}}, - {Kinds: []string{""}}, - {Queues: []string{strings.Repeat("x", 1025)}}, - {Tags: make([]string, 101)}, - } { - _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), invalid) - require.Error(t, err) - } - for _, limit := range []int{-1, 0, 201} { + for _, limit := range []int{-1, 0, riveruidriver.JobDeadLetterListMaxLimit + 1} { _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{Limit: &limit}) require.Error(t, err) } } +// The validate tags can't refer to constants, so check that they agree with the +// limits and facets that the drivers enforce. +func TestDeadLetterRequestTagsMatchDriver(t *testing.T) { + t.Parallel() + + validateTag := func(request any, fieldName string) string { + field, ok := reflect.TypeOf(request).FieldByName(fieldName) + require.True(t, ok) + return field.Tag.Get("validate") + } + require.Equal(t, + fmt.Sprintf("omitempty,min=1,max=%d", riveruidriver.JobDeadLetterListMaxLimit), + validateTag(deadLetterJobListRequest{}, "Limit")) + require.Equal(t, + fmt.Sprintf("required,oneof=%s %s", riveruidriver.JobDeadLetterFacetJobKind, riveruidriver.JobDeadLetterFacetQueueName), + validateTag(deadLetterAutocompleteRequest{}, "Facet")) +} + +// validDeadLetterIDs and validDeadLetterValues return slices whose elements +// pass validation, so that only the size of the slice is under test. +func validDeadLetterIDs(count int) []int64 { + ids := make([]int64, count) + for i := range ids { + ids[i] = int64(i) + 1 + } + return ids +} + +func validDeadLetterValues(count int) []string { + values := make([]string, count) + for i := range values { + values[i] = fmt.Sprintf("value_%d", i) + } + return values +} + +func TestDeadLetterJobListValidation(t *testing.T) { + t.Parallel() + ctx := t.Context() + endpoint, _ := setupEndpoint(ctx, t, NewDeadLetterJobListEndpoint) + + const ( + tooManyElements = "must contain at most %d element(s)" + tooLong = "must be at most 1024 character(s) long" + required = "is required" + ) + for _, tt := range []struct { + name string + request *deadLetterJobListRequest + wantErr string // empty when the request is valid + }{ + {name: "IDsAtLimit", request: &deadLetterJobListRequest{IDs: validDeadLetterIDs(1000)}}, + {name: "IDsOverLimit", request: &deadLetterJobListRequest{IDs: validDeadLetterIDs(1001)}, wantErr: fmt.Sprintf(tooManyElements, 1000)}, + {name: "IDZero", request: &deadLetterJobListRequest{IDs: []int64{0}}, wantErr: "must be greater or equal to 1"}, + {name: "IDNegative", request: &deadLetterJobListRequest{IDs: []int64{-1}}, wantErr: "must be greater or equal to 1"}, + {name: "KindsAtLimit", request: &deadLetterJobListRequest{Kinds: validDeadLetterValues(100)}}, + {name: "KindsOverLimit", request: &deadLetterJobListRequest{Kinds: validDeadLetterValues(101)}, wantErr: fmt.Sprintf(tooManyElements, 100)}, + {name: "KindAtLength", request: &deadLetterJobListRequest{Kinds: []string{strings.Repeat("x", 1024)}}}, + {name: "KindOverLength", request: &deadLetterJobListRequest{Kinds: []string{strings.Repeat("x", 1025)}}, wantErr: tooLong}, + {name: "KindEmpty", request: &deadLetterJobListRequest{Kinds: []string{""}}, wantErr: required}, + {name: "QueuesAtLimit", request: &deadLetterJobListRequest{Queues: validDeadLetterValues(100)}}, + {name: "QueuesOverLimit", request: &deadLetterJobListRequest{Queues: validDeadLetterValues(101)}, wantErr: fmt.Sprintf(tooManyElements, 100)}, + {name: "QueueAtLength", request: &deadLetterJobListRequest{Queues: []string{strings.Repeat("x", 1024)}}}, + {name: "QueueOverLength", request: &deadLetterJobListRequest{Queues: []string{strings.Repeat("x", 1025)}}, wantErr: tooLong}, + {name: "QueueEmpty", request: &deadLetterJobListRequest{Queues: []string{""}}, wantErr: required}, + {name: "TagsAtLimit", request: &deadLetterJobListRequest{Tags: validDeadLetterValues(100)}}, + {name: "TagsOverLimit", request: &deadLetterJobListRequest{Tags: validDeadLetterValues(101)}, wantErr: fmt.Sprintf(tooManyElements, 100)}, + {name: "TagAtLength", request: &deadLetterJobListRequest{Tags: []string{strings.Repeat("x", 1024)}}}, + {name: "TagOverLength", request: &deadLetterJobListRequest{Tags: []string{strings.Repeat("x", 1025)}}, wantErr: tooLong}, + {name: "TagEmpty", request: &deadLetterJobListRequest{Tags: []string{""}}, wantErr: required}, + {name: "PrioritiesAll", request: &deadLetterJobListRequest{Priorities: []int16{1, 2, 3, 4}}}, + {name: "PrioritiesOverLimit", request: &deadLetterJobListRequest{Priorities: []int16{1, 2, 3, 4, 1}}, wantErr: fmt.Sprintf(tooManyElements, 4)}, + {name: "PriorityZero", request: &deadLetterJobListRequest{Priorities: []int16{0}}, wantErr: "min"}, + {name: "PriorityTooHigh", request: &deadLetterJobListRequest{Priorities: []int16{5}}, wantErr: "max"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), tt.request) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + var badRequest *apierror.BadRequest + require.ErrorAs(t, err, &badRequest) + require.Equal(t, http.StatusBadRequest, badRequest.StatusCode) + require.Contains(t, badRequest.Message, tt.wantErr) + }) + } +} + +func TestDeadLetterAutocompleteValidation(t *testing.T) { + t.Parallel() + ctx := t.Context() + endpoint, _ := setupEndpoint(ctx, t, NewDeadLetterAutocompleteEndpoint) + + const tooLong = "must be at most 1024 character(s) long" + for _, tt := range []struct { + name string + request *deadLetterAutocompleteRequest + wantErr string // empty when the request is valid + }{ + {name: "MatchAtLength", request: &deadLetterAutocompleteRequest{Facet: "job_kind", Match: strings.Repeat("x", 1024)}}, + {name: "MatchOverLength", request: &deadLetterAutocompleteRequest{Facet: "job_kind", Match: strings.Repeat("x", 1025)}, wantErr: tooLong}, + {name: "ExcludeAtLimit", request: &deadLetterAutocompleteRequest{Facet: "job_kind", Exclude: validDeadLetterValues(100)}}, + {name: "ExcludeOverLimit", request: &deadLetterAutocompleteRequest{Facet: "job_kind", Exclude: validDeadLetterValues(101)}, wantErr: "must contain at most 100 element(s)"}, + {name: "ExcludeValueAtLength", request: &deadLetterAutocompleteRequest{Facet: "queue_name", Exclude: []string{strings.Repeat("x", 1024)}}}, + {name: "ExcludeValueOverLength", request: &deadLetterAutocompleteRequest{Facet: "queue_name", Exclude: []string{strings.Repeat("x", 1025)}}, wantErr: tooLong}, + {name: "FacetMissing", request: &deadLetterAutocompleteRequest{}, wantErr: "is required"}, + {name: "FacetUnknown", request: &deadLetterAutocompleteRequest{Facet: "tags"}, wantErr: "should be one of the following values: job_kind queue_name"}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), tt.request) + if tt.wantErr == "" { + require.NoError(t, err) + return + } + var badRequest *apierror.BadRequest + require.ErrorAs(t, err, &badRequest) + require.Equal(t, http.StatusBadRequest, badRequest.StatusCode) + require.Contains(t, badRequest.Message, tt.wantErr) + }) + } +} + +// Every dead letter endpoint must report a missing table as a bad request that +// names the migration, rather than failing in a query. +func TestDeadLetterJobsRequireTable(t *testing.T) { + t.Parallel() + ctx := t.Context() + + dropTable := func(t *testing.T, bundle *setupEndpointTestBundle) { + t.Helper() + require.NoError(t, bundle.exec.Exec(ctx, "DROP TABLE "+dbutil.SafeIdentifier(bundle.schema)+"."+deadLetterTableName)) + + exists, err := HasDeadLetterTable(ctx, bundle.exec, bundle.schema) + require.NoError(t, err) + require.False(t, exists) + } + requireMissingTableError := func(t *testing.T, err error) { + t.Helper() + var badRequest *apierror.BadRequest + require.ErrorAs(t, err, &badRequest) + require.Equal(t, http.StatusBadRequest, badRequest.StatusCode) + require.Equal(t, "Dead letter jobs require River Pro's dead letter queue migration.", badRequest.Message) + } + + t.Run("List", func(t *testing.T) { + t.Parallel() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterJobListEndpoint) + exists, err := HasDeadLetterTable(ctx, bundle.exec, bundle.schema) + require.NoError(t, err) + require.True(t, exists) + dropTable(t, bundle) + _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{}) + requireMissingTableError(t, err) + }) + t.Run("Autocomplete", func(t *testing.T) { + t.Parallel() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterAutocompleteEndpoint) + dropTable(t, bundle) + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterAutocompleteRequest{Facet: "job_kind"}) + requireMissingTableError(t, err) + }) + t.Run("Get", func(t *testing.T) { + t.Parallel() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterJobGetEndpoint) + dropTable(t, bundle) + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: 1}) + requireMissingTableError(t, err) + }) + t.Run("Retry", func(t *testing.T) { + t.Parallel() + endpoint, bundle := setupEndpoint(ctx, t, NewDeadLetterJobRetryEndpoint) + dropTable(t, bundle) + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: 1}) + requireMissingTableError(t, err) + }) +} + +func requireDeadLetterNotFound(t *testing.T, err error, jobID int64) { + t.Helper() + var notFound *apierror.NotFound + require.ErrorAs(t, err, ¬Found) + require.Equal(t, http.StatusNotFound, notFound.StatusCode) + require.Equal(t, fmt.Sprintf("Dead letter job with ID %d not found. It may already have been retried.", jobID), notFound.Message) +} + func TestDeadLetterJobGet(t *testing.T) { t.Parallel() ctx := t.Context() @@ -123,7 +304,7 @@ func TestDeadLetterJobGet(t *testing.T) { require.Equal(t, "delivery failed", resp.Errors[0].Error) _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID + 1}) - require.EqualError(t, err, fmt.Sprintf("Dead letter job with ID %d not found. It may already have been retried.", job.ID+1)) + requireDeadLetterNotFound(t, err, job.ID+1) } func TestDeadLetterJobRetry(t *testing.T) { @@ -148,9 +329,9 @@ func TestDeadLetterJobRetry(t *testing.T) { require.Equal(t, rivertype.JobStateAvailable, live.State) _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID}) - require.EqualError(t, err, fmt.Sprintf("Dead letter job with ID %d not found. It may already have been retried.", job.ID)) + requireDeadLetterNotFound(t, err, job.ID) _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: live.ID}) - require.EqualError(t, err, fmt.Sprintf("Dead letter job with ID %d not found. It may already have been retried.", live.ID)) + requireDeadLetterNotFound(t, err, live.ID) } func TestDeadLetterJobRetryUniqueConflict(t *testing.T) { @@ -170,7 +351,10 @@ func TestDeadLetterJobRetryUniqueConflict(t *testing.T) { require.NoError(t, err) _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: job.ID}) - require.ErrorContains(t, err, "another active job has the same unique properties") + var apiErr *apierror.APIError + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusConflict, apiErr.StatusCode) + require.Equal(t, "Job can't be retried because another active job has the same unique properties. Wait for it to finish before retrying.", apiErr.Message) _, err = bundle.exec.JobDeadLetterGetByID(ctx, &driver.JobDeadLetterGetByIDParams{ID: job.ID, Schema: bundle.schema}) require.NoError(t, err, "a conflict must retain the dead letter job") } @@ -195,6 +379,21 @@ func TestDeadLetterJobSerialization(t *testing.T) { require.Contains(t, string(encoded), `"errors":[]`) } +func TestDeadLetterJobListItemSerialization(t *testing.T) { + t.Parallel() + + finalizedAt := time.Date(2026, time.October, 21, 10, 2, 3, 456_000_000, time.UTC) + encoded, err := json.Marshal(deadLetterJobListItemFromDriver(&riveruidriver.JobDeadLetterListResult{ + ID: 1970670598291982290, Attempt: 3, FinalizedAt: &finalizedAt, Kind: "send_email", MaxAttempts: 5, Queue: "mail", + })) + require.NoError(t, err) + require.JSONEq(t, `{"id":"1970670598291982290","attempt":3,"finalized_at":"2026-10-21T10:02:03.456Z","kind":"send_email","max_attempts":5,"queue":"mail"}`, string(encoded)) + + encoded, err = json.Marshal(deadLetterJobListItemFromDriver(&riveruidriver.JobDeadLetterListResult{ID: 1})) + require.NoError(t, err) + require.JSONEq(t, `{"id":"1","attempt":0,"finalized_at":null,"kind":"","max_attempts":0,"queue":""}`, string(encoded)) +} + func TestDeadLetterJobListRequest(t *testing.T) { t.Parallel() for _, query := range []string{"limit=invalid", "limit=1.5", "limit=9223372036854775808", "ids=bad", "ids=1.5", "ids=9223372036854775808", "priorities=bad", "priorities=32768"} { @@ -212,6 +411,75 @@ func TestDeadLetterJobListRequest(t *testing.T) { require.Equal(t, []string{"urgent", "batch"}, parsed.Tags) } +func TestDeadLetterRequestRejectsInvalidText(t *testing.T) { + t.Parallel() + + extractList := func(r *http.Request) error { return (&deadLetterJobListRequest{}).ExtractRaw(r) } + extractAutocomplete := func(r *http.Request) error { return (&deadLetterAutocompleteRequest{}).ExtractRaw(r) } + for _, tt := range []struct { + param string + extract func(r *http.Request) error + }{ + {param: "kinds", extract: extractList}, + {param: "queues", extract: extractList}, + {param: "tags", extract: extractList}, + {param: "match", extract: extractAutocomplete}, + {param: "exclude", extract: extractAutocomplete}, + } { + // A NUL byte alone and within text, then invalid UTF-8 sequences. + for _, value := range []string{"%00", "send%00email", "%ff", "caf%c3"} { + t.Run(tt.param+"="+value, func(t *testing.T) { + t.Parallel() + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/?facet=job_kind&kinds=valid&"+tt.param+"="+value, nil) + err := tt.extract(req) + var badRequest *apierror.BadRequest + require.ErrorAs(t, err, &badRequest) + require.Equal(t, http.StatusBadRequest, badRequest.StatusCode) + require.Equal(t, "Invalid `"+tt.param+"` value: must be valid UTF-8 without NUL bytes.", badRequest.Message) + }) + } + } + + // Other control characters and non-ASCII text are valid in both databases. + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/?kinds=caf%C3%A9&queues=tab%09bed&tags=%C3%BCber", nil) + var parsed deadLetterJobListRequest + require.NoError(t, parsed.ExtractRaw(req)) + require.Equal(t, []string{"café"}, parsed.Kinds) + require.Equal(t, []string{"tab\tbed"}, parsed.Queues) + require.Equal(t, []string{"über"}, parsed.Tags) +} + +// Requests go through the mounted endpoint so that a value PostgreSQL rejects +// is shown to produce a bad request rather than an internal server error. +func TestDeadLetterInvalidTextStatus(t *testing.T) { + t.Parallel() + ctx := t.Context() + + listEndpoint, _ := setupEndpoint(ctx, t, NewDeadLetterJobListEndpoint) + autocompleteEndpoint, _ := setupEndpoint(ctx, t, NewDeadLetterAutocompleteEndpoint) + mux := http.NewServeMux() + apiendpoint.Mount(mux, listEndpoint, testMountOpts(t)) + apiendpoint.Mount(mux, autocompleteEndpoint, testMountOpts(t)) + + for _, tt := range []struct { + path string + want int + }{ + {path: "/api/pro/dead-letter-jobs?kinds=%00", want: http.StatusBadRequest}, + {path: "/api/pro/dead-letter-jobs?queues=%00", want: http.StatusBadRequest}, + {path: "/api/pro/dead-letter-jobs?tags=%ff", want: http.StatusBadRequest}, + {path: "/api/pro/dead-letter-jobs?kinds=caf%C3%A9&tags=tab%09bed", want: http.StatusOK}, + {path: "/api/pro/dead-letter-jobs/autocomplete?facet=job_kind&match=%00", want: http.StatusBadRequest}, + {path: "/api/pro/dead-letter-jobs/autocomplete?facet=job_kind&match=%ff", want: http.StatusBadRequest}, + {path: "/api/pro/dead-letter-jobs/autocomplete?facet=job_kind&exclude=%00", want: http.StatusBadRequest}, + {path: "/api/pro/dead-letter-jobs/autocomplete?facet=job_kind&match=caf%C3%A9", want: http.StatusOK}, + } { + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, httptest.NewRequestWithContext(ctx, http.MethodGet, tt.path, nil)) + require.Equal(t, tt.want, recorder.Code, "%s: %s", tt.path, recorder.Body.String()) + } +} + func TestDeadLetterAutocomplete(t *testing.T) { t.Parallel() ctx := t.Context() @@ -233,8 +501,9 @@ func TestDeadLetterAutocomplete(t *testing.T) { } require.Equal(t, tt.want, got) } - for _, facet := range []string{"", "tags", "id"} { + for _, facet := range []riveruidriver.JobDeadLetterFacet{"", "tags", "id"} { _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterAutocompleteRequest{Facet: facet}) - require.Error(t, err) + var badRequest *apierror.BadRequest + require.ErrorAs(t, err, &badRequest) } } diff --git a/src/components/CopyableHeadingText.tsx b/src/components/CopyableHeadingText.tsx new file mode 100644 index 00000000..abb0dc44 --- /dev/null +++ b/src/components/CopyableHeadingText.tsx @@ -0,0 +1,62 @@ +import { CheckIcon } from "@heroicons/react/16/solid"; +import { ClipboardIcon } from "@heroicons/react/24/outline"; +import { toastSuccess } from "@services/toast"; +import { capitalize } from "@utils/string"; +import { useState } from "react"; + +type CopyableHeadingTextProps = { + // Names what is copied, in lowercase, such as "job kind". + label: string; + text: string; +}; + +// CopyableHeadingText keeps a heading on one line. Text that doesn't fit +// is cut with an ellipsis, and the copy button and tooltip give the full +// text. +export default function CopyableHeadingText({ + label, + text, +}: CopyableHeadingTextProps) { + const [copied, setCopied] = useState(false); + + const copyToClipboard = () => { + if (!navigator.clipboard?.writeText) { + console.error(`Failed to copy ${label}: Clipboard API unavailable`); + return; + } + + navigator.clipboard.writeText(text).then( + () => { + setCopied(true); + toastSuccess({ message: `${capitalize(label)} copied to clipboard` }); + setTimeout(() => setCopied(false), 2000); + }, + (error: unknown) => { + console.error(`Failed to copy ${label} to clipboard:`, error); + }, + ); + }; + + return ( + + + {text} + + + + ); +} diff --git a/src/components/DeadLetterJobDetail.stories.ts b/src/components/DeadLetterJobDetail.stories.ts new file mode 100644 index 00000000..65ace602 --- /dev/null +++ b/src/components/DeadLetterJobDetail.stories.ts @@ -0,0 +1,29 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { jobFactory } from "@test/factories/job"; + +import DeadLetterJobDetail from "./DeadLetterJobDetail"; + +const meta: Meta = { + component: DeadLetterJobDetail, + title: "Pages/DeadLetterJobDetail", +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + job: jobFactory.discarded().build(), + retry: () => {}, + retryPending: false, + }, +}; + +export const Retrying: Story = { + args: { + ...Default.args, + retryPending: true, + }, +}; diff --git a/src/components/DeadLetterJobDetail.tsx b/src/components/DeadLetterJobDetail.tsx index 825df79a..a6955e3f 100644 --- a/src/components/DeadLetterJobDetail.tsx +++ b/src/components/DeadLetterJobDetail.tsx @@ -1,12 +1,8 @@ import { ArrowUturnLeftIcon } from "@heroicons/react/24/outline"; import { Job } from "@services/jobs"; -import { Button } from "./Button"; -import JobAttempts from "./JobAttempts"; -import JSONTextView from "./JSONTextView"; -import JSONView from "./JSONView"; -import RelativeTimeFormatter from "./RelativeTimeFormatter"; -import TopNavTitleOnly from "./TopNavTitleOnly"; +import ButtonForGroup from "./ButtonForGroup"; +import { JobDetailView } from "./JobDetail"; export default function DeadLetterJobDetail({ job, @@ -18,72 +14,20 @@ export default function DeadLetterJobDetail({ retryPending: boolean; }) { return ( - <> - -
- -
-
-

{job.kind}

-

- ID: {job.id.toString()} -

-
- -
-

- Retrying removes this job from the dead letter queue and enqueues a - new job with the same args and a fresh attempt history. -

-
-
-
Queue
-
{job.queue}
-
-
-
Attempts
-
- {job.attempt} / {job.maxAttempts} -
-
-
-
Created
-
- -
-
-
-
Discarded
-
- {job.finalizedAt ? ( - - ) : ( - "–" - )} -
-
-
-
-
-
Args
-
- -
-
-
-
Metadata
-
- -
-
- -
-
- + + + + } + job={job} + notice="Retrying removes this job from the dead letter queue and enqueues a new job with the same args and a fresh attempt history." + title="Dead Letter Job" + /> ); } diff --git a/src/components/DeadLetterJobList.stories.ts b/src/components/DeadLetterJobList.stories.ts new file mode 100644 index 00000000..3ad5c1f8 --- /dev/null +++ b/src/components/DeadLetterJobList.stories.ts @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { FilterTypeId } from "@components/job-search/JobSearch"; +import { jobMinimalFactory } from "@test/factories/job"; + +import DeadLetterJobList from "./DeadLetterJobList"; + +const meta: Meta = { + component: DeadLetterJobList, + title: "Pages/DeadLetterJobList", +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + canShowFewer: false, + canShowMore: true, + initialFilters: [], + jobs: jobMinimalFactory.discarded().buildList(10), + loading: false, + onFiltersChange: () => {}, + showFewer: () => {}, + showMore: () => {}, + }, +}; + +export const Empty: Story = { + args: { + ...Default.args, + canShowMore: false, + jobs: [], + }, +}; + +export const NoFilterMatches: Story = { + args: { + ...Empty.args, + initialFilters: [ + { + id: FilterTypeId.KIND, + match: "kind:", + typeId: FilterTypeId.KIND, + values: ["missing"], + }, + ], + }, +}; + +export const RefreshFailed: Story = { + args: { + ...Default.args, + loadError: "Couldn't refresh dead letter jobs: Failed to fetch", + }, +}; diff --git a/src/components/DeadLetterJobList.tsx b/src/components/DeadLetterJobList.tsx index 61312a92..f8e0059b 100644 --- a/src/components/DeadLetterJobList.tsx +++ b/src/components/DeadLetterJobList.tsx @@ -1,18 +1,78 @@ +import { Badge } from "@components/Badge"; import { fetchSuggestions } from "@components/job-search/api"; import { Filter, FilterTypeId, JobSearch, } from "@components/job-search/JobSearch"; +import ListEmptyState from "@components/ListEmptyState"; +import ListPagination from "@components/ListPagination"; +import RelativeTimeFormatter from "@components/RelativeTimeFormatter"; +import TopNavTitleOnly from "@components/TopNavTitleOnly"; import { DeadLetterJobListItem, fetchDeadLetterSuggestions, } from "@services/deadLetterJobs"; import { Link } from "@tanstack/react-router"; -import { Button } from "./Button"; -import RelativeTimeFormatter from "./RelativeTimeFormatter"; -import TopNavTitleOnly from "./TopNavTitleOnly"; +type DeadLetterJobListProps = { + canShowFewer: boolean; + canShowMore: boolean; + filterError?: string; + initialFilters: Filter[]; + jobs: DeadLetterJobListItem[]; + // Set when a refresh fails. The last loaded jobs stay visible. + loadError?: string; + loading: boolean; + onFiltersChange: (filters: Filter[]) => void; + showFewer: () => void; + showMore: () => void; +}; + +const DeadLetterJobRow = ({ job }: { job: DeadLetterJobListItem }) => ( +
  • +
    +
    +
    +
    +
    +

    + + {job.kind}{" "} + + #{job.id.toString()} + + +

    +
    + {job.finalizedAt ? ( + + ) : ( + "–" + )} +
    +
    +
    +
    + {job.attempt.toString()} + / + {job.maxAttempts.toString()} +
    + + {job.queue} + +
    +
    +
  • +); export default function DeadLetterJobList({ canShowFewer, @@ -20,121 +80,76 @@ export default function DeadLetterJobList({ filterError, initialFilters, jobs, + loadError, loading, onFiltersChange, showFewer, showMore, -}: { - canShowFewer: boolean; - canShowMore: boolean; - filterError?: string; - initialFilters: Filter[]; - jobs: DeadLetterJobListItem[]; - loading: boolean; - onFiltersChange: (filters: Filter[]) => void; - showFewer: () => void; - showMore: () => void; -}) { +}: DeadLetterJobListProps) { + const alert = filterError ?? loadError; + return ( <> -
    -

    - Discarded jobs retained in the dead letter queue. Select a job to - inspect its failures and retry it. -

    - - {filterError && ( -

    - {filterError} -

    - )} +
    +
    +
    + +
    + {alert && ( +

    + {alert} +

    + )} +
    {loading ? ( -

    Loading…

    - ) : jobs.length === 0 ? ( -

    - {initialFilters.length - ? "No dead letter jobs match these filters." - : "No dead letter jobs."} +

    + Loading…

    + ) : jobs.length === 0 ? ( +
    + {initialFilters.length ? ( + + ) : ( + + Discarded jobs appear here when the dead letter queue is + enabled with Config.DeadLetter.Enabled in River + Pro. + + } + title="No dead letter jobs." + /> + )} +
    ) : ( <> -
    - - - - - - - - - - - {jobs.map((job) => ( - - - - - - - ))} - -
    - Job - - Queue - - Attempts - - Discarded -
    - - {job.kind}{" "} - #{job.id.toString()} - - {job.queue} - {job.attempt} / {job.maxAttempts} - - {job.finalizedAt ? ( - - ) : ( - "–" - )} -
    -
    - + {jobs.map((job) => ( + + ))} + + )}
    @@ -146,13 +161,15 @@ async function fetchSuggestionsForDeadLetters( type: FilterTypeId, query: string, selectedValues: string[], + signal?: AbortSignal, ): Promise { if (type === FilterTypeId.KIND || type === FilterTypeId.QUEUE) { return fetchDeadLetterSuggestions( type === FilterTypeId.KIND ? "job_kind" : "queue_name", query, selectedValues, + signal, ); } - return fetchSuggestions(type, query, selectedValues); + return fetchSuggestions(type, query, selectedValues, signal); } diff --git a/src/components/JobDetail.tsx b/src/components/JobDetail.tsx index 3040348e..ce26a29e 100644 --- a/src/components/JobDetail.tsx +++ b/src/components/JobDetail.tsx @@ -1,6 +1,7 @@ import { Badge } from "@components/Badge"; import ButtonForGroup from "@components/ButtonForGroup"; import ConfirmationDialog from "@components/ConfirmationDialog"; +import CopyableHeadingText from "@components/CopyableHeadingText"; import JobAttempts from "@components/JobAttempts"; import JobTimeline from "@components/JobTimeline"; import JSONTextView from "@components/JSONTextView"; @@ -17,7 +18,7 @@ import { Job, JobWithKnownMetadata } from "@services/jobs"; import { JobState } from "@services/types"; import { Link } from "@tanstack/react-router"; import { capitalize } from "@utils/string"; -import { FormEvent, useState } from "react"; +import { FormEvent, ReactNode, useState } from "react"; type JobDetailProps = { cancel: () => void; @@ -26,12 +27,44 @@ type JobDetailProps = { retry: () => void; }; +type JobDetailViewProps = { + actions: ReactNode; + job: Job; + // Shown under the heading, for context that applies to the whole job. + notice?: ReactNode; + title: string; +}; + export default function JobDetail({ cancel, deleteFn, job, retry, }: JobDetailProps) { + return ( + + } + job={job} + title="Job Details" + /> + ); +} + +// JobDetailView is the job detail page without its actions, so that other +// panes showing a job (like dead letters) share one layout. +export function JobDetailView({ + actions, + job, + notice, + title, +}: JobDetailViewProps) { const featureEnabledWorkflows = useFeature("ENABLE_WORKFLOWS", true); let jobWithMetadata: JobWithKnownMetadata | undefined; @@ -41,28 +74,26 @@ export default function JobDetail({ return ( <> - +
    {/* Heading */}
    -
    -

    - - {job.kind} - +
    +

    +

    ID: {job.id.toString()}

    + {notice && ( +

    + {notice} +

    + )}
    - + {actions}

    diff --git a/src/components/JobList.tsx b/src/components/JobList.tsx index d8a347fd..024ef1da 100644 --- a/src/components/JobList.tsx +++ b/src/components/JobList.tsx @@ -1,11 +1,12 @@ import { Badge } from "@components/Badge"; -import { Button } from "@components/Button"; import ButtonForGroup from "@components/ButtonForGroup"; import ConfirmationDialog from "@components/ConfirmationDialog"; import { CustomCheckbox } from "@components/CustomCheckbox"; import { Dropdown, DropdownItem, DropdownMenu } from "@components/Dropdown"; import { Filter, JobSearch } from "@components/job-search/JobSearch"; import { JobStateFilters } from "@components/JobStateFilters"; +import ListEmptyState from "@components/ListEmptyState"; +import ListPagination from "@components/ListPagination"; import RelativeTimeFormatter from "@components/RelativeTimeFormatter"; import TopNav from "@components/TopNav"; import { useFeatures } from "@contexts/Features.hook"; @@ -152,35 +153,6 @@ const JobListItem = ({ ); }; -type EmptySetIconProps = React.ComponentProps<"svg">; - -const EmptySetIcon = (props: EmptySetIconProps) => ( - - - - -); - -const EmptyState = () => ( -
    - -

    - No jobs found -

    -

    - Try adjusting your filters to find what you're looking for. -

    -
    -); - export type JobRowsProps = { cancelJobs: (jobIDs: bigint[]) => void; canShowFewer: boolean; @@ -408,7 +380,10 @@ const JobRows = ({
    {jobs.length === 0 ? (
    - +
    ) : ( <> @@ -426,27 +401,12 @@ const JobRows = ({ /> ))} - + )} diff --git a/src/components/JobNotFound.tsx b/src/components/JobNotFound.tsx index 8716d857..6fbd0a63 100644 --- a/src/components/JobNotFound.tsx +++ b/src/components/JobNotFound.tsx @@ -1,7 +1,7 @@ import { JobState } from "@services/types"; import { Button } from "./Button"; -import TopNavTitleOnly from "./TopNavTitleOnly"; +import NotFoundPage from "./NotFoundPage"; type JobNotFoundProps = { jobId: bigint; @@ -9,27 +9,19 @@ type JobNotFoundProps = { export default function JobNotFound({ jobId }: JobNotFoundProps) { return ( - <> - - -
    -
    -

    - 404 -

    -

    - Job not found -

    -

    - Sorry, there is no job with ID {jobId.toString()}. -

    -
    - -
    -
    -
    - + + Return to job list + + } + description={ + <> + Sorry, there is no job with ID {jobId.toString()}. + + } + heading="Job not found" + title="Job Details" + /> ); } diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 57c7e9c0..a5634788 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -16,11 +16,16 @@ import { RectangleGroupIcon, XMarkIcon, } from "@heroicons/react/24/outline"; +import { retainedJobSearchKeys } from "@routes/jobs/index.schema"; import { Link, useRouterState } from "@tanstack/react-router"; import { Fragment, PropsWithChildren, useMemo } from "react"; type LayoutProps = PropsWithChildren; +const clearedJobSearch = Object.fromEntries( + retainedJobSearchKeys.map((key) => [key, undefined]), +); + const Layout = ({ children }: LayoutProps) => { const { open: sidebarOpen, setOpen: setSidebarOpen } = useSidebarSetting(); @@ -37,18 +42,15 @@ const Layout = ({ children }: LayoutProps) => { icon: QueueListIcon, name: "Jobs", // Explicitly clear retained Jobs filters when entering from another pane. - search: inJobsPane - ? {} - : { - id: undefined, - kind: undefined, - limit: undefined, - priority: undefined, - queue: undefined, - tags: undefined, - }, + search: inJobsPane ? {} : clearedJobSearch, }, { href: "/queues", icon: InboxStackIcon, name: "Queues" }, + { + hidden: !features.workflowQueries, + href: "/workflows", + icon: RectangleGroupIcon, + name: "Workflows", + }, { hidden: !features.deadLetterJobs, href: "/dead-letter-jobs", @@ -56,12 +58,6 @@ const Layout = ({ children }: LayoutProps) => { name: "Dead Letters", search: {}, }, - { - hidden: !features.workflowQueries, - href: "/workflows", - icon: RectangleGroupIcon, - name: "Workflows", - }, { hidden: !features.durablePeriodicJobs, href: "/periodic-jobs", diff --git a/src/components/ListEmptyState.tsx b/src/components/ListEmptyState.tsx new file mode 100644 index 00000000..8e1e8bc3 --- /dev/null +++ b/src/components/ListEmptyState.tsx @@ -0,0 +1,37 @@ +import { ReactNode } from "react"; + +type EmptySetIconProps = React.ComponentProps<"svg">; + +const EmptySetIcon = (props: EmptySetIconProps) => ( + + + + +); + +type ListEmptyStateProps = { + description: ReactNode; + title: string; +}; + +const ListEmptyState = ({ description, title }: ListEmptyStateProps) => ( +
    + +

    + {title} +

    +

    + {description} +

    +
    +); + +export default ListEmptyState; diff --git a/src/components/ListPagination.tsx b/src/components/ListPagination.tsx new file mode 100644 index 00000000..9843ce4c --- /dev/null +++ b/src/components/ListPagination.tsx @@ -0,0 +1,39 @@ +import { Button } from "@components/Button"; + +type ListPaginationProps = { + canShowFewer: boolean; + canShowMore: boolean; + showFewer: () => void; + showMore: () => void; +}; + +const ListPagination = ({ + canShowFewer, + canShowMore, + showFewer, + showMore, +}: ListPaginationProps) => ( + +); + +export default ListPagination; diff --git a/src/components/NotFoundPage.tsx b/src/components/NotFoundPage.tsx new file mode 100644 index 00000000..a1b4cfbb --- /dev/null +++ b/src/components/NotFoundPage.tsx @@ -0,0 +1,40 @@ +import { ReactNode } from "react"; + +import TopNavTitleOnly from "./TopNavTitleOnly"; + +type NotFoundPageProps = { + action: ReactNode; + description: ReactNode; + heading: string; + title: string; +}; + +export default function NotFoundPage({ + action, + description, + heading, + title, +}: NotFoundPageProps) { + return ( + <> + + +
    +
    +

    + 404 +

    +

    + {heading} +

    +

    + {description} +

    +
    + {action} +
    +
    +
    + + ); +} diff --git a/src/components/WorkflowDetail.tsx b/src/components/WorkflowDetail.tsx index 3dfeaf72..fd3ad668 100644 --- a/src/components/WorkflowDetail.tsx +++ b/src/components/WorkflowDetail.tsx @@ -1,4 +1,5 @@ import ButtonForGroup from "@components/ButtonForGroup"; +import CopyableHeadingText from "@components/CopyableHeadingText"; import { DurationCompact } from "@components/DurationCompact"; import { Subheading } from "@components/Heading"; import { RunningSpinnerIcon } from "@components/icons/jobStateIcons"; @@ -18,12 +19,7 @@ import WorkflowGateInspector, { } from "@components/WorkflowGateInspector"; import { useFeatures } from "@contexts/Features.hook"; // (Dialog is now encapsulated in RetryWorkflowDialog) -import { CheckIcon } from "@heroicons/react/16/solid"; -import { - ArrowPathIcon, - ClipboardIcon, - LinkIcon, -} from "@heroicons/react/24/outline"; +import { ArrowPathIcon, LinkIcon } from "@heroicons/react/24/outline"; import { CheckCircleIcon, PlayCircleIcon, @@ -31,7 +27,6 @@ import { TrashIcon, XCircleIcon, } from "@heroicons/react/24/solid"; -import { toastSuccess } from "@services/toast"; import { type Heroicon, JobState } from "@services/types"; import { Workflow, @@ -122,7 +117,6 @@ export default function WorkflowDetail({ // Modal state for retry const [retryOpen, setRetryOpen] = useState(false); const [retryMode, setRetryMode] = useState(); - const [workflowNameCopied, setWorkflowNameCopied] = useState(false); if (!features.workflowQueries) { return ( @@ -156,52 +150,7 @@ export default function WorkflowDetail({

    - - - {workflowName} - - - +

    ID: {workflow.id} diff --git a/src/components/job-search/JobSearch.test.tsx b/src/components/job-search/JobSearch.test.tsx index 684081b0..64b346e7 100644 --- a/src/components/job-search/JobSearch.test.tsx +++ b/src/components/job-search/JobSearch.test.tsx @@ -170,6 +170,29 @@ describe("JobSearch", () => { expect(input).toHaveValue("kind:batch"); }); + it("aborts a suggestion request when a newer one replaces it", async () => { + const signals: AbortSignal[] = []; + const fetchSuggestions = vi.fn( + ( + _type: FilterTypeId, + _query: string, + _selected: string[], + signal?: AbortSignal, + ) => { + if (signal) signals.push(signal); + return new Promise(() => {}); + }, + ); + render(); + const input = screen.getByTestId("job-search-input"); + fireEvent.change(input, { target: { value: "kind:b" } }); + await waitFor(() => expect(signals).toHaveLength(1)); + fireEvent.change(input, { target: { value: "kind:ba" } }); + await waitFor(() => expect(signals).toHaveLength(2)); + expect(signals[0].aborted).toBe(true); + expect(signals[1].aborted).toBe(false); + }); + it("notifies parent of filter changes", async () => { const onFiltersChange = vi.fn(); render(); diff --git a/src/components/job-search/JobSearch.tsx b/src/components/job-search/JobSearch.tsx index fc7b2155..887ab2f5 100644 --- a/src/components/job-search/JobSearch.tsx +++ b/src/components/job-search/JobSearch.tsx @@ -22,12 +22,14 @@ export interface JobSearchProps { * @param filterTypeId - The ID of the filter type * @param query - The search query * @param selectedValues - Values already selected for this filter + * @param signal - Aborted when a newer request replaces this one * @returns Promise resolving to an array of suggestion strings */ fetchSuggestions?: ( filterTypeId: JobFilterTypeID, query: string, selectedValues: string[], + signal?: AbortSignal, ) => Promise; /** * Initial filters to display diff --git a/src/components/job-search/api.ts b/src/components/job-search/api.ts index 93b4d107..fe4f53ad 100644 --- a/src/components/job-search/api.ts +++ b/src/components/job-search/api.ts @@ -8,6 +8,7 @@ export async function fetchSuggestions( filterTypeId: JobFilterTypeID, query: string, selectedValues: string[], + signal?: AbortSignal, ): Promise { if (filterTypeId === JobFilterTypeID.PRIORITY) { // Priority is a hardcoded list of values—we just need to filter out @@ -31,7 +32,13 @@ export async function fetchSuggestions( } if (fetchType) { - return await fetchAutocomplete(fetchType, query, undefined, selectedValues); + return await fetchAutocomplete( + fetchType, + query, + undefined, + selectedValues, + signal, + ); } return []; diff --git a/src/components/job-search/hooks.ts b/src/components/job-search/hooks.ts index b19c6625..60b1d752 100644 --- a/src/components/job-search/hooks.ts +++ b/src/components/job-search/hooks.ts @@ -21,6 +21,7 @@ interface UseFilterInputProps { filterTypeId: JobFilterTypeID, query: string, selectedValues: string[], + signal?: AbortSignal, ) => Promise; initialFilters: JobFilter[]; onFiltersChange?: (filters: JobFilter[]) => void; @@ -32,6 +33,7 @@ interface UseSuggestionsProps { filterTypeId: JobFilterTypeID, query: string, selectedValues: string[], + signal?: AbortSignal, ) => Promise; suppressSuggestions: boolean; } @@ -237,10 +239,15 @@ export function useSuggestions({ }); const latestSuggestionRequestRef = useRef(0); + const suggestionAbortRef = useRef(undefined); const updateSuggestions = useCallback( async (text: string, cursor: number) => { const requestId = ++latestSuggestionRequestRef.current; + // A newer request replaces this one, so stop the old fetch instead of + // leaving its query running on the server. + suggestionAbortRef.current?.abort(); + suggestionAbortRef.current = undefined; const context = analyzeAutocompleteContext( text, cursor, @@ -289,10 +296,13 @@ export function useSuggestions({ let suggestions: string[] = []; if (context.type === "filter-value" && context.filterTypeId) { + const abortController = new AbortController(); + suggestionAbortRef.current = abortController; suggestions = await fetchSuggestions( context.filterTypeId, context.currentToken || "", context.existingValues || [], + abortController.signal, ); } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 8e12b44a..03f56222 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,3 +1,5 @@ +import { Button } from "@components/Button"; +import NotFoundPage from "@components/NotFoundPage"; import { Root } from "@components/Root"; import { type Features, featuresKey, getFeatures } from "@services/features"; import { QueryClient } from "@tanstack/react-query"; @@ -18,6 +20,20 @@ export const Route = createRootRouteWithContext<{ }; }, component: RootComponent, + // Also shown for a pane this server doesn't support, such as a Pro-only + // route opened on an OSS server. + notFoundComponent: () => ( + + Return to job list + + } + description="This page doesn't exist, or this River UI server doesn't support it." + heading="Page not found" + title="Not Found" + /> + ), }); function RootComponent() { diff --git a/src/routes/dead-letter-jobs.test.tsx b/src/routes/dead-letter-jobs.test.tsx index 78c25b77..18de4556 100644 --- a/src/routes/dead-letter-jobs.test.tsx +++ b/src/routes/dead-letter-jobs.test.tsx @@ -1,7 +1,10 @@ import Layout from "@components/Layout"; import { FeaturesContext } from "@contexts/Features"; import { SidebarSettingProvider } from "@contexts/SidebarSetting.provider"; -import { deadLetterJobSearchSchema } from "@routes/dead-letter-jobs/index.schema"; +import { + deadLetterJobRouteSearchSchema, + deadLetterJobSearchSchema, +} from "@routes/dead-letter-jobs/index.schema"; import { listDeadLetterJobsKey } from "@services/deadLetterJobs"; import { featuresKey } from "@services/features"; import { Job, listJobs, listJobsKey } from "@services/jobs"; @@ -30,7 +33,7 @@ import { } from "@testing-library/react"; import { APIResponseError, NotFoundError } from "@utils/api"; import qs from "query-string"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "../global-type-overrides"; import { routeTree } from "../routeTree.gen"; @@ -126,6 +129,10 @@ async function renderRoute(path: string, pro = true) { return { queryClient, router }; } +afterEach(() => { + vi.useRealTimers(); +}); + beforeEach(() => { vi.clearAllMocks(); fetchDeadLetterSuggestions.mockResolvedValue([]); @@ -228,9 +235,46 @@ describe("Dead letter jobs", () => { ).not.toBeInTheDocument(); expect(listDeadLetterJobs).not.toHaveBeenCalled(); expect(getDeadLetterJob).not.toHaveBeenCalled(); + expect( + screen.getByRole("heading", { name: "Page not found" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Return to job list" }), + ).toHaveAttribute("href", "/jobs"); }, ); + it("opens the list when a bookmarked URL has filters it can't parse", async () => { + const { router } = await renderRoute( + "/dead-letter-jobs?id=abc&priority=5&queue=mail", + ); + expect( + await screen.findByRole("link", { name: `send_email #${job.id}` }), + ).toBeInTheDocument(); + expect(screen.getByTestId("job-search-input")).toHaveValue("queue:mail"); + expect(router.state.location.search).toEqual({ queue: "mail" }); + expect(listDeadLetterJobs).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: listDeadLetterJobsKey(20, { queues: ["mail"] }), + }), + ); + }); + + it("keeps the loaded jobs visible when a refresh fails", async () => { + const { queryClient } = await renderRoute("/dead-letter-jobs"); + listDeadLetterJobs.mockRejectedValue(new Error("Failed to fetch")); + await act(async () => { + await queryClient.refetchQueries({ queryKey: listDeadLetterJobsKey() }); + }); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Couldn't refresh dead letter jobs: Failed to fetch", + ); + expect( + screen.getByRole("link", { name: `send_email #${job.id}` }), + ).toBeInTheDocument(); + expect(screen.getByTestId("job-search-input")).toBeInTheDocument(); + }); + it("shows the Pro navigation and opens the selected dead letter job", async () => { const { router } = await renderRoute("/dead-letter-jobs"); expect( @@ -397,6 +441,7 @@ describe("Dead letter jobs", () => { }); it("restores the search field on browser Back", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); const { router } = await renderRoute("/dead-letter-jobs?queue=mail"); await act(async () => { await router.navigate({ @@ -413,8 +458,9 @@ describe("Dead letter jobs", () => { await waitFor(() => expect(screen.getByTestId("job-search-input")).toHaveValue("queue:mail"), ); + // Run the filter debounce to confirm it doesn't rewrite the restored URL. await act(async () => { - await new Promise((resolve) => setTimeout(resolve, 300)); + await vi.advanceTimersByTimeAsync(300); }); expect(router.state.location.search).toEqual({ queue: "mail" }); }); @@ -442,6 +488,7 @@ describe("Dead letter jobs", () => { "job_kind", "archive_", [], + expect.any(AbortSignal), ), ); fireEvent.change(input, { target: { value: "queue:mail,re" } }); @@ -450,6 +497,7 @@ describe("Dead letter jobs", () => { "queue_name", "re", ["mail"], + expect.any(AbortSignal), ), ); }); @@ -473,6 +521,14 @@ describe("Dead letter jobs", () => { expect(deadLetterJobSearchSchema.safeParse({ priority }).success).toBe( false, ); + // The route schema drops only the filters it can't parse. + expect( + deadLetterJobRouteSearchSchema.parse({ + id: "bad", + kind: "report", + priority: "5", + }), + ).toEqual({ limit: 20, kind: ["report"] }); }); it("retries through the Pro API and opens the newly created job", async () => { diff --git a/src/routes/dead-letter-jobs/$jobId.tsx b/src/routes/dead-letter-jobs/$jobId.tsx index fea226cd..b91311aa 100644 --- a/src/routes/dead-letter-jobs/$jobId.tsx +++ b/src/routes/dead-letter-jobs/$jobId.tsx @@ -1,6 +1,6 @@ import { Button } from "@components/Button"; import DeadLetterJobDetail from "@components/DeadLetterJobDetail"; -import TopNavTitleOnly from "@components/TopNavTitleOnly"; +import NotFoundPage from "@components/NotFoundPage"; import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; import { @@ -9,7 +9,7 @@ import { listDeadLetterJobsKey, retryDeadLetterJob, } from "@services/deadLetterJobs"; -import { getJobKey } from "@services/jobs"; +import { getJobKey, listJobsKeyPrefix } from "@services/jobs"; import { countsByStateKey } from "@services/states"; import { toastError, toastSuccess } from "@services/toast"; import { @@ -34,15 +34,16 @@ export const Route = createFileRoute("/dead-letter-jobs/$jobId")({ queryClient.ensureQueryData(jobQueryOptions), errorComponent: ({ error }) => error instanceof NotFoundError ? ( - <> - -

    -

    {error.message}

    - -
    - + } + description={error.message} + heading="Dead letter job not found" + title="Dead Letter Job" + /> ) : ( ), @@ -68,7 +69,7 @@ export function DeadLetterJobComponent() { toastSuccess({ message: "Job enqueued for retry", duration: 2000 }); await Promise.all([ queryClient.invalidateQueries({ queryKey: listDeadLetterJobsKey() }), - queryClient.invalidateQueries({ queryKey: ["listJobs"] }), + queryClient.invalidateQueries({ queryKey: listJobsKeyPrefix }), queryClient.invalidateQueries({ queryKey: countsByStateKey() }), ]); await navigate({ to: "/jobs/$jobId", params: { jobId: job.id } }); @@ -83,7 +84,11 @@ export function DeadLetterJobComponent() { throwOnError: true, }); - if (!query.data) return

    Loading…

    ; + if (!query.data) { + return ( +

    Loading…

    + ); + } return ( ; +// The route drops a filter it can't parse, as the Jobs list does, so that a +// hand-edited or stale URL still opens the list. Edits in the search field +// use the strict schema above and show the problem instead. +export const deadLetterJobRouteSearchSchema = deadLetterJobSearchSchema.extend({ + id: ids.catch(undefined), + kind: strings.catch(undefined), + priority: priorities.catch(undefined), + queue: strings.catch(undefined), + tags: strings.catch(undefined), +}); + export const searchToDeadLetterFilters = ( search: DeadLetterJobSearch, ): DeadLetterJobFilters => ({ diff --git a/src/routes/dead-letter-jobs/index.tsx b/src/routes/dead-letter-jobs/index.tsx index e392d753..0212ecf5 100644 --- a/src/routes/dead-letter-jobs/index.tsx +++ b/src/routes/dead-letter-jobs/index.tsx @@ -13,6 +13,7 @@ import { createFileRoute, stripSearchParams } from "@tanstack/react-router"; import { useCallback, useMemo, useState } from "react"; import { + deadLetterJobRouteSearchSchema, DeadLetterJobSearch, deadLetterJobSearchSchema, inputFiltersToSearch, @@ -30,7 +31,7 @@ const jobsQueryOptions = (search: DeadLetterJobSearch) => }); export const Route = createFileRoute("/dead-letter-jobs/")({ - validateSearch: deadLetterJobSearchSchema, + validateSearch: deadLetterJobRouteSearchSchema, search: { middlewares: [stripSearchParams({ limit: defaultDeadLetterJobsLimit })], }, @@ -50,7 +51,6 @@ function DeadLetterJobsIndexComponent() { const query = useQuery({ ...jobsQueryOptions(search), ...refreshQueryOptions(intervalMs), - throwOnError: true, }); const onFiltersChange = useCallback( (filters: Filter[]) => { @@ -78,6 +78,11 @@ function DeadLetterJobsIndexComponent() { filterError={filterError} initialFilters={initialFilters} jobs={query.data ?? []} + loadError={ + query.isError + ? `Couldn't refresh dead letter jobs: ${query.error.message}` + : undefined + } loading={query.isLoading} onFiltersChange={onFiltersChange} showFewer={() => diff --git a/src/routes/jobs/index.schema.ts b/src/routes/jobs/index.schema.ts index ab1b4064..f4978cf7 100644 --- a/src/routes/jobs/index.schema.ts +++ b/src/routes/jobs/index.schema.ts @@ -5,6 +5,17 @@ export const minimumLimit = 20; export const defaultLimit = 20; export const maximumLimit = 200; +// Search params kept when moving between job states. Other panes clear +// them when they link to Jobs. +export const retainedJobSearchKeys = [ + "id", + "kind", + "limit", + "priority", + "queue", + "tags", +] as const; + // Define default values export const defaultValues = { limit: defaultLimit, diff --git a/src/routes/jobs/index.tsx b/src/routes/jobs/index.tsx index 69c4fe0f..268afbce 100644 --- a/src/routes/jobs/index.tsx +++ b/src/routes/jobs/index.tsx @@ -6,7 +6,11 @@ import { refreshQueryOptions, } from "@contexts/RefreshSettings.query"; import { useRetryJobs } from "@hooks/use-retry-jobs"; -import { defaultValues, jobSearchSchema } from "@routes/jobs/index.schema"; +import { + defaultValues, + jobSearchSchema, + retainedJobSearchKeys, +} from "@routes/jobs/index.schema"; import { cancelJobs, deleteJobs, @@ -50,7 +54,7 @@ export const Route = createFileRoute("/jobs/")({ search: { middlewares: [ stripSearchParams(defaultValues), - retainSearchParams(["id", "kind", "limit", "priority", "queue", "tags"]), + retainSearchParams([...retainedJobSearchKeys]), ], }, beforeLoad: async ({ context }) => { diff --git a/src/services/deadLetterJobs.ts b/src/services/deadLetterJobs.ts index ea1bcfa8..7a8d5b2e 100644 --- a/src/services/deadLetterJobs.ts +++ b/src/services/deadLetterJobs.ts @@ -95,12 +95,13 @@ export async function fetchDeadLetterSuggestions( facet: "job_kind" | "queue_name", match: string, exclude: string[], + signal?: AbortSignal, ): Promise { const query = new URLSearchParams({ facet, match }); exclude.forEach((value) => query.append("exclude", value)); - const response = await API.get>({ - path: "/pro/dead-letter-jobs/autocomplete", - query, - }); + const response = await API.get>( + { path: "/pro/dead-letter-jobs/autocomplete", query }, + { signal }, + ); return response.data; } diff --git a/src/services/jobs.ts b/src/services/jobs.ts index 487620e1..26d6396b 100644 --- a/src/services/jobs.ts +++ b/src/services/jobs.ts @@ -205,6 +205,9 @@ type ListJobsFilters = { tags?: string[]; }; +// Matches every job list query, for invalidation. +export const listJobsKeyPrefix = ["listJobs"] as const; + export const listJobsKey = (args: ListJobsFilters): ListJobsKey => { return [ "listJobs", From ad4979c62c743f82eb2f9584935f71b9fe907d9b Mon Sep 17 00:00:00 2001 From: Brandur Date: Sat, 24 Oct 2026 18:41:29 -0500 Subject: [PATCH 3/3] Disable automatic refresh on dead letter jobs There's some concern around query performance for dead letter jobs. Just so that we can get this shipped out without too much ado, here we disable automatic refreshes for dead letter jobs. Automatic refresh for normal jobs continues as normal. Users can enable dead letter refresh with the toggle (it's just off by default). --- src/contexts/RefreshSettings.provider.tsx | 24 +++- src/contexts/RefreshSettings.tsx | 12 +- src/routes/dead-letter-jobs.test.tsx | 167 ++++++++++++++++++++-- src/routes/dead-letter-jobs.tsx | 17 ++- 4 files changed, 207 insertions(+), 13 deletions(-) diff --git a/src/contexts/RefreshSettings.provider.tsx b/src/contexts/RefreshSettings.provider.tsx index fe97fcc1..1233305f 100644 --- a/src/contexts/RefreshSettings.provider.tsx +++ b/src/contexts/RefreshSettings.provider.tsx @@ -1,6 +1,10 @@ import { Fragment, useContext, useMemo, useState } from "react"; -import { RefreshSettingContext } from "./RefreshSettings"; +import { + DeadLetterJobsRefreshSettingContext, + defaultDeadLetterJobsRefreshIntervalMs, + RefreshSettingContext, +} from "./RefreshSettings"; export interface RefreshSettingProviderProps { children?: React.ReactNode; @@ -20,6 +24,10 @@ const RefreshSetting: React.FC = ({ children, }) => { const [intervalMs, setIntervalMs] = useState(2000); + // Keep the dead letter choice independent and retain it across navigation. + const [deadLetterJobsIntervalMs, setDeadLetterJobsIntervalMs] = useState( + defaultDeadLetterJobsRefreshIntervalMs, + ); const providerValue = useMemo( () => ({ @@ -29,9 +37,21 @@ const RefreshSetting: React.FC = ({ [intervalMs, setIntervalMs], ); + const deadLetterJobsProviderValue = useMemo( + () => ({ + intervalMs: deadLetterJobsIntervalMs, + setIntervalMs: setDeadLetterJobsIntervalMs, + }), + [deadLetterJobsIntervalMs, setDeadLetterJobsIntervalMs], + ); + return ( - {children} + + {children} + ); }; diff --git a/src/contexts/RefreshSettings.tsx b/src/contexts/RefreshSettings.tsx index 8f096e77..8ba11014 100644 --- a/src/contexts/RefreshSettings.tsx +++ b/src/contexts/RefreshSettings.tsx @@ -1,7 +1,7 @@ import { createContext } from "react"; export interface UseRefreshSettingProps { - /** List of all available theme names */ + /** Refresh interval in milliseconds; 0 disables refresh. */ intervalMs: number; /** Update the interval setting. Set to 0 to disable refresh. */ setIntervalMs: React.Dispatch>; @@ -10,3 +10,13 @@ export interface UseRefreshSettingProps { export const RefreshSettingContext = createContext< undefined | UseRefreshSettingProps >(undefined); + +// Temporary: restore to 2000 once dead letter indexes and query performance +// are verified. Users can still enable live updates with the selector. +export const defaultDeadLetterJobsRefreshIntervalMs = 0; + +export const DeadLetterJobsRefreshSettingContext = + createContext({ + intervalMs: defaultDeadLetterJobsRefreshIntervalMs, + setIntervalMs: () => {}, + }); diff --git a/src/routes/dead-letter-jobs.test.tsx b/src/routes/dead-letter-jobs.test.tsx index 18de4556..73650260 100644 --- a/src/routes/dead-letter-jobs.test.tsx +++ b/src/routes/dead-letter-jobs.test.tsx @@ -1,5 +1,6 @@ import Layout from "@components/Layout"; import { FeaturesContext } from "@contexts/Features"; +import { RefreshSettingProvider } from "@contexts/RefreshSettings.provider"; import { SidebarSettingProvider } from "@contexts/SidebarSetting.provider"; import { deadLetterJobRouteSearchSchema, @@ -12,6 +13,8 @@ import { countsByState, StatesAndCounts } from "@services/states"; import { toastError } from "@services/toast"; import { JobState } from "@services/types"; import { + focusManager, + onlineManager, QueryClient, QueryClientProvider, QueryFunctionContext, @@ -64,12 +67,6 @@ vi.mock("@components/Root", () => ({ ), })); -vi.mock("@components/TopNavTitleOnly", () => ({ - default: ({ title }: { title: string }) =>

    {title}

    , -})); -vi.mock("@contexts/RefreshSettings.hook", () => ({ - useRefreshSetting: () => ({ intervalMs: 0 }), -})); vi.mock("@services/toast", () => ({ toastError: vi.fn(), toastSuccess: vi.fn(), @@ -91,11 +88,11 @@ const job = jobFactory.discarded().build({ queue: "mail", }); -async function renderRoute(path: string, pro = true) { +async function renderRoute(path: string, pro = true, staleTime = Infinity) { const features = createFeatures({ deadLetterJobs: pro }); const queryClient = new QueryClient({ defaultOptions: { - queries: { retry: false, staleTime: Infinity }, + queries: { retry: false, staleTime }, mutations: { retry: false }, }, }); @@ -120,7 +117,9 @@ async function renderRoute(path: string, pro = true) { - + + + , @@ -129,7 +128,17 @@ async function renderRoute(path: string, pro = true) { return { queryClient, router }; } +async function selectRefreshInterval(name: string) { + fireEvent.click(screen.getByRole("button", { name: /live updates/i })); + fireEvent.click(await screen.findByRole("option", { name })); + await waitFor(() => + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(), + ); +} + afterEach(() => { + focusManager.setFocused(undefined); + onlineManager.setOnline(true); vi.useRealTimers(); }); @@ -157,6 +166,146 @@ describe("Dead letter jobs", () => { const filteredSearch = "?id=1970670598291982290&kind=send_email&queue=mail&priority=1,3&tags=batch,urgent&limit=40"; + it.each(["/dead-letter-jobs", `/dead-letter-jobs/${job.id}`])( + "defaults to paused live updates for %s", + async (path) => { + focusManager.setFocused(true); + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { queryClient } = await renderRoute(path, true, 0); + const fetchJob = + path === "/dead-letter-jobs" ? listDeadLetterJobs : getDeadLetterJob; + expect(fetchJob).toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: /Resume live updates/ }), + ).toBeInTheDocument(); + const initialCalls = fetchJob.mock.calls.length; + + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + expect(fetchJob).toHaveBeenCalledTimes(initialCalls); + await act(async () => { + focusManager.setFocused(false); + focusManager.setFocused(true); + onlineManager.setOnline(false); + onlineManager.setOnline(true); + await vi.advanceTimersByTimeAsync(10); + }); + expect(fetchJob).toHaveBeenCalledTimes(initialCalls); + queryClient.clear(); + }, + ); + + it.each(["/dead-letter-jobs", `/dead-letter-jobs/${job.id}`])( + "allows enabling and pausing live updates with the selector for %s", + async (path) => { + focusManager.setFocused(true); + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { queryClient } = await renderRoute(path, true, 0); + const fetchJob = + path === "/dead-letter-jobs" ? listDeadLetterJobs : getDeadLetterJob; + const initialCalls = fetchJob.mock.calls.length; + + await selectRefreshInterval("1s"); + expect( + screen.getByRole("button", { name: /Pause live updates/ }), + ).toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1200); + }); + expect(fetchJob.mock.calls.length).toBeGreaterThan(initialCalls); + + await selectRefreshInterval("Pause"); + const callsAfterPausing = fetchJob.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + focusManager.setFocused(false); + focusManager.setFocused(true); + onlineManager.setOnline(false); + onlineManager.setOnline(true); + await vi.advanceTimersByTimeAsync(10); + }); + expect(fetchJob).toHaveBeenCalledTimes(callsAfterPausing); + queryClient.clear(); + }, + ); + + it("keeps standard Jobs refreshing after leaving Dead Letters", async () => { + focusManager.setFocused(true); + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { queryClient, router } = await renderRoute("/dead-letter-jobs"); + await act(async () => { + await router.navigate({ to: "/jobs" }); + }); + await screen.findByTestId("job-search-input"); + const initialCalls = vi.mocked(listJobs).mock.calls.length; + expect( + screen.getByText("Pause live updates", { selector: "span" }), + ).toBeInTheDocument(); + await act(async () => { + await vi.advanceTimersByTimeAsync(2500); + }); + expect(vi.mocked(listJobs).mock.calls.length).toBeGreaterThan(initialCalls); + expect(listDeadLetterJobs).toHaveBeenCalledTimes(1); + queryClient.clear(); + }); + + it("retains separate refresh choices across panes and dead letter details", async () => { + focusManager.setFocused(true); + vi.useFakeTimers({ shouldAdvanceTime: true }); + const { queryClient, router } = await renderRoute("/jobs"); + await selectRefreshInterval("Pause"); + + await act(async () => { + await router.navigate({ to: "/dead-letter-jobs" }); + }); + expect( + screen.getByRole("button", { name: /Resume live updates/ }), + ).toBeInTheDocument(); + await selectRefreshInterval("1s"); + + await act(async () => { + await router.navigate({ + to: "/dead-letter-jobs/$jobId", + params: { jobId: job.id }, + }); + }); + const initialDetailCalls = getDeadLetterJob.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(1200); + }); + expect(getDeadLetterJob.mock.calls.length).toBeGreaterThan( + initialDetailCalls, + ); + + await act(async () => { + await router.navigate({ to: "/jobs" }); + }); + expect( + screen.getByRole("button", { name: /Resume live updates/ }), + ).toBeInTheDocument(); + const initialJobsCalls = vi.mocked(listJobs).mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(6000); + }); + expect(listJobs).toHaveBeenCalledTimes(initialJobsCalls); + + await act(async () => { + await router.navigate({ to: "/dead-letter-jobs" }); + }); + expect( + screen.getByRole("button", { name: /Pause live updates/ }), + ).toBeInTheDocument(); + const initialListCalls = listDeadLetterJobs.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(1200); + }); + expect(listDeadLetterJobs.mock.calls.length).toBeGreaterThan( + initialListCalls, + ); + queryClient.clear(); + }); + it("clears filters and pagination when navigating from Dead Letters to Jobs", async () => { const { router, queryClient } = await renderRoute( "/dead-letter-jobs" + filteredSearch, diff --git a/src/routes/dead-letter-jobs.tsx b/src/routes/dead-letter-jobs.tsx index bad9bc1b..08e25173 100644 --- a/src/routes/dead-letter-jobs.tsx +++ b/src/routes/dead-letter-jobs.tsx @@ -1,8 +1,23 @@ +import { + DeadLetterJobsRefreshSettingContext, + RefreshSettingContext, +} from "@contexts/RefreshSettings"; import { createFileRoute, notFound, Outlet } from "@tanstack/react-router"; +import { useContext } from "react"; export const Route = createFileRoute("/dead-letter-jobs")({ beforeLoad: ({ context: { features } }) => { if (!features.deadLetterJobs) throw notFound(); }, - component: Outlet, + component: DeadLetterJobsLayout, }); + +function DeadLetterJobsLayout() { + const refreshSetting = useContext(DeadLetterJobsRefreshSettingContext); + + return ( + + + + ); +}