Repository: riverqueue/river
Pull request #880: Add `JobArgsWithKindAliases`, providing an easy/safe way of renaming kinds
Author: login: brandur
Created: 2025-05-04T21:53:50Z | Merged: 2025-05-10T07:48:28Z

Here, add `JobArgsWithKindAliases`, a job args interface that lets job
args implement a second `KindAliases` that returns alternate kinds
that its worker will respond to. This provides an easy and safe way of
renaming jobs even when existing jobs under the original name are still
in the database.

Renaming a job is a three part process. To begin, a job args with its
original name:

    type jobArgsBeingRenamed struct{}

    func (a jobArgsBeingRenamed) Kind() string { return "old_name" }

Rename by putting the new name in `Kind` and moving the old name to
`KindAliases`:

    type jobArgsBeingRenamed struct{}

    func (a jobArgsBeingRenamed) Kind() string          { return "new_name" }
    func (a jobArgsBeingRenamed) KindAliases() []string { return "old_name_still_recognized" }

After all jobs inserted under the original name have finished working
(including all their possible retries, which notably might take up to
three weeks on the default retry policy), remove `KindAliases`:

    type jobArgsBeingRenamed struct{}

    func (a jobArgsBeingRenamed) Kind() string { return "new_name" }

This addition is largely driven by #879, which may make some users kinds
invalid if they contain banned characters like spaces or commas. Without
this change, users wouldn't have a particularly easy way of renaming
kinds that aren't compliant, and would have to resort to something like
fully duplicating a job args and worker and doing a find/replace.



Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f6127c1a..36967201 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Added
+
+- A new `JobArgsWithKindAliases` interface lets job args implement `KindAliases` to register a second kind that their worker will respond to. This provides a way to safely rename job kinds even with jobs using the original kind already in the database. [PR #XXX](https://github.com/riverqueue/river/pull/XXX).
+
 ### Changed
 
-- Job kinds must comply to a format of `\A[\w][\w\-\[\]<>\/.·:+]+\z`, mainly in an attempt to eliminate commas and spaces to make format more predictable for an upcoming search UI. This check can be disabled for now using `Config.SkipJobKindValidation`, but this option will likely be removed in a future version of River. [PR #879](https://github.com/riverqueue/river/pull/879).
+- Job kinds must comply to a format of `\A[\w][\w\-\[\]<>\/.·:+]+\z`, mainly in an attempt to eliminate commas and spaces to make format more predictable for an upcoming search UI. This check can be disabled for now using `Config.SkipJobKindValidation`, but this option will likely be removed in a future version of River. The new `JobArgsWithKindAliases` interface (see above) can be used to rename non-compliant kinds. [PR #879](https://github.com/riverqueue/river/pull/879).
 
 ## [0.21.0] - 2025-05-02
 
diff --git a/client_test.go b/client_test.go
index 2d5da8ba..f356963b 100644
--- a/client_test.go
+++ b/client_test.go
@@ -1221,6 +1221,30 @@ func Test_Client(t *testing.T) {
 		require.Equal(t, rivertype.JobStateCompleted, event.Job.State)
 	})
 
+	t.Run("KindAliases", func(t *testing.T) {
+		t.Parallel()
+
+		_, bundle := setup(t)
+
+		AddWorker(bundle.config.Workers, WorkFunc(func(ctx context.Context, job *Job[withKindAliasesArgs]) error {
+			return nil
+		}))
+
+		client, err := NewClient(riverpgxv5.New(bundle.dbPool), bundle.config)
+		require.NoError(t, err)
+
+		subscribeChan := subscribe(t, client)
+		startClient(ctx, t, client)
+
+		// withKindAliasesCollisionArgs returns withKindAliasesArgs's
+		// KindAliases as its primary Kind
+		_, err = client.Insert(ctx, withKindAliasesCollisionArgs{}, nil)
+		require.NoError(t, err)
+
+		event := riversharedtest.WaitOrTimeout(t, subscribeChan)
+		require.Equal(t, (withKindAliasesArgs{}).KindAliases(), []string{event.Job.Kind})
+	})
+
 	t.Run("StartStopStress", func(t *testing.T) {
 		t.Parallel()
 
diff --git a/job.go b/job.go
index fb2aa2ef..749c7b6c 100644
--- a/job.go
+++ b/job.go
@@ -24,6 +24,39 @@ type JobArgs interface {
 	Kind() string
 }
 
+// JobArgsWithKindAliases  is an interface that jobs args can implement to
+// provide an alternate kind which a worker will be registered under in addition
+// to the primary kind. This is useful for renaming a job kind in a safe manner
+// so that any jobs already in the database aren't orphaned.
+//
+// Renaming a job is a three part process. To begin, a job args with its
+// original name:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string { return "old_name" }
+//
+// Rename by putting the new name in Kind and moving the old name to
+// KindAliases:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string          { return "new_name" }
+//	func (a jobArgsBeingRenamed) KindAliases() []string { return []string{"old_name_still_recognized"} }
+//
+// After all jobs inserted under the original name have finished working
+// (including all their possible retries, which notably might take up to three
+// weeks on the default retry policy), remove KindAliases:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string { return "new_name" }
+type JobArgsWithKindAliases interface {
+	// KindAliases returns alias kinds that an associated job args worker will
+	// respond to.
+	KindAliases() []string
+}
+
 // JobArgsWithHooks is an interface that job args can implement to attach
 // specific hooks (i.e. other than those globally installed to a client) to
 // certain kinds of jobs.
diff --git a/worker.go b/worker.go
index 576a6dcb..b5cac4c2 100644
--- a/worker.go
+++ b/worker.go
@@ -147,17 +147,34 @@ func NewWorkers() *Workers {
 }
 
 func (w Workers) add(jobArgs JobArgs, workUnitFactory workunit.WorkUnitFactory) error {
-	kind := jobArgs.Kind()
-
-	if _, ok := w.workersMap[kind]; ok {
-		return fmt.Errorf("worker for kind %q is already registered", kind)
+	checkRegistered := func(kind string) error {
+		if _, ok := w.workersMap[kind]; ok {
+			return fmt.Errorf("worker for kind %q is already registered", kind)
+		}
+		return nil
 	}
 
-	w.workersMap[kind] = workerInfo{
+	workerInfo := workerInfo{
 		jobArgs:         jobArgs,
 		workUnitFactory: workUnitFactory,
 	}
 
+	kind := jobArgs.Kind()
+	if err := checkRegistered(kind); err != nil {
+		return err
+	}
+	w.workersMap[kind] = workerInfo
+
+	// Jobs can register an alternate kind to make renaming easier.
+	if jobArgsWithKindAliases, ok := jobArgs.(JobArgsWithKindAliases); ok {
+		for _, kind := range jobArgsWithKindAliases.KindAliases() {
+			if err := checkRegistered(kind); err != nil {
+				return err
+			}
+			w.workersMap[kind] = workerInfo
+		}
+	}
+
 	return nil
 }
 
diff --git a/worker_test.go b/worker_test.go
index 715f25af..165f3107 100644
--- a/worker_test.go
+++ b/worker_test.go
@@ -32,6 +32,18 @@ func TestWork(t *testing.T) {
 		return nil
 	}))
 	require.Contains(t, workers.workersMap, (JobArgs{}).Kind())
+
+	AddWorker(workers, WorkFunc(func(ctx context.Context, job *Job[withKindAliasesArgs]) error {
+		return nil
+	}))
+	require.Contains(t, workers.workersMap, (withKindAliasesArgs{}).Kind())
+	require.Contains(t, workers.workersMap, (withKindAliasesArgs{}).KindAliases()[0])
+
+	require.PanicsWithError(t, `worker for kind "with_kind_alternate_alternate" is already registered`, func() {
+		AddWorker(workers, WorkFunc(func(ctx context.Context, job *Job[withKindAliasesCollisionArgs]) error {
+			return nil
+		}))
+	})
 }
 
 type configurableArgs struct {
@@ -51,6 +63,17 @@ func (w *configurableWorker) Work(ctx context.Context, job *Job[configurableArgs
 	return nil
 }
 
+type withKindAliasesArgs struct{}
+
+func (a withKindAliasesArgs) Kind() string          { return "with_kind_alternate" }
+func (a withKindAliasesArgs) KindAliases() []string { return []string{"with_kind_alternate_alternate"} }
+
+type withKindAliasesCollisionArgs struct{}
+
+func (a withKindAliasesCollisionArgs) Kind() string {
+	return (withKindAliasesArgs{}).KindAliases()[0]
+}
+
 func TestWorkers_add(t *testing.T) {
 	t.Parallel()
 


Reviews:
user: login: bgentry
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T21:10:14Z
user: login: brandur
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T08:45:04Z
user: login: bgentry
body: Looks great! We can probably add a docs section to one of our pages for this, though I'm not quite sure which one makes the most sense 🤔 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T16:04:22Z

Review comments:
diff_hunk: @@ -24,6 +24,37 @@ type JobArgs interface {
 	Kind() string
 }
 
+// JobArgsWithKindAlternate  is an interface that jobs args can implement to
+// provide an alternate kind which a worker will be registered under in addition
+// to the primary kind. This is useful for renaming a job kind in a safe manner
+// so that any jobs already in the database aren't orphaned.
+//
+// Renaming a job is a three part process. To begin, a job args with its
+// original name:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string { return "old_name" }
+//
+// Rename by putting the new name in Kind and moving the old name to
+// KindAlternate:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string          { return "new_name" }
+//	func (a jobArgsBeingRenamed) KindAlternate() string { return "old_name_still_recognized" }
+//
+// After all jobs inserted under the original name have finished working
+// (including all their possible retries, which notably might take up to three
+// weeks on the default retry policy), remove KindAlternate:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string { return "new_name" }
+type JobArgsWithKindAlternate interface {
+	KindAlternate() string
path: job.go
user: login: bgentry
body: Thoughts on not limiting this to a single alternate name/alias? I'm imagining a use case where multiple old job types are consolidated into one new one—would be great to handle more flexible situations like that. `KindAliases() []string` for example.
created_at: 2025-05-08T21:10:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -24,6 +24,37 @@ type JobArgs interface {
 	Kind() string
 }
 
+// JobArgsWithKindAlternate  is an interface that jobs args can implement to
+// provide an alternate kind which a worker will be registered under in addition
+// to the primary kind. This is useful for renaming a job kind in a safe manner
+// so that any jobs already in the database aren't orphaned.
+//
+// Renaming a job is a three part process. To begin, a job args with its
+// original name:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string { return "old_name" }
+//
+// Rename by putting the new name in Kind and moving the old name to
+// KindAlternate:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string          { return "new_name" }
+//	func (a jobArgsBeingRenamed) KindAlternate() string { return "old_name_still_recognized" }
+//
+// After all jobs inserted under the original name have finished working
+// (including all their possible retries, which notably might take up to three
+// weeks on the default retry policy), remove KindAlternate:
+//
+//	type jobArgsBeingRenamed struct{}
+//
+//	func (a jobArgsBeingRenamed) Kind() string { return "new_name" }
+type JobArgsWithKindAlternate interface {
+	KindAlternate() string
path: job.go
user: login: brandur
body: Yeah I thought about it, but TBH, I couldn't think of any particularly compelling cases where something like that would be needed.

That said, I don't feel particularly strongly about it. Updated the PR to add `KindAliases`.
created_at: 2025-05-09T08:45:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: brandur
created_at: 2025-05-08T00:20:22Z
body: @bgentry Any immediate reactions on this one? I wouldn't mind shipping a new release so we could try putting the full lib/pq support in front of people, but since #879 would go out at the same time, it wouldn't be the worst idea to make this available as a migration aid.
author_association: CONTRIBUTOR
user: login: brandur
created_at: 2025-05-10T07:48:25Z
body: Great, thx.

RE docs: Yeah, I'm not sure yet either, but I was kind of thinking there might be a dedicated "renaming jobs" how-to or something of that nature. It's a big enough/non-obvious enough subject that there's a decent amount of content.
author_association: CONTRIBUTOR