Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ruby/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed

- A `River::Config` without an explicit `id:` generates its default client ID on first use in each process, so clients built before a fork (as with preloading Puma, Unicorn, or Pitchfork servers) no longer share one ID across forked workers. Explicit IDs are unchanged, and `Config#with` copies share the original's ID. [PR #1498](https://github.com/riverqueue/river/pull/1498).
- Inserting a job stores its encoded args as given instead of decoding and re-encoding them in Ruby, which rewrote numbers like `1.10` as `1.1` and `1e3` as `1000.0` and rounded digits beyond `Float` precision. Postgres applies its usual `jsonb` normalization, and SQLite keeps the encoded bytes, matching River's Go drivers. Affects Active Record on Postgres and SQLite, and Sequel on SQLite. [PR #1503](https://github.com/riverqueue/river/pull/1503).

## [0.13.0] - 2026-10-08

Expand Down
10 changes: 8 additions & 2 deletions ruby/driver/riverqueue-activerecord/lib/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,10 @@ def transaction(&)
metadata = insert_params.metadata || {}
metadata = metadata.merge(UNIQUE_INSERT_METADATA_KEY => nonce) if nonce
{
args: JSON.parse(insert_params.encoded_args),
# Bypass the jsonb attribute type, which would decode and re-encode
# the args, losing number spellings like `1.10` and digits beyond
# Float precision. Postgres parses the encoded bytes itself instead.
args: Arel.sql("CAST(#{runtime_quote(insert_params.encoded_args)} AS jsonb)"),
kind: insert_params.kind,
max_attempts: insert_params.max_attempts,
metadata: metadata,
Expand Down Expand Up @@ -262,7 +265,10 @@ def transaction(&)

private def sqlite_insert_params_to_hash(insert_params, nonce)
{
args: JSON.parse(insert_params.encoded_args),
# Embed the encoded args as a JSON string so they reach SQLite
# byte-for-byte. `json_extract` unwraps the string back to the
# encoded JSON text before `jsonb` parses it.
args: insert_params.encoded_args,
kind: insert_params.kind,
max_attempts: insert_params.max_attempts,
metadata: {UNIQUE_INSERT_METADATA_KEY => nonce},
Expand Down
5 changes: 4 additions & 1 deletion ruby/driver/riverqueue-sequel/lib/driver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,10 @@ def transaction

private def sqlite_insert_params_to_hash(insert_params, nonce)
{
args: JSON.parse(insert_params.encoded_args),
# Embed the encoded args as a JSON string so they reach SQLite
# byte-for-byte. `json_extract` unwraps the string back to the
# encoded JSON text before `jsonb` parses it.
args: insert_params.encoded_args,
kind: insert_params.kind,
max_attempts: insert_params.max_attempts,
metadata: {UNIQUE_INSERT_METADATA_KEY => nonce},
Expand Down
34 changes: 34 additions & 0 deletions ruby/spec/driver_shared_examples.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ def kind = "simple"
def to_json = JSON.dump({job_num: job_num})
end

# Returns preencoded JSON as given, like args encoded by a plugin or a custom
# `#to_json` implementation.
class EncodedJSONArgs
def initialize(encoded)
@encoded = encoded
end

def kind = "encoded_json"

def to_json = @encoded
end

# Lets us test job-specific insertion opts by making `#insert_opts` an accessor.
# Real args that make use of this functionality will probably want to make
# `#insert_opts` a non-accessor method instead.
Expand Down Expand Up @@ -541,6 +553,28 @@ class SimpleArgsWithInsertOpts < SimpleArgs
expect(driver.job_get_by_id(jobs[0].id)).to be nil
expect(driver.job_get_by_id(jobs[1].id)).to be nil
end

# Decoding and re-encoding args in Ruby would rewrite `1.10` as `1.1` and
# `1e3` as `1000.0`, round digits beyond Float precision, and decode
# unicode escapes. Postgres instead stores its jsonb normalization of the
# encoded bytes, and SQLite keeps them as given, matching Go's drivers.
it "stores encoded args as given instead of re-encoding them" do
encoded = '{"decimal":1.10,"exponent":1e3,"high_precision":1234567890.1234567890,"unicode":"caf\\u00e9"}'

postgres = driver.send(:runtime_postgres?)

inserted = client.insert_many([EncodedJSONArgs.new(encoded), EncodedJSONArgs.new(encoded)])

stored = inserted.map do |result|
driver.send(:runtime_query_rows, <<~SQL).first.values.first
SELECT #{postgres ? "args::text" : "json(args)"} AS args FROM river_job WHERE id = #{result.job.id}
SQL
end
expect(stored).to all(eq(postgres ?
'{"decimal": 1.10, "unicode": "café", "exponent": 1000, "high_precision": 1234567890.1234567890}' :
encoded))
expect(inserted.map { |result| result.job.args }).to all(eq(JSON.parse(encoded)))
end
end

describe "#job_list" do
Expand Down
Loading