Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Technically, a talk | RailsConf 2020

Technically, a talk | RailsConf 2020

Peer deep into Rails' database handling and you may find the code overly complex, hard to follow, and full of technical debt. On the surface you're right - it is complex, but that complexity represents the strong foundation that keeps your applications simple and focused on your product code. In this talk we'll look at how to use multiple databases, the beauty (and horror) of Rails connection management, and why we built this feature for you.

Eileen M. Uchitelle

May 05, 2020
Tweet

More Decks by Eileen M. Uchitelle

Other Decks in Programming

Transcript

  1. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #35073 #35130 #35237 #36469 #36834 #36868 #38042 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672 #38670 #38920 #38684 #38770
  2. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #38684
  3. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #38670 #38920 #38684
  4. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #38770 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #38684 #38670 #38920
  5. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #38684 #38670 #38920 #38770
  6. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #35073 #35130 #35237 #36469 #36834 #36868 #38042 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38684 #38670 #38920 #38770 #38581 #38672
  7. a

  8. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 Migrations #38684
  9. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #37298 #37364 #37368 #37443 #38011 #37280 #38209 Database Configurations #38670 #38920
  10. { "development" => { "database" => "recipes_development" }, "test" =>

    { "database" => "recipes_test" }, "production" => { "database" => "recipes_production" } }
  11. development: primary: database: recipes_development primary_replica: database: recipes_development replica: true test:

    primary: database: recipes_test primary_replica: database: recipes_test replica: true production: primary: database: recipes_production primary_replica: database: recipes_production replica: true
  12. { "development" => { "primary" => { "database" => "recipes_development"

    }, "primary_replica" => { "database" => "recipes_development", "replica" => true }, } "test" => { "primary" => { "database" => "recipes_test" }, "primary_replica" => { "database" => "recipes_test", "replica" => true }, }, "production" => { "primary" => { "database" => "recipes_production" }, "primary_replica" => { "database" => "recipes_production", "replica" => true }, } }
  13. DATABASE_URL="postgres://root:pass123@localhost:9000/my_database" #<ActiveRecord::DatabaseConfigurations::UrlConfig @env_name = "production", @name = "primary", @configuration_hash =

    { :adapter => "postgres", :database => "my_database", :host => "localhost", :username => "root", :password => "pass123" }, @url = "postgres://root:pass123@localhost:9000/my_database" >
  14. initializer "active_record.initialize_database" do ActiveSupport.on_load(:active_record) do self.connection_handlers = { writing_role =>

    ActiveRecord::Base.default_connection_handler } self.configurations = Rails.application.config.database_configuration establish_connection end end
  15. #<ActiveRecord::DatabaseConfigurations @configurations=[ #<ActiveRecord::DatabaseConfigurations::HashConfig @env_name = "production", @name = "primary", @configuration_hash

    = { :adapter => "mysql2", :database => "recipes_app_development" } >, #<ActiveRecord::DatabaseConfigurations::HashConfig @env_name = "production", @name = "meals_one", @configuration_hash = { :adapter => "mysql2", :database => "meals_one_development" } > ] >
  16. ActiveRecord::Base.configurations.configs_for( env_name: "production" ) #<ActiveRecord::DatabaseConfigurations @configurations=[ #<ActiveRecord::DatabaseConfigurations::HashConfig @env_name = "production",

    @name = "primary", @configuration_hash = { :adapter => "mysql2", :database => "recipes_app_development" }>, #<ActiveRecord::DatabaseConfigurations::HashConfig @env_name = "production", @name = "meals_one", @configuration_hash = { :adapter => "mysql2", :database => "meals_one_development" }>, #<ActiveRecord::DatabaseConfigurations::HashConfig @env_name = "production", @name = "meals_two", @configuration_hash = { :adapter => "mysql2", :database => "meals_two_development" }> ] >
  17. ActiveRecord::Base.configurations.configs_for( env_name: "production", name: "meals_one" ) #<ActiveRecord::DatabaseConfigurations::HashConfig @env_name = "production",

    @name = "meals_one", @configuration_hash = { :adapter => "mysql2", :database => "meals_one_development", :replica => true }>
  18. { "database" => "recipes_development", "replica" => true "adapter" => "mysql2",

    "reaping_frequency" => 60, "username" => "user_ro", "password" => ENV["PASSWORD"]}
  19. { "database" => "recipes_development", "replica" => true "adapter" => "mysql2",

    "reaping_frequency" => 60, "username" => "user_ro", "password" => ENV["PASSWORD"]...} Values Rails Needs • database • host • adapter • replica • pool • reaping_frequency • checkout_timeout • idle_timeout
  20. { "database" => "recipes_development", "replica" => true "adapter" => "mysql2",

    "reaping_frequency" => 60, "username" => "user_ro", "password" => ENV["PASSWORD"]...} • database • host • adapter • replica • pool • reaping_frequency • checkout_timeout • idle_timeout • database • host • username • password • timeout • custom settings Values Rails Needs Values Clients Need
  21. class HashConfig ... def checkout_timeout (configuration_hash[:checkout_timeout] || 5).to_f end def

    reaping_frequency configuration_hash.fetch(:reaping_frequency, 60)&.to_f end def idle_timeout timeout = configuration_hash.fetch(:idle_timeout, 300).to_f timeout if timeout > 0 end ... end
  22. - @checkout_timeout = (spec.db_config.configuration_hash[:checkout_timeout] && spec.db_config.configuration_hash[:checkout_timeout].to_f) || 5 - if

    @idle_timeout = spec.db_config.configuration_hash.fetch(:idle_timeout, 300) - @idle_timeout = @idle_timeout.to_f - @idle_timeout = nil if @idle_timeout <= 0 - end - # default max pool size to 5 - @size = (spec.db_config.configuration_hash[:pool] && spec.db_config.configuration_hash[:pool].to_i) || 5 + @checkout_timeout = spec.db_config.checkout_timeout + @idle_timeout = spec.db_config.idle_timeout + @size = spec.db_config.pool
  23. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 Rails Tasks #38770
  24. rails db:create rails db:drop rails db:migrate rails db:reset rails db:abort_if_pending_migrations

    rails db:migrate:status rails db:structure:dump rails db:schema:cache:dump
  25. task migrate: :load_config do original_db_config = ActiveRecord::Base.connection_db_config ActiveRecord::Base.configurations.configs_for( env_name: ActiveRecord::Tasks::DatabaseTasks.env

    ).each do |db_config| ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate end db_namespace["_dump"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end
  26. task migrate: :load_config do original_db_config = ActiveRecord::Base.connection_db_config ActiveRecord::Base.configurations.configs_for( env_name: ActiveRecord::Tasks::DatabaseTasks.env

    ).each do |db_config| ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate end db_namespace["_dump"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end
  27. task migrate: :load_config do original_db_config = ActiveRecord::Base.connection_db_config ActiveRecord::Base.configurations.configs_for( env_name: ActiveRecord::Tasks::DatabaseTasks.env

    ).each do |db_config| ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate end db_namespace["_dump"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end
  28. task migrate: :load_config do original_db_config = ActiveRecord::Base.connection_db_config ActiveRecord::Base.configurations.configs_for( env_name: ActiveRecord::Tasks::DatabaseTasks.env

    ).each do |db_config| ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate end db_namespace["_dump"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end
  29. task migrate: :load_config do original_db_config = ActiveRecord::Base.connection_db_config ActiveRecord::Base.configurations.configs_for( env_name: ActiveRecord::Tasks::DatabaseTasks.env

    ).each do |db_config| ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate end db_namespace["_dump"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end
  30. rails db:create:primary rails db:create:meals_default rails db:create:meals_one rails db:create:meals_two rails db:drop:primary

    rails db:drop:meals_default rails db:drop:meals_one rails db:drop:meals_two rails db:migrate:primary rails db:drop:meals_default rails db:migrate:meals_one rails db:migrate:meals_two
  31. namespace :migrate do ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |name| desc "Migrate #{name} database

    for current environment" task name => :load_config do original_db_config = ActiveRecord::Base.connection_db_config db_config = ActiveRecord::Base.configurations.configs_for( env_name: Rails.env, name: name ) ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate db_namespace["_dump:#{name}"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end end end
  32. namespace :migrate do ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |name| desc "Migrate #{name} database

    for current environment" task name => :load_config do original_db_config = ActiveRecord::Base.connection_db_config db_config = ActiveRecord::Base.configurations.configs_for( env_name: Rails.env, name: name ) ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate db_namespace["_dump:#{name}"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end end end
  33. def load_database_yaml if path = paths["config/database"].existent.first require "rails/application/dummy_erb_compiler" yaml =

    Pathname.new(path) erb = DummyERB.new(yaml.read) YAML.load(erb.result) || {} else {} end end
  34. class DummyERB < ERB # :nodoc: def make_compiler(trim_mode) DummyCompiler.new trim_mode

    end end class DummyCompiler < ERB::Compiler # :nodoc: def compile_content(stag, out) if stag == "<%=" out.push "_erbout << ''" end end end
  35. namespace :migrate do ActiveRecord::Tasks::DatabaseTasks.for_each(databases) do |name| desc "Migrate #{name} database

    for current environment" task name => :load_config do original_db_config = ActiveRecord::Base.connection_db_config db_config = ActiveRecord::Base.configurations.configs_for( env_name: Rails.env, name: name ) ActiveRecord::Base.establish_connection(db_config) ActiveRecord::Tasks::DatabaseTasks.migrate db_namespace["_dump:#{name}"].invoke ensure ActiveRecord::Base.establish_connection(original_db_config) end end end
  36. ✅ rails db:migrate:up:primary ✅ rails db:migrate:up:meals_one ✅ rails db:migrate:up:meals_two ✅

    rails db:migrate:down:primary ✅ rails db:migrate:down:meals_one ✅ rails db:migrate:down:meals_two ✅ rails db:rollback:primary ✅ rails db:rollback:meals_one ✅ rails db:rollback:meals_two
  37. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 Connection APIs #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672
  38. initializer "active_record.initialize_database" do ActiveSupport.on_load(:active_record) do self.connection_handlers = { writing_role =>

    ActiveRecord::Base.default_connection_handler } self.configurations = Rails.application.config.database_configuration establish_connection end end
  39. class MealApplicationRecord < ApplicationRecord self.abstract_class = true connects_to shards: {

    default: { writing: :meals_default, reading: :meals_default_replica }, shard_one: { writing: :meals_one, reading: :meals_one_replica } shard_two: { writing: :meals_two, reading: :meals_two_replica } } end
  40. class MealApplicationRecord < ApplicationRecord self.abstract_class = true connects_to shards: {

    default: { writing: :meals_default, reading: :meals_default_replica }, shard_one: { writing: :meals_one, reading: :meals_one_replica } shard_two: { writing: :meals_two, reading: :meals_two_replica } } end Shard key
  41. class MealApplicationRecord < ApplicationRecord self.abstract_class = true connects_to shards: {

    default: { writing: :meals_default, reading: :meals_default_replica }, shard_one: { writing: :meals_one, reading: :meals_one_replica } shard_two: { writing: :meals_two, reading: :meals_two_replica } } end Role
  42. class MealApplicationRecord < ApplicationRecord self.abstract_class = true connects_to shards: {

    default: { writing: :meals_default, reading: :meals_default_replica }, shard_one: { writing: :meals_one, reading: :meals_one_replica } shard_two: { writing: :meals_two, reading: :meals_two_replica } } end Name
  43. :writing :default PoolConfig ConnectionPool :shard_one PoolConfig ConnectionPool :shard_two PoolConfig ConnectionPool

    MealApplicationBase ActiveRecord::Base Pool Config Connection Pool :default PoolConfig ActiveRecord::Base ConnectionPool
  44. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #35073 #35130 #35237 #36469 #36834 #36868 #38042 Automatic connection swapping
  45. Rails.application.configure do config.active_record.database_selector = { delay: 2.seconds } config.active_record.database_resolver =

    ActiveRecord::Middleware::DatabaseSelector::Resolver config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session end
  46. class DatabaseSelector def initialize(app, resolver_klass = nil, context_klass = nil,

    options = {}) @app = app @resolver_klass = resolver_klass || Resolver @context_klass = context_klass || Resolver::Session @options = options end ... end
  47. class DatabaseSelector def select_database(request, &blk) context = context_klass.call(request) resolver =

    resolver_klass.call(context, options) response = if reading_request?(request) resolver.read(&blk) else resolver.write(&blk) end resolver.update_context(response) response end end
  48. class DatabaseSelector def select_database(request, &blk) context = context_klass.call(request) resolver =

    resolver_klass.call(context, options) response = if reading_request?(request) resolver.read(&blk) else resolver.write(&blk) end resolver.update_context(response) response end end
  49. class Resolver ... def write_to_primary(&blk) ActiveRecord::Base.connected_to( role: ActiveRecord::Base.writing_role, prevent_writes: false

    ) do instrumenter.instrument("database_selector") do yield ensure context.update_last_write_timestamp end end end end
  50. class Resolver class Cookie def self.call(request) new(request.cookies) end def initialize(cookies)

    @cookies = cookies end def update_last_write_timestamp @cookies[:last_write] = self.class.convert_time_to_timestamp(Time.now) end end end
  51. Rails.application.configure do config.active_record.database_selector = { delay: 2.seconds } config.active_record.database_resolver =

    ActiveRecord::Middleware::DatabaseSelector::Resolver config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Cookies end
  52. Migrations didn’t run, configurations were broken, Rails tasks didn’t work

    correctly, no way for models to establish multiple connections,
  53. no API or pattern for auto-switching connections... Migrations didn’t run,

    configurations were broken, Rails tasks didn’t work correctly, no way for models to establish multiple connections,
  54. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #38684
  55. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #38670 #38920 #38684
  56. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #38770 #38684 #38670 #38920
  57. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672 #38684 #38670 #38920 #38770
  58. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #35073 #35130 #35237 #36469 #36834 #36868 #38042 #38684 #38670 #38920 #38770 #38581 #38672
  59. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #35073 #35130 #35237 #36469 #36834 #36868 #38042 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672 #38670 #38920 #38684 #38770 Public APIs / User Experience
  60. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #35073 #35130 #35237 #36469 #36834 #36868 #38042 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672 #38670 #38920 #38684 #38770 Private APIs / Internals
  61. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #35073 #35130 #35237 #36469 #36834 #36868 #38042 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672 #38670 #38920 #38684 #38770
  62. Jan 18 Feb 18 Mar 18 Apr 18 May 18

    Jun 18 Jul 18 Aug 18 Sep 18 Oct 18 Nov 18 Dec 18 Jan 19 Feb 19 Mar 19 Apr 19 May 19 Jun 19 Jul 19 Aug 19 Sep 19 Oct 19 Nov 19 Dec 19 Jan 20 Feb 20 Mar 20 Apr 20 May 20 #31727 #33760 #36439 #36371 #36886 #38235 #38444 #32075 #32271 #32396 #32274 #33637 #33770 #34495 #34969 #35102 #35242 #36560 #36565 #36756 #36770 #36814 #37185 #37182 #37231 #37276 #37277 #37279 #37280 #37695 #37873 #37963 #38008 #38004 #34495 #38536 #38609 #32774 #33748 #35479 #35663 #36023 #36039 #36237 *d83ab #36824 #37223 #37242 #37291 #37230 #37365 *48d58 *0a353 #35073 #35130 #35237 #36469 #36834 #36868 #38042 #37298 #37364 #37368 #37443 #38011 #37280 #38209 #33877 #34052 #34505 #34054 #34491 #34632 #34753 #35042 #35123 #35089 #35899 #36394 #37065 #37180 #37199 #37503 #37296 #37408 #37622 #37874 #38258 #38339 #39190 #38531 #38580 #38581 #38672 #38670 #38920 #38684 #38770 ❤