Skip to content

[Rust, JS] Match River Go's behavior in the Rust and JavaScript clients - #1504

Merged
bgentry merged 2 commits into
masterfrom
bg/match-go-behavior-rust-js
Oct 14, 2026
Merged

bgentry merged 2 commits into
masterfrom
bg/match-go-behavior-rust-js

Conversation

@bgentry

@bgentry bgentry commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

River's Rust and JavaScript clients share a database schema with River Go, but in a few places they store or do something different with the same job, so a mixed fleet behaves differently depending on which process inserts or works a job. This brings both clients in line with Go. Each fix stands on its own, and none of it depends on the cross-language conformance harness in #1435, which builds on this.

Rust:

  • Attempt counts are i32 instead of i16, like Go's int, across JobRow, AttemptError, InsertOpts, InsertParams, ClientBuilder::default_max_attempts, MAX_ATTEMPTS_DEFAULT, the derive macro, and riverqueue-test's builders. On SQLite, a job with more than 32,767 maximum attempts inserts and reads back unchanged, and on Postgres an insert clamps max_attempts to the smallint column's 32,767 the way Go's pgx and database/sql drivers do, instead of failing. This is a breaking change to those types.
  • A new JobCancelError lets a worker cancel its job with a reason, like Go's JobCancel(err). Found anywhere in the worker error's source chain, it cancels the job regardless of remaining attempts, skips the error handler, and records JobCancelError: <reason>, the same text Go writes. WorkOutcome::Cancel still cancels without a reason.

JavaScript:

  • insertMany rejects an empty batch with a ValidationError carrying Go's "no jobs to insert" instead of resolving to [].
  • maxAttempts is no longer capped at 32,767. SQLite stores wider attempt counts, and the Postgres and Prisma drivers clamp max_attempts and priority to their smallint columns on insert, as Go's drivers do.
  • A failed resumable step records the step's own error, like Go's ResumableStep, instead of wrapping it in a LifecycleError.
  • A leader whose renewal finds no term to renew gives up leadership immediately without resigning, like Go's elector, instead of running maintenance until its local deadline passes.
  • updateQueue raising a full queue's maxWorkers starts jobs right away. A producer with every worker busy now also wakes for the queue's update, not only when a running job finishes.

@bgentry
bgentry force-pushed the bg/match-go-behavior-rust-js branch from 56d8e6d to 8bef9f6 Compare October 14, 2026 07:18

@brandur brandur left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should try to get a new Rust release sooner rather than later to get that breaking change in in case anyone starts using it.

@bgentry
bgentry force-pushed the bg/match-go-behavior-rust-js branch 2 times, most recently from 909d7a3 to f44435a Compare October 14, 2026 15:28
@bgentry
bgentry marked this pull request as ready for review October 14, 2026 15:45
@bgentry
bgentry enabled auto-merge (squash) October 14, 2026 15:49
River for JavaScript and River for Go store or do different things for
the same job in a few places, so a mixed fleet behaves differently
depending on which process handles a job. Fix each to match Go:

- `insertMany` rejects an empty batch with a `ValidationError` carrying
  Go's "no jobs to insert", like `InsertMany` and `InsertManyTx`,
  instead of resolving to `[]`.
- `maxAttempts` is no longer capped at 32767, like Go's `int`: SQLite
  stores wider attempt counts, and the Postgres and Prisma drivers
  clamp `max_attempts` and `priority` to their `smallint` columns on
  insert, like Go's pgx and `database/sql` drivers.
- A failed resumable step records the step's own error, like Go's
  `ResumableStep`, instead of wrapping it in a `LifecycleError`.
- A leader whose renewal finds no term to renew gives up leadership at
  once without resigning, like Go's elector, instead of running
  maintenance until its local deadline passes.
- `updateQueue` raising a full queue's `maxWorkers` starts jobs
  immediately: a producer with every worker busy also waits for the
  queue's wake-up, not only for a running job to finish.

Tests cover the empty batch, a job with 40,000 maximum attempts read
back unchanged on SQLite and as 32,767 through the Postgres and Prisma
drivers, and the resumable step's recorded error. The changelog lists
each fix under Unreleased.
In two places, River Rust can't do what River Go does with the same
job.

River Go stores `attempt` and `max_attempts` as Go `int`s. On SQLite,
whose columns are native integers, a Go client inserts a job with 40,000
maximum attempts and reads it back unchanged; on Postgres, whose
columns are `smallint`, Go's pgx and `database/sql` drivers clamp
`max_attempts` to 32,767 on insert instead of failing. River Rust
models both as `i16`, so it can't insert such a job at all and reads
one Go wrote on SQLite as 32,767.

A River Go worker cancels its job with `JobCancel(err)`, and the attempt
records `JobCancelError: <err>`. A Rust worker can only return
`WorkOutcome::Cancel`, which records the fixed message "job cancelled
by worker", so a Rust-worked job loses the reason a Go-worked one keeps.

Make attempt counts `i32` across the crates: `JobRow::attempt` and
`max_attempts`, `AttemptError::attempt` and `AttemptError::new`,
`InsertOpts::max_attempts`/`with_max_attempts`,
`InsertParams::max_attempts`, `ClientBuilder::default_max_attempts`,
`MAX_ATTEMPTS_DEFAULT`, the derive macro's `max_attempts` bound, and
`riverqueue-test`'s builders. SQLite rows decode the native integers
directly instead of saturating them into `i16`; a value beyond `i32`
makes the row undecodable like other columns River can't represent.
Postgres rows widen the `smallint` columns, and a Postgres insert
clamps `max_attempts` to 32,767 like Go's drivers.

Add `JobCancelError`, an error a worker returns to cancel its job with
a reason. Anywhere in the worker error's source chain, it cancels the
job whatever attempts it has left, skips the error handler as Go does,
and records `JobCancelError: <reason>`, the same text Go writes.
`WorkOutcome::Cancel` keeps cancelling without a reason.

New tests insert and list a job with 40,000 maximum attempts on SQLite,
check Postgres stores and lists it as 32,767, and check a worker's
`JobCancelError` cancels the job and records its reason.
@bgentry
bgentry force-pushed the bg/match-go-behavior-rust-js branch from da9ebf3 to 6edf0f5 Compare October 14, 2026 15:50
@bgentry
bgentry merged commit f64c804 into master Oct 14, 2026
44 checks passed
@bgentry
bgentry deleted the bg/match-go-behavior-rust-js branch October 14, 2026 15:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants