Skip to content

Commit 112c3cd

Browse files
Merge pull request rails#47770 from jonathanhefner/message_pack
Add `ActiveSupport::MessagePack`
2 parents bd8aeea + a2a6331 commit 112c3cd

File tree

15 files changed

+1010
-4
lines changed

15 files changed

+1010
-4
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ gem "listen", "~> 3.3", require: false
6868
gem "libxml-ruby", platforms: :ruby
6969
gem "connection_pool", require: false
7070
gem "rexml", require: false
71+
gem "msgpack", ">= 1.7.0", require: false
7172

7273
# for railties
7374
gem "bootsnap", ">= 1.4.4", require: false

Gemfile.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ GEM
320320
mixlib-shellout (3.2.7)
321321
chef-utils
322322
mono_logger (1.1.1)
323-
msgpack (1.6.0)
323+
msgpack (1.7.0)
324324
multi_json (1.15.0)
325325
multipart-post (2.2.3)
326326
mustermann (3.0.0)
@@ -580,6 +580,7 @@ DEPENDENCIES
580580
minitest-bisect
581581
minitest-ci
582582
minitest-retry
583+
msgpack (>= 1.7.0)
583584
mysql2 (~> 0.5)
584585
nokogiri (>= 1.8.1, != 1.11.0)
585586
pg (~> 1.3)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecord
4+
module MessagePack # :nodoc:
5+
FORMAT_VERSION = 1
6+
7+
class << self
8+
def dump(input)
9+
encoder = Encoder.new
10+
[FORMAT_VERSION, encoder.encode(input), encoder.entries]
11+
end
12+
13+
def load(dumped)
14+
format_version, top_level, entries = dumped
15+
unless format_version == FORMAT_VERSION
16+
raise "Invalid format version: #{format_version.inspect}"
17+
end
18+
Decoder.new(entries).decode(top_level)
19+
end
20+
end
21+
22+
module Extensions
23+
extend self
24+
25+
def install(registry)
26+
registry.register_type 119, ActiveModel::Type::Binary::Data,
27+
packer: :to_s,
28+
unpacker: :new
29+
30+
registry.register_type 120, ActiveRecord::Base,
31+
packer: method(:write_record),
32+
unpacker: method(:read_record),
33+
recursive: true
34+
end
35+
36+
def write_record(record, packer)
37+
packer.write(ActiveRecord::MessagePack.dump(record))
38+
end
39+
40+
def read_record(unpacker)
41+
ActiveRecord::MessagePack.load(unpacker.read)
42+
end
43+
end
44+
45+
class Encoder
46+
attr_reader :entries
47+
48+
def initialize
49+
@entries = []
50+
@refs = {}.compare_by_identity
51+
end
52+
53+
def encode(input)
54+
if input.is_a?(Array)
55+
input.map { |record| encode_record(record) }
56+
elsif input
57+
encode_record(input)
58+
end
59+
end
60+
61+
def encode_record(record)
62+
ref = @refs[record]
63+
64+
if !ref
65+
ref = @refs[record] = @entries.size
66+
@entries << build_entry(record)
67+
add_cached_associations(record, @entries.last)
68+
end
69+
70+
ref
71+
end
72+
73+
def build_entry(record)
74+
[
75+
ActiveSupport::MessagePack::Extensions.dump_class(record.class),
76+
record.attributes_for_database,
77+
record.new_record?
78+
]
79+
end
80+
81+
def add_cached_associations(record, entry)
82+
record.class.reflections.each_value do |reflection|
83+
if record.association_cached?(reflection.name)
84+
entry << reflection.name << encode(record.association(reflection.name).target)
85+
end
86+
end
87+
end
88+
end
89+
90+
class Decoder
91+
def initialize(entries)
92+
@records = entries.map { |entry| build_record(entry) }
93+
@records.zip(entries) { |record, entry| resolve_cached_associations(record, entry) }
94+
end
95+
96+
def decode(ref)
97+
if ref.is_a?(Array)
98+
ref.map { |r| @records[r] }
99+
elsif ref
100+
@records[ref]
101+
end
102+
end
103+
104+
def build_record(entry)
105+
class_name, attributes_hash, is_new_record, * = entry
106+
klass = ActiveSupport::MessagePack::Extensions.load_class(class_name)
107+
attributes = klass.attributes_builder.build_from_database(attributes_hash)
108+
klass.allocate.init_with_attributes(attributes, is_new_record)
109+
end
110+
111+
def resolve_cached_associations(record, entry)
112+
i = 3 # entry == [class_name, attributes_hash, is_new_record, *associations]
113+
while i < entry.length
114+
begin
115+
record.association(entry[i]).target = decode(entry[i + 1])
116+
rescue ActiveRecord::AssociationNotFoundError
117+
# The association no longer exists, so just skip it.
118+
end
119+
i += 2
120+
end
121+
end
122+
end
123+
end
124+
end

