diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a6a5fc8..ccf6a658 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- 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 - 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 +- 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). - 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..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,6 +32,114 @@ 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 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 { + 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 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"` + FinalizedAt *time.Time `json:"finalized_at"` + Kind string `json:"kind"` + MaxAttempts int `json:"max_attempts"` + 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 { diff --git a/internal/riveruidriver/riveruidrivertest/job_dead_letter.go b/internal/riveruidriver/riveruidrivertest/job_dead_letter.go new file mode 100644 index 00000000..2051906b --- /dev/null +++ b/internal/riveruidriver/riveruidrivertest/job_dead_letter.go @@ -0,0 +1,262 @@ +package riveruidrivertest + +import ( + "context" + "fmt" + "slices" + "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" +) + +// 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, + 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 = riveruidriver.JobDeadLetterFacetJobKind + ) + t.Run("JobDeadLetterList", func(t *testing.T) { + t.Parallel() + executor, schema := executorWithSchema(ctx, t, driver, executorWithTx) + 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 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 := range 205 { + finalizedAt := "'2026-10-21 10:02:03.456+00:00'" + 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, 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+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) + 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))) + require.Equal(t, time.UTC, job.FinalizedAt.Location(), "timestamps must be in UTC on every driver") + } + } + } + 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}}, Max: 1, Schema: schema, + }) + require.NoError(t, err) + require.Len(t, jobs, 1) + 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{ + 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{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{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{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}) + 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 riveruidriver.JobDeadLetterFacet + 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: 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{}}, + } { + 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) + }) + } + }) +} + +// 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 6917337c..e58b7386 100644 --- a/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go +++ b/internal/riveruidriver/riveruidrivertest/riveruidrivertest.go @@ -28,6 +28,8 @@ func Exercise( exerciseGetExecutor(ctx, t, driver, executorWithTx) 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/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..5c43ab08 --- /dev/null +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql @@ -0,0 +1,84 @@ +-- 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 +-- 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, + '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 (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 DESC + LIMIT @max::int +) AS limited; + +-- name: JobDeadLetterAutocomplete :one +-- 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. +SELECT coalesce(json_agg(suggestions.value ORDER BY suggestions.value COLLATE "C"), '[]'::json)::text AS values +FROM ( + 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 new file mode 100644 index 00000000..7451eb97 --- /dev/null +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_job_dead_letter.sql.go @@ -0,0 +1,128 @@ +// 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 COLLATE "C"), '[]'::json)::text AS values +FROM ( + 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 +` + +type JobDeadLetterAutocompleteParams struct { + Facet string + Match string + Exclude []byte + Max int32 +} + +// 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, + 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 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, '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 JobDeadLetterListParams struct { + Filters []byte + Max int32 +} + +// 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 a37c7633..f28f55f1 100644 --- a/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go +++ b/internal/riveruidriver/riveruipostgres/internal/dbsqlc/river_queries.go @@ -86,3 +86,27 @@ 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 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 { + return "", err + } + return values, 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/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..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" @@ -139,3 +140,51 @@ 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 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) + } + // 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) + } + var jobs []*riveruidriver.JobDeadLetterListResult + 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 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, string(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) + } + // 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/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..e837a095 --- /dev/null +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql @@ -0,0 +1,86 @@ +-- 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 +-- 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, + '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), '$.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 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 + 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..3dadb5a5 --- /dev/null +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_job_dead_letter.sql.go @@ -0,0 +1,130 @@ +// 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. +// +// 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, + 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), '$.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 JobDeadLetterListParams struct { + Filters string + Max int64 +} + +// 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 ef42302d..71023799 100644 --- a/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go +++ b/internal/riveruidriver/riveruisqlite/internal/dbsqlc/river_queries.go @@ -36,3 +36,27 @@ 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 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 { + return "", err + } + return values, 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/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..8e4a6096 100644 --- a/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go +++ b/internal/riveruidriver/riveruisqlite/river_ui_sqlite_driver.go @@ -4,9 +4,11 @@ package riveruisqlite import ( "context" "encoding/hex" + "encoding/json" "errors" "fmt" "math" + "slices" "strconv" "strings" @@ -240,3 +242,51 @@ 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 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) + } + // 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) + } + var jobs []*riveruidriver.JobDeadLetterListResult + 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 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, string(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) + } + // 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 2ed66a2d..00cf7f4a 100644 --- a/riverproui/endpoints.go +++ b/riverproui/endpoints.go @@ -90,6 +90,11 @@ func (e *endpoints[TTx]) Extensions(ctx context.Context) (map[string]bool, error } defer execTx.Rollback(ctx) + hasDeadLetterTable, err := prohandler.HasDeadLetterTable(ctx, execTx, schema) + if err != nil { + return nil, err + } + hasPeriodicJobTable, err := execTx.TableExists(ctx, &riverdriver.TableExistsParams{ Schema: schema, Table: "river_periodic_job", @@ -120,6 +125,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 +163,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..7de38e7d --- /dev/null +++ b/riverproui/internal/prohandler/dead_letter_endpoints.go @@ -0,0 +1,312 @@ +package prohandler + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + "unicode/utf8" + + "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" +) + +// 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) + } + if !exists { + return apierror.NewBadRequest("Dead letter jobs require River Pro's dead letter queue migration.") + } + return nil +} + +// 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 { + 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"` +} + +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 { + *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} +} + +// 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"` + 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"` +} + +// 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 { + 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, 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, deadLetterJobListItemFromDriver)), 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} +} + +// The validate tags must be literals. Facet's values mirror the +// riveruidriver.JobDeadLetterFacet constants. +type deadLetterAutocompleteRequest struct { + 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 = 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) { + 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: riveruidriver.JobDeadLetterAutocompleteMaxLimit, 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..001cfb80 --- /dev/null +++ b/riverproui/internal/prohandler/dead_letter_endpoints_test.go @@ -0,0 +1,509 @@ +package prohandler + +import ( + "context" + "encoding/json" + "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 { + 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)) + var newest *rivertype.JobRow + for range 24 { + newest = insertDeadLetterJob(ctx, t, bundle) + } + resp, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobListRequest{}) + require.NoError(t, err) + 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.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) + 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 _, 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() + 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}) + requireDeadLetterNotFound(t, err, 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}) + requireDeadLetterNotFound(t, err, job.ID) + _, err = apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterJobRequest{JobID: live.ID}) + requireDeadLetterNotFound(t, err, 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}) + 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") +} + +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 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"} { + 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 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() + 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 []riveruidriver.JobDeadLetterFacet{"", "tags", "id"} { + _, err := apitest.InvokeHandler(ctx, endpoint.Execute, testMountOpts(t), &deadLetterAutocompleteRequest{Facet: facet}) + var badRequest *apierror.BadRequest + require.ErrorAs(t, err, &badRequest) + } +} 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/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 new file mode 100644 index 00000000..a6955e3f --- /dev/null +++ b/src/components/DeadLetterJobDetail.tsx @@ -0,0 +1,33 @@ +import { ArrowUturnLeftIcon } from "@heroicons/react/24/outline"; +import { Job } from "@services/jobs"; + +import ButtonForGroup from "./ButtonForGroup"; +import { JobDetailView } from "./JobDetail"; + +export default function DeadLetterJobDetail({ + job, + retry, + retryPending, +}: { + job: Job; + retry: () => void; + retryPending: boolean; +}) { + return ( + + + + } + 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 new file mode 100644 index 00000000..f8e0059b --- /dev/null +++ b/src/components/DeadLetterJobList.tsx @@ -0,0 +1,175 @@ +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"; + +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, + canShowMore, + filterError, + initialFilters, + jobs, + loadError, + loading, + onFiltersChange, + showFewer, + showMore, +}: DeadLetterJobListProps) { + const alert = filterError ?? loadError; + + return ( + <> + +
    +
    +
    + +
    + {alert && ( +

    + {alert} +

    + )} +
    + {loading ? ( +

    + 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) => ( + + ))} +
    + + + )} +
    + + ); +} + +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, 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 39844594..a5634788 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,15 +16,23 @@ import { RectangleGroupIcon, XMarkIcon, } from "@heroicons/react/24/outline"; -import { Link } from "@tanstack/react-router"; +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(); const { features } = useFeatures(); + const inJobsPane = useRouterState({ + select: ({ matches }) => matches.some((match) => match.routeId === "/jobs"), + }); const navigation = useMemo( () => @@ -32,7 +41,8 @@ const Layout = ({ children }: LayoutProps) => { href: "/jobs", icon: QueueListIcon, name: "Jobs", - search: {}, + // Explicitly clear retained Jobs filters when entering from another pane. + search: inJobsPane ? {} : clearedJobSearch, }, { href: "/queues", icon: InboxStackIcon, name: "Queues" }, { @@ -41,6 +51,13 @@ const Layout = ({ children }: LayoutProps) => { icon: RectangleGroupIcon, name: "Workflows", }, + { + hidden: !features.deadLetterJobs, + href: "/dead-letter-jobs", + icon: ArchiveBoxIcon, + name: "Dead Letters", + search: {}, + }, { hidden: !features.durablePeriodicJobs, href: "/periodic-jobs", @@ -48,7 +65,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/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.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/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 36e83522..64b346e7 100644 --- a/src/components/job-search/JobSearch.test.tsx +++ b/src/components/job-search/JobSearch.test.tsx @@ -121,6 +121,78 @@ 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("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 edb10e44..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 @@ -39,6 +41,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 +50,7 @@ export function JobSearch({ fetchSuggestions = defaultFetchSuggestions, initialFilters = [], onFiltersChange, + syncInitialFilters = false, }: JobSearchProps) { const inputRef = useRef(null); const containerRef = useRef(null); @@ -67,6 +72,7 @@ export function JobSearch({ fetchSuggestions, initialFilters, onFiltersChange, + syncInitialFilters, }); // Handle clicking outside to close suggestions @@ -180,6 +186,7 @@ export function JobSearch({ {inputValue && (

    + } + 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 new file mode 100644 index 00000000..73650260 --- /dev/null +++ b/src/routes/dead-letter-jobs.test.tsx @@ -0,0 +1,766 @@ +import Layout from "@components/Layout"; +import { FeaturesContext } from "@contexts/Features"; +import { RefreshSettingProvider } from "@contexts/RefreshSettings.provider"; +import { SidebarSettingProvider } from "@contexts/SidebarSetting.provider"; +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"; +import { countsByState, StatesAndCounts } from "@services/states"; +import { toastError } from "@services/toast"; +import { JobState } from "@services/types"; +import { + focusManager, + onlineManager, + QueryClient, + QueryClientProvider, + QueryFunctionContext, +} from "@tanstack/react-query"; +import { + createMemoryHistory, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { jobFactory } from "@test/factories/job"; +import { createFeatures } from "@test/utils/features"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { APIResponseError, NotFoundError } from "@utils/api"; +import qs from "query-string"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import "../global-type-overrides"; +import { routeTree } from "../routeTree.gen"; + +const { + fetchDeadLetterSuggestions, + getDeadLetterJob, + listDeadLetterJobs, + retryDeadLetterJob, +} = vi.hoisted(() => ({ + fetchDeadLetterSuggestions: vi.fn(), + getDeadLetterJob: vi.fn(), + listDeadLetterJobs: vi.fn(), + retryDeadLetterJob: vi.fn(), +})); + +vi.mock("@services/deadLetterJobs", async (importOriginal) => ({ + ...(await importOriginal()), + fetchDeadLetterSuggestions, + getDeadLetterJob, + listDeadLetterJobs, + retryDeadLetterJob, +})); +vi.mock("@components/Root", () => ({ + Root: () => ( + + + + ), +})); +vi.mock("@services/toast", () => ({ + toastError: vi.fn(), + toastSuccess: vi.fn(), +})); +vi.mock("@services/jobs", async (importOriginal) => ({ + ...(await importOriginal()), + getJob: vi.fn(), + listJobs: vi.fn(), +})); +vi.mock("@services/states", async (importOriginal) => ({ + ...(await importOriginal()), + countsByState: vi.fn(), +})); + +const job = jobFactory.discarded().build({ + argsRaw: '{"id":1970670598291982290}', + id: 1970670598291982290n, + kind: "send_email", + queue: "mail", +}); + +async function renderRoute(path: string, pro = true, staleTime = Infinity) { + const features = createFeatures({ deadLetterJobs: pro }); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime }, + mutations: { retry: false }, + }, + }); + queryClient.setQueryData(featuresKey(), features); + const router = createRouter({ + context: { features, queryClient }, + defaultPendingMinMs: 0, + history: createMemoryHistory({ initialEntries: [path] }), + parseSearch: (search) => qs.parse(search, { arrayFormat: "comma" }), + stringifySearch: (search) => { + const result = qs.stringify(search, { + arrayFormat: "comma", + skipEmptyString: true, + }); + return result ? `?${result}` : ""; + }, + routeTree, + }); + await act(async () => { + await router.load(); + render( + + + + + + + + + , + ); + }); + 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(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + fetchDeadLetterSuggestions.mockResolvedValue([]); + listDeadLetterJobs.mockResolvedValue([job]); + getDeadLetterJob.mockResolvedValue(job); + vi.mocked(listJobs).mockResolvedValue([]); + const zero = { accuracy: "exact", count: 0n } as const; + const counts: StatesAndCounts = { + available: zero, + cancelled: zero, + completed: zero, + discarded: zero, + pending: zero, + retryable: zero, + running: zero, + scheduled: zero, + }; + vi.mocked(countsByState).mockResolvedValue(counts); +}); + +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, + ); + const jobsLink = screen.getByRole("link", { name: "Jobs" }); + expect(jobsLink).toHaveAttribute("href", "/jobs"); + await act(async () => { + fireEvent.click(jobsLink); + }); + await waitFor(() => expect(router.state.location.pathname).toBe("/jobs")); + expect(router.state.location.search).toEqual({}); + expect(await screen.findByTestId("job-search-input")).toHaveValue(""); + expect( + queryClient.getQueryData( + listJobsKey({ limit: 20, state: JobState.Running }), + ), + ).toEqual([]); + expect(listJobs).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: listJobsKey({ limit: 20, state: JobState.Running }), + }), + ); + }); + + it("clears filters and pagination when navigating from Jobs to Dead Letters", async () => { + const { router } = await renderRoute( + "/jobs" + filteredSearch + "&state=discarded", + ); + const deadLettersLink = screen.getByRole("link", { name: "Dead Letters" }); + expect(deadLettersLink).toHaveAttribute("href", "/dead-letter-jobs"); + await act(async () => { + fireEvent.click(deadLettersLink); + }); + await waitFor(() => + expect(router.state.location.pathname).toBe("/dead-letter-jobs"), + ); + expect(router.state.location.search).toEqual({}); + expect(await screen.findByTestId("job-search-input")).toHaveValue(""); + expect(listDeadLetterJobs).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: listDeadLetterJobsKey(20) }), + ); + }); + + it("preserves filters and pagination between job states and within the Jobs pane", async () => { + const { router } = await renderRoute("/jobs" + filteredSearch); + const filters = router.state.location.search; + const input = await screen.findByTestId("job-search-input"); + const initialText = input.getAttribute("value"); + await act(async () => { + fireEvent.click(screen.getByRole("link", { name: "Discarded" })); + }); + await waitFor(() => + expect(router.state.location.search).toEqual({ + ...filters, + state: JobState.Discarded, + }), + ); + expect(await screen.findByTestId("job-search-input")).toHaveValue( + initialText, + ); + await act(async () => { + fireEvent.click(screen.getByRole("link", { name: "Jobs" })); + }); + await waitFor(() => expect(router.state.location.search).toEqual(filters)); + expect(await screen.findByTestId("job-search-input")).toHaveValue( + initialText, + ); + }); + + it.each(["/dead-letter-jobs", `/dead-letter-jobs/${job.id}`])( + "hides navigation and blocks OSS access to %s", + async (path) => { + await renderRoute(path, false); + expect( + screen.queryByRole("link", { name: "Dead Letters" }), + ).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( + screen.getByRole("link", { name: "Dead Letters" }), + ).toBeInTheDocument(); + await act(async () => { + fireEvent.click( + screen.getByRole("link", { name: `send_email #${job.id}` }), + ); + }); + await waitFor(() => + expect(router.state.location.pathname).toBe( + `/dead-letter-jobs/${job.id}`, + ), + ); + expect( + await screen.findByRole("button", { name: "Retry job" }), + ).toBeInTheDocument(); + expect(screen.getByText("Args").parentElement).toHaveTextContent( + "1970670598291982290", + ); + expect( + screen.queryByRole("button", { name: "Delete" }), + ).not.toBeInTheDocument(); + }); + + it("shows an empty queue", async () => { + listDeadLetterJobs.mockResolvedValue([]); + await renderRoute("/dead-letter-jobs"); + expect(screen.getByText("No dead letter jobs.")).toBeInTheDocument(); + }); + + it("requests larger and smaller bounded lists and keeps the limit in the URL", async () => { + const jobs = Array.from({ length: 65 }, (_, i) => ({ + ...job, + id: BigInt(i + 1), + })); + listDeadLetterJobs.mockImplementation( + ({ + queryKey, + }: QueryFunctionContext>) => + jobs.slice(0, queryKey[1]?.limit ?? 20), + ); + const { queryClient, router } = await renderRoute("/dead-letter-jobs"); + expect(screen.getAllByRole("link", { name: /^send_email #/ })).toHaveLength( + 20, + ); + expect(screen.getByRole("button", { name: "Fewer" })).toBeDisabled(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "More" })); + }); + await screen.findByRole("link", { name: "send_email #40" }); + expect(screen.getAllByRole("link", { name: /^send_email #/ })).toHaveLength( + 40, + ); + expect(router.state.location.search).toEqual({ limit: "40" }); + expect(queryClient.getQueryData(listDeadLetterJobsKey(20))).toHaveLength( + 20, + ); + expect(queryClient.getQueryData(listDeadLetterJobsKey(40))).toHaveLength( + 40, + ); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Fewer" })); + }); + await waitFor(() => + expect( + screen.getAllByRole("link", { name: /^send_email #/ }), + ).toHaveLength(20), + ); + expect(router.state.location.search).toEqual({}); + }); + + it("honors a bookmarked limit and caps the controls at 200", async () => { + const { router } = await renderRoute("/dead-letter-jobs?limit=200"); + expect(listDeadLetterJobs).toHaveBeenCalledWith( + expect.objectContaining({ queryKey: listDeadLetterJobsKey(200) }), + ); + expect(screen.getByRole("button", { name: "More" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Fewer" })).toBeEnabled(); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Fewer" })); + }); + await waitFor(() => + expect(router.state.location.search).toEqual({ limit: "180" }), + ); + }); + + it("disables More when the query returns fewer jobs than the limit", async () => { + await renderRoute("/dead-letter-jobs"); + expect(screen.getByRole("button", { name: "More" })).toBeDisabled(); + }); + + it("normalizes invalid limits and accepts numeric strings", () => { + for (const limit of [undefined, "bad", -1, 0, 19, 201, 20.5]) { + expect(deadLetterJobSearchSchema.parse({ limit })).toEqual({ limit: 20 }); + } + expect(deadLetterJobSearchSchema.parse({ limit: "40" })).toEqual({ + limit: 40, + }); + }); + + it("restores bookmarked filters and preserves them while paginating", async () => { + listDeadLetterJobs.mockResolvedValue( + Array.from({ length: 40 }, (_, i) => ({ ...job, id: BigInt(i + 1) })), + ); + const { router, queryClient } = await renderRoute( + "/dead-letter-jobs?kind=send_email,report&queue=mail&priority=1,3&tags=urgent&id=1970670598291982290", + ); + const filters = { + ids: ["1970670598291982290"], + kinds: ["report", "send_email"], + priorities: ["1", "3"], + queues: ["mail"], + tags: ["urgent"], + }; + expect( + queryClient.getQueryData(listDeadLetterJobsKey(20, filters)), + ).toHaveLength(40); + expect(screen.getByTestId("job-search-input")).toHaveValue( + "id:1970670598291982290 kind:report,send_email priority:1,3 queue:mail tags:urgent", + ); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "More" })); + }); + await waitFor(() => + expect( + queryClient.getQueryData(listDeadLetterJobsKey(40, filters)), + ).toHaveLength(40), + ); + expect(router.state.location.search).toMatchObject({ + limit: "40", + kind: ["report", "send_email"], + queue: "mail", + }); + }); + + it("applies edited filters, distinguishes empty results, and clears the search", async () => { + const { router, queryClient } = await renderRoute("/dead-letter-jobs"); + listDeadLetterJobs.mockResolvedValue([]); + fireEvent.change(screen.getByTestId("job-search-input"), { + target: { value: "kind:report queue:mail tags:urgent,batch priority:2" }, + }); + await screen.findByText("No dead letter jobs match these filters."); + expect(router.state.location.search).toMatchObject({ + kind: "report", + queue: "mail", + tags: ["batch", "urgent"], + priority: "2", + }); + expect( + queryClient.getQueryData( + listDeadLetterJobsKey(20, { + kinds: ["report"], + queues: ["mail"], + tags: ["batch", "urgent"], + priorities: ["2"], + }), + ), + ).toEqual([]); + fireEvent.click(screen.getByRole("button", { name: "Clear filters" })); + await screen.findByRole("link", { name: `send_email #${job.id}` }); + expect(router.state.location.search).toEqual({}); + }); + + 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({ + to: "/dead-letter-jobs", + search: { kind: ["report"] }, + }); + }); + await waitFor(() => + expect(screen.getByTestId("job-search-input")).toHaveValue("kind:report"), + ); + await act(async () => { + router.history.back(); + }); + 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 vi.advanceTimersByTimeAsync(300); + }); + expect(router.state.location.search).toEqual({ queue: "mail" }); + }); + + it("shows invalid numeric filters without issuing a misleading unfiltered query", async () => { + const { router } = await renderRoute("/dead-letter-jobs?queue=mail"); + listDeadLetterJobs.mockClear(); + fireEvent.change(screen.getByTestId("job-search-input"), { + target: { value: "id:bad" }, + }); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Job IDs must be positive integers.", + ); + expect(router.state.location.search).toEqual({ queue: "mail" }); + expect(listDeadLetterJobs).not.toHaveBeenCalled(); + }); + + it("uses dead letter autocomplete for kinds and queues", async () => { + await renderRoute("/dead-letter-jobs"); + fetchDeadLetterSuggestions.mockResolvedValue(["archive_report"]); + const input = screen.getByTestId("job-search-input"); + fireEvent.change(input, { target: { value: "kind:archive_" } }); + await waitFor(() => + expect(fetchDeadLetterSuggestions).toHaveBeenCalledWith( + "job_kind", + "archive_", + [], + expect.any(AbortSignal), + ), + ); + fireEvent.change(input, { target: { value: "queue:mail,re" } }); + await waitFor(() => + expect(fetchDeadLetterSuggestions).toHaveBeenCalledWith( + "queue_name", + "re", + ["mail"], + expect.any(AbortSignal), + ), + ); + }); + + it("validates and normalizes filter values without losing ID precision", () => { + expect( + deadLetterJobSearchSchema.parse({ + id: "9223372036854775807", + kind: ["report", "email", "report"], + tags: [], + }), + ).toEqual({ + limit: 20, + id: ["9223372036854775807"], + kind: ["email", "report"], + tags: undefined, + }); + for (const id of ["bad", "1.5", "0", "-1", "9223372036854775808"]) + expect(deadLetterJobSearchSchema.safeParse({ id }).success).toBe(false); + for (const priority of ["bad", "0", "5", "1.5"]) + 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 () => { + let finishRetry: ((value: Job) => void) | undefined; + retryDeadLetterJob.mockImplementation( + () => + new Promise((resolve) => { + finishRetry = resolve; + }), + ); + const { queryClient, router } = await renderRoute( + `/dead-letter-jobs/${job.id}`, + ); + queryClient.setQueryData(listDeadLetterJobsKey(20), [job]); + queryClient.setQueryData(listDeadLetterJobsKey(40), [job]); + const filteredKey = listDeadLetterJobsKey(20, { queues: ["mail"] }); + queryClient.setQueryData(filteredKey, [job]); + queryClient.setQueryData(["listJobs"], [job]); + queryClient.setQueryData(["countsByState"], {}); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Retry job" })); + }); + expect( + await screen.findByRole("button", { name: "Retrying…" }), + ).toBeDisabled(); + expect(retryDeadLetterJob).toHaveBeenCalledWith(job.id, expect.anything()); + const newJob = jobFactory + .available() + .build({ id: job.id + 1n, kind: job.kind }); + await act(async () => { + finishRetry?.(newJob); + }); + await waitFor(() => + expect(router.state.location.pathname).toBe(`/jobs/${newJob.id}`), + ); + expect( + queryClient.getQueryData(["getDeadLetterJob", job.id.toString()]), + ).toBeUndefined(); + expect( + queryClient.getQueryState(listDeadLetterJobsKey(20))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(listDeadLetterJobsKey(40))?.isInvalidated, + ).toBe(true); + expect(queryClient.getQueryState(["listJobs"])?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(filteredKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(["countsByState"])?.isInvalidated).toBe( + true, + ); + }); + + it("keeps the job open and displays retry errors", async () => { + retryDeadLetterJob.mockRejectedValue( + new APIResponseError( + "Another active job has the same unique properties.", + 409, + "Conflict", + ), + ); + const { router } = await renderRoute(`/dead-letter-jobs/${job.id}`); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Retry job" })); + }); + await waitFor(() => + expect(toastError).toHaveBeenCalledWith({ + message: "Job retry failed", + subtext: "Another active job has the same unique properties.", + }), + ); + expect(router.state.location.pathname).toBe(`/dead-letter-jobs/${job.id}`); + expect(screen.getByRole("button", { name: "Retry job" })).toBeEnabled(); + }); + + it("handles a job already retried elsewhere", async () => { + getDeadLetterJob.mockRejectedValue( + new NotFoundError("This dead letter job has already been retried."), + ); + await renderRoute(`/dead-letter-jobs/${job.id}`); + expect( + screen.getByText("This dead letter job has already been retried."), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Return to dead letters" }), + ).toHaveAttribute("href", "/dead-letter-jobs"); + }); +}); diff --git a/src/routes/dead-letter-jobs.tsx b/src/routes/dead-letter-jobs.tsx new file mode 100644 index 00000000..08e25173 --- /dev/null +++ b/src/routes/dead-letter-jobs.tsx @@ -0,0 +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: DeadLetterJobsLayout, +}); + +function DeadLetterJobsLayout() { + const refreshSetting = useContext(DeadLetterJobsRefreshSettingContext); + + return ( + + + + ); +} diff --git a/src/routes/dead-letter-jobs/$jobId.tsx b/src/routes/dead-letter-jobs/$jobId.tsx new file mode 100644 index 00000000..b91311aa --- /dev/null +++ b/src/routes/dead-letter-jobs/$jobId.tsx @@ -0,0 +1,99 @@ +import { Button } from "@components/Button"; +import DeadLetterJobDetail from "@components/DeadLetterJobDetail"; +import NotFoundPage from "@components/NotFoundPage"; +import { useRefreshSetting } from "@contexts/RefreshSettings.hook"; +import { refreshQueryOptions } from "@contexts/RefreshSettings.query"; +import { + getDeadLetterJob, + getDeadLetterJobKey, + listDeadLetterJobsKey, + retryDeadLetterJob, +} from "@services/deadLetterJobs"; +import { getJobKey, listJobsKeyPrefix } from "@services/jobs"; +import { countsByStateKey } from "@services/states"; +import { toastError, toastSuccess } from "@services/toast"; +import { + queryOptions, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { createFileRoute, ErrorComponent } from "@tanstack/react-router"; +import { NotFoundError } from "@utils/api"; + +export const Route = createFileRoute("/dead-letter-jobs/$jobId")({ + parseParams: ({ jobId }) => ({ jobId: BigInt(jobId) }), + stringifyParams: ({ jobId }) => ({ jobId: jobId.toString() }), + beforeLoad: ({ params: { jobId } }) => ({ + jobQueryOptions: queryOptions({ + queryFn: getDeadLetterJob, + queryKey: getDeadLetterJobKey(jobId), + }), + }), + loader: ({ context: { queryClient, jobQueryOptions } }) => + queryClient.ensureQueryData(jobQueryOptions), + errorComponent: ({ error }) => + error instanceof NotFoundError ? ( + + Return to dead letters + + } + description={error.message} + heading="Dead letter job not found" + title="Dead Letter Job" + /> + ) : ( + + ), + 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: listJobsKeyPrefix }), + 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..16967472 --- /dev/null +++ b/src/routes/dead-letter-jobs/index.schema.ts @@ -0,0 +1,105 @@ +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; + +// 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 => ({ + 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..0212ecf5 --- /dev/null +++ b/src/routes/dead-letter-jobs/index.tsx @@ -0,0 +1,108 @@ +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 { + deadLetterJobRouteSearchSchema, + 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: deadLetterJobRouteSearchSchema, + 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), + }); + 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 ?? []} + loadError={ + query.isError + ? `Couldn't refresh dead letter jobs: ${query.error.message}` + : undefined + } + 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/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.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..7a8d5b2e --- /dev/null +++ b/src/services/deadLetterJobs.ts @@ -0,0 +1,107 @@ +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[], + 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 }, + { signal }, + ); + 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/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", 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,