diff --git a/ruby/CHANGELOG.md b/ruby/CHANGELOG.md index 9a6be3ee6..291474d94 100644 --- a/ruby/CHANGELOG.md +++ b/ruby/CHANGELOG.md @@ -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 diff --git a/ruby/driver/riverqueue-activerecord/lib/driver.rb b/ruby/driver/riverqueue-activerecord/lib/driver.rb index 2f45e0d9e..fab5ab795 100644 --- a/ruby/driver/riverqueue-activerecord/lib/driver.rb +++ b/ruby/driver/riverqueue-activerecord/lib/driver.rb @@ -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, @@ -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}, diff --git a/ruby/driver/riverqueue-sequel/lib/driver.rb b/ruby/driver/riverqueue-sequel/lib/driver.rb index a2c576e50..354c33998 100644 --- a/ruby/driver/riverqueue-sequel/lib/driver.rb +++ b/ruby/driver/riverqueue-sequel/lib/driver.rb @@ -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}, diff --git a/ruby/spec/driver_shared_examples.rb b/ruby/spec/driver_shared_examples.rb index b9c06129a..b6cda7868 100644 --- a/ruby/spec/driver_shared_examples.rb +++ b/ruby/spec/driver_shared_examples.rb @@ -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. @@ -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