activerecord/lib/active_record/railtie.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,5 +424,14 @@ class Railtie < Rails::Railtie # :nodoc:
424424
end
425425
end
426426
end
427+
428+
initializer "active_record.message_pack" do
429+
ActiveSupport.on_load(:message_pack) do
430+
ActiveSupport.on_load(:active_record) do
431+
require "active_record/message_pack"
432+
ActiveRecord::MessagePack::Extensions.install(ActiveSupport::MessagePack::CacheSerializer)
433+
end
434+
end
435+
end
427436
end
428437
end
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# frozen_string_literal: true
2+
3+
require "cases/helper"
4+
require "models/author"
5+
require "models/binary"
6+
require "models/comment"
7+
require "models/post"
8+
require "active_support/message_pack"
9+
require "active_record/message_pack"
10+
11+
class ActiveRecordMessagePackTest < ActiveRecord::TestCase
12+
test "enshrines type IDs" do
13+
expected = {
14+
119 => ActiveModel::Type::Binary::Data,
15+
120 => ActiveRecord::Base,
16+
}
17+
18+
factory = ::MessagePack::Factory.new
19+
ActiveRecord::MessagePack::Extensions.install(factory)
20+
actual = factory.registered_types.to_h do |entry|
21+
[entry[:type], entry[:class]]
22+
end
23+
24+
assert_equal expected, actual
25+
end
26+
27+
test "roundtrips record and cached associations" do
28+
post = Post.create!(title: "A Title", body: "A body.")
29+
post.create_author!(name: "An Author")
30+
post.comments.create!(body: "A comment.")
31+
post.comments.create!(body: "Another comment.", author: post.author)
32+
post.comments.load
33+
34+
assert_no_queries do
35+
roundtripped_post = roundtrip(post)
36+
37+
assert_equal post, roundtripped_post
38+
assert_equal post.author, roundtripped_post.author
39+
assert_equal post.comments.to_a, roundtripped_post.comments.to_a
40+
assert_equal post.comments.map(&:author), roundtripped_post.comments.map(&:author)
41+
42+
assert_same roundtripped_post, roundtripped_post.comments[0].post
43+
assert_same roundtripped_post, roundtripped_post.comments[1].post
44+
assert_same roundtripped_post.author, roundtripped_post.comments[1].author
45+
end
46+
end
47+
48+
test "roundtrips new_record? status" do
49+
post = Post.new(title: "A Title", body: "A body.")
50+
post.create_author!(name: "An Author")
51+
52+
assert_no_queries do
53+
roundtripped_post = roundtrip(post)
54+
55+
assert_equal post.attributes, roundtripped_post.attributes
56+
assert_equal post.new_record?, roundtripped_post.new_record?
57+
assert_equal post.author, roundtripped_post.author
58+
assert_equal post.author.new_record?, roundtripped_post.author.new_record?
59+
end
60+
end
61+
62+
test "roundtrips binary attribute" do
63+
binary = Binary.new(data: Marshal.dump("data"))
64+
assert_equal binary.attributes, roundtrip(binary).attributes
65+
end
66+
67+
test "raises ActiveSupport::MessagePack::MissingClassError if record class no longer exists" do
68+
klass = Class.new(Post)
69+
def klass.name; "SomeLegacyClass"; end
70+
dumped = serializer.dump(klass.new(title: "A Title", body: "A body."))
71+
72+
assert_raises ActiveSupport::MessagePack::MissingClassError do
73+
serializer.load(dumped)
74+
end
75+
end
76+
77+
private
78+
def serializer
79+
@serializer ||= ::MessagePack::Factory.new.tap do |factory|
80+
ActiveRecord::MessagePack::Extensions.install(factory)
81+
ActiveSupport::MessagePack::Extensions.install(factory)
82+
ActiveSupport::MessagePack::Extensions.install_unregistered_type_error(factory)
83+
end
84+
end
85+
86+
def roundtrip(input)
87+
serializer.load(serializer.dump(input))
88+
end
89+
end
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# frozen_string_literal: true
2+
3+
begin
4+
gem "msgpack", ">= 1.7.0"
5+
require "msgpack"
6+
rescue LoadError => error
7+
warn "ActiveSupport::MessagePack requires the msgpack gem, version 1.7.0 or later. " \
8+
"Please add it to your Gemfile: `gem \"msgpack\", \">= 1.7.0\"`"
9+
raise error
10+
end
11+
12+
require_relative "message_pack/cache_serializer"
13+
require_relative "message_pack/serializer"
14+
15+
module ActiveSupport
16+
module MessagePack
17+
extend Serializer
18+
19+
##
20+
# :singleton-method: dump
21+
# :call-seq: dump(object)
22+
#
23+
# Dumps an object. Raises ActiveSupport::MessagePack::UnserializableObjectError
24+
# if the object type is not supported.
25+
#
26+
#--
27+
# Implemented by Serializer#dump.
28+
29+
##
30+
# :singleton-method: load
31+
# :call-seq: load(dumped)
32+
#
33+
# Loads an object dump created by ::dump.
34+
#
35+
#--
36+
# Implemented by Serializer#load.
37+
38+
##
39+
# :singleton-method: signature?
40+
# :call-seq: signature?(dumped)
41+
#
42+
# Returns true if the given dump begins with an +ActiveSupport::MessagePack+
43+
# signature.
44+
#
45+
#--
46+
# Implemented by Serializer#signature?.
47+
48+
ActiveSupport.run_load_hooks(:message_pack, self)
49+
end
50+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# frozen_string_literal: true
2+
3+
require_relative "serializer"
4+
5+
module ActiveSupport
6+
module MessagePack
7+
module CacheSerializer
8+
include Serializer
9+
extend self
10+
11+
ZLIB_HEADER = "\x78"
12+
13+
def dump(entry)
14+
super(entry.pack)
15+
end
16+
17+
def dump_compressed(entry, threshold) # :nodoc:
18+
dumped = dump(entry)
19+
if dumped.bytesize >= threshold
20+
compressed = Zlib::Deflate.deflate(dumped)
21+
compressed.bytesize < dumped.bytesize ? compressed : dumped
22+
else
23+
dumped
24+
end
25+
end
26+
27+
def load(dumped)
28+
dumped = Zlib::Inflate.inflate(dumped) if compressed?(dumped)
29+
ActiveSupport::Cache::Entry.unpack(super)
30+
rescue ActiveSupport::MessagePack::MissingClassError
31+
# Treat missing class as cache miss => return nil
32+
end
33+
34+
private
35+
def compressed?(dumped)
36+
dumped.start_with?(ZLIB_HEADER)
37+
end
38+
39+
def install_unregistered_type_handler
40+
Extensions.install_unregistered_type_fallback(message_pack_factory)
41+
end
42+
end
43+
end
44+
end

0 commit comments

Comments
 (0)