Skip to content

Replace multi_json with the json standard library - #613

Merged
ls-todd-lunter merged 1 commit into
upserve:masterfrom
tas50:replace-multi-json-with-json
Oct 27, 2026
Merged

ls-todd-lunter merged 1 commit into
upserve:masterfrom
tas50:replace-multi-json-with-json

Conversation

@tas50

@tas50 tas50 commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Replaces multi_json with Ruby's standard json library and drops the multi_json runtime dependency.

multi_json picks a JSON backend at runtime. That was useful when the json gem was slow, but current stdlib json matches or beats oj. Calling it directly also skips multi_json's per-call adapter dispatch, which is most of the cost on the small payloads the Docker API returns.

  • MultiJson.load / MultiJson.dump → JSON.parse / JSON.generate
  • MultiJson::ParseError → JSON::ParserError in Docker::Util.parse_json (it still re-raises as Docker::Error::UnexpectedResponseError, so callers see no change)
  • multi_json's symbolize_keys: true → stdlib's symbolize_names: true

Performance

These payloads were captured from a real Docker 29.9 daemon (/containers/json, /images/json, /containers/:id/json, /info). There's also a synthetic 200-container list to stand in for a busy host. The write case is a typical Container.create body. I used benchmark-ips in ruby:* containers on arm64 Linux. Each report selects its multi_json adapter once, outside the timed loop.

Iterations per second (higher is better):

Ruby 4.0.9, json 2.18.0 (bundled), oj 3.17.7

Operation JSON MultiJson (json_gem) MultiJson (oj)
parse containers (2.5 KB) 173k 140k (1.24× slower) 128k (1.35× slower)
parse images (1.9 KB) 289k 186k (1.55× slower) 141k (2.05× slower)
parse inspect (5.3 KB) 68k 61k (within error) 61k (within error)
parse info (9.9 KB) 50k 45k (1.10× slower) 48k (within error)
parse 200 containers (495 KB) 1.05k 1.07k (within error) 0.89k (1.21× slower)
generate Container.create body 1.71M 468k (3.64× slower) 428k (3.99× slower)

Ruby 3.3.13 with current json 3.1.0, oj 3.17.7

Operation JSON MultiJson (json_gem) MultiJson (oj)
parse containers 160k 107k (1.49× slower) 112k (1.43× slower)
parse images 223k 144k (1.55× slower) 112k (2.00× slower)
parse inspect 47k 42k (within error) 48k (within error)
parse info 49k 44k (within error) 49k (within error)
parse 200 containers 1.01k 0.99k (within error) 0.71k (1.42× slower)
generate Container.create body 849k 476k (1.78× slower) 413k (2.06× slower)

What this shows:

  • Writes are 1.8–4× faster on every Ruby tested. Request bodies are small, so multi_json's dispatch overhead is most of the time spent.
  • Parsing is faster or equal on small responses (up to 1.55× vs json_gem and 2× vs oj). On large responses the parser itself dominates and the gap closes.
  • Caveat: Ruby 3.3's bundled json 2.7.2 parses 1.1–1.5× slower than oj. A user on Ruby ≤ 3.3 who has oj in their bundle would see slower parsing until they update the json gem. json ≥ 2.10 includes the rewritten parser that closes the gap, as the second table shows. I didn't add a json >= 2.10 constraint because that requires Ruby 2.7+, and CI still covers 2.4–2.6. It makes sense to add once the minimum Ruby version is raised.
  • In absolute terms these are microseconds against HTTP round trips to the daemon that take milliseconds. The practical wins are one less runtime dependency and no adapter indirection. The speedup is a bonus, not a hot-path fix.

The harness also asserts that JSON and MultiJson (json_gem adapter) give identical results on every payload.

Benchmark script
# frozen_string_literal: true

# Compares MultiJson (with its json_gem and oj adapters) against stdlib JSON
# on payloads captured from a real Docker daemon. Usage: ruby bench.rb DATA_DIR
require 'benchmark/ips'
require 'json'
require 'multi_json'
require 'oj'

dir = ARGV.fetch(0)
payloads = %w[containers images inspect info].to_h do |name|
  [name, File.read(File.join(dir, "#{name}.json"))]
end
# A busy host: 200 containers in one /containers/json response.
payloads['containers_x200'] = JSON.generate(JSON.parse(payloads['containers']) * 200)

