From 504ffcb2c40489f28d6e0406b37c887984a3587c Mon Sep 17 00:00:00 2001 From: Blake Gentry Date: Tue, 13 Oct 2026 18:05:35 -0700 Subject: [PATCH] [Ruby] Preserve encoded job args on insert Insert job args using the encoded bytes produced by `#to_json` (or supplied pre-encoded, as by a plugin) rather than decoding them with `JSON.parse` and serializing them again. That round trip runs on Active Record with Postgres or SQLite and on Sequel with SQLite, and it rewrites numbers through Ruby's `Float`: `1.10` becomes `1.1`, `1e3` becomes `1000.0`, and decimals beyond `Float` precision are rounded. On SQLite it also decodes unicode escapes, since the stored JSON is otherwise kept as written. On Postgres, Active Record now binds the encoded args as a SQL literal wrapped in `CAST(... AS jsonb)`, bypassing the jsonb attribute type that would decode and re-encode them. Postgres applies its normal jsonb normalization, the same as Go's River drivers, so `1.10` and long decimals are kept exactly. Sequel on Postgres already passes the string through. On SQLite, both drivers embed the encoded args as a JSON string in the batch document. `json_extract` unwraps it to the original JSON text before `jsonb` parses it, so the stored args match the bytes Go's SQLite driver stores for the same input. Batch inserts stay a single statement, and unique key computation already uses the encoded args, so it is unaffected. --- ruby/CHANGELOG.md | 1 + .../riverqueue-activerecord/lib/driver.rb | 10 ++++-- ruby/driver/riverqueue-sequel/lib/driver.rb | 5 ++- ruby/spec/driver_shared_examples.rb | 34 +++++++++++++++++++ 4 files changed, 47 insertions(+), 3 deletions(-) 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