# Typical Container.create body.
create_body = {
  'Image' => 'debian:stable', 'Cmd' => %w[sh -c echo hello],
  'Env' => %w[FOO=bar BAZ=qux], 'Labels' => { 'app' => 'web', 'tier' => 'frontend' },
  'ExposedPorts' => { '80/tcp' => {} },
  'HostConfig' => { 'PortBindings' => { '80/tcp' => [{ 'HostPort' => '8080' }] },
                    'Binds' => ['/tmp:/tmp:ro'], 'Memory' => 268_435_456 }
}

puts "ruby #{RUBY_VERSION}, json #{JSON::VERSION}, multi_json #{MultiJson::VERSION}, oj #{Oj::VERSION}"
puts payloads.map { |k, v| "#{k}=#{v.bytesize}B" }.join(' ')

# Each report selects its adapter once, then loops, so adapter switching is
# not part of the timed work.
def report(x, label, adapter = nil)
  x.report(label) do |times|
    MultiJson.use(adapter) if adapter
    i = 0
    while i < times
      yield
      i += 1
    end
  end
end

payloads.each do |name, body|
  puts "\n== parse #{name} (#{body.bytesize} bytes)"
  Benchmark.ips do |x|
    x.config(warmup: 1, time: 3)
    report(x, 'JSON.parse') { JSON.parse(body) }
    report(x, 'MultiJson.load (json_gem)', :json_gem) { MultiJson.load(body) }
    report(x, 'MultiJson.load (oj)', :oj) { MultiJson.load(body) }
    x.compare!
  end
end

puts "\n== generate Container.create body"
Benchmark.ips do |x|
  x.config(warmup: 1, time: 3)
  report(x, 'JSON.generate') { JSON.generate(create_body) }
  report(x, 'MultiJson.dump (json_gem)', :json_gem) { MultiJson.dump(create_body) }
  report(x, 'MultiJson.dump (oj)', :oj) { MultiJson.dump(create_body) }
  x.compare!
end

# Outputs must be identical so the swap is behavior-preserving.
payloads.each_value do |body|
  MultiJson.use(:json_gem)
  raise 'parse mismatch' unless JSON.parse(body) == MultiJson.load(body)
end
MultiJson.use(:json_gem)
raise 'generate mismatch' unless JSON.generate(create_body) == MultiJson.dump(create_body)
puts "\nequivalence: JSON and MultiJson(json_gem) produce identical results"

Run with: docker run --rm -v $PWD:/b ruby:4.0 bash -c 'gem install benchmark-ips multi_json oj && ruby /b/bench.rb /b/data'. data/ holds the captured API responses.

Testing

  • I ran the full suite (rake rspec) against Docker 29.9 in a ruby:4.0 container, on both this branch and master: 242 examples, 2 failures, 11 pending on each, and the same two failures. Both are environmental on a macOS Docker host and fail on master too: container_spec.rb:680 is the stdin exec hang from Attaching a container with stdin hangs on OSX #601, and image_spec.rb:281 is a local registry issue.
  • util_spec, event_spec, and docker_spec, which cover the changed parse and auth-header code: 69 examples, 0 failures.

multi_json picks between JSON backends at runtime. That mattered when the
json gem was slow, but stdlib json is now as fast as or faster than oj on
Docker API payloads, and calling it directly skips multi_json's per-call
adapter dispatch.

- JSON.parse / JSON.generate replace MultiJson.load / MultiJson.dump
- JSON::ParserError replaces MultiJson::ParseError in Util.parse_json
- symbolize_names: replaces multi_json's symbolize_keys: option
- Drop the multi_json runtime dependency

Signed-off-by: Tim Smith <tsmith84@proton.me>
@ls-todd-lunter
ls-todd-lunter merged commit 7374dd6 into upserve:master Oct 27, 2026
32 checks passed
ls-todd-lunter added a commit that referenced this pull request Oct 27, 2026
* Declare base64 as a runtime dependency so the gem loads on Ruby 3.4+ (#605)
* Replace multi_json with the json standard library and drop the multi_json dependency (#613)
* Keep the build context inside the build directory: symlinks are archived as symlinks rather than followed, and .dockerignore patterns can no longer reach outside the directory (#611)
* Send platform as a query parameter on Container.create (#607)
* Honor stdout: false and stderr: false in Container#exec (#609)
* Request an HTTP upgrade when attaching to stdin so the attach no longer hangs (#610)
* Memoize Connection#rootless? when the daemon is not rootless (#608)
* Add gemspec metadata (source_code_uri, bug_tracker_uri, rubygems_mfa_required) and build the gem file list without git (#615)
* Fix and update README examples and streaming read_timeout docs (#606, #585, #592)
* Fix CI, test through Ruby 4.0 and Docker 28 and 29, and remove Travis, Code Climate and unmaintained test image leftovers (#604, #605, #614, #616)
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