diff --git a/Gemfile b/Gemfile
index e20b1260..f6d23dcb 100644
--- a/Gemfile
+++ b/Gemfile
@@ -7,6 +7,9 @@ gem 'rails', '~> 5.2.3'
gem 'pg', '>= 0.18', '< 2.0'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
+gem 'oj'
+gem 'activerecord-import'
+gem 'rack-mini-profiler'
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
diff --git a/Gemfile.lock b/Gemfile.lock
index fccf6f5f..da406f9c 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -33,6 +33,8 @@ GEM
activemodel (= 5.2.3)
activesupport (= 5.2.3)
arel (>= 9.0)
+ activerecord-import (1.6.0)
+ activerecord (>= 4.2)
activestorage (5.2.3)
actionpack (= 5.2.3)
activerecord (= 5.2.3)
@@ -51,7 +53,7 @@ GEM
concurrent-ruby (1.1.5)
crass (1.0.4)
erubi (1.8.0)
- ffi (1.10.0)
+ ffi (1.15.5)
globalid (0.4.2)
activesupport (>= 4.2.0)
i18n (1.6.0)
@@ -68,7 +70,9 @@ GEM
marcel (0.3.3)
mimemagic (~> 0.3.2)
method_source (0.9.2)
- mimemagic (0.3.3)
+ mimemagic (0.3.10)
+ nokogiri (~> 1)
+ rake
mini_mime (1.0.1)
mini_portile2 (2.4.0)
minitest (5.11.3)
@@ -76,9 +80,12 @@ GEM
nio4r (2.3.1)
nokogiri (1.10.2)
mini_portile2 (~> 2.4.0)
+ oj (3.13.23)
pg (1.1.4)
puma (3.12.1)
rack (2.0.6)
+ rack-mini-profiler (3.1.1)
+ rack (>= 1.2.0)
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.2.3)
@@ -134,11 +141,14 @@ PLATFORMS
ruby
DEPENDENCIES
+ activerecord-import
bootsnap (>= 1.1.0)
byebug
listen (>= 3.0.5, < 3.2)
+ oj
pg (>= 0.18, < 2.0)
puma (~> 3.11)
+ rack-mini-profiler
rails (~> 5.2.3)
tzinfo-data
web-console (>= 3.3.0)
diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb
index acb38be2..eb2dee12 100644
--- a/app/controllers/trips_controller.rb
+++ b/app/controllers/trips_controller.rb
@@ -2,6 +2,6 @@ class TripsController < ApplicationController
def index
@from = City.find_by_name!(params[:from])
@to = City.find_by_name!(params[:to])
- @trips = Trip.where(from: @from, to: @to).order(:start_time)
+ @trips = Trip.where(from: @from, to: @to).preload(bus: :services).order(:start_time)
end
end
diff --git a/app/models/buses_service.rb b/app/models/buses_service.rb
new file mode 100644
index 00000000..976a0c10
--- /dev/null
+++ b/app/models/buses_service.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class BusesService < ApplicationRecord
+ belongs_to :bus
+ belongs_to :service
+end
diff --git a/app/services/tasks/import_json_data.rb b/app/services/tasks/import_json_data.rb
new file mode 100644
index 00000000..4918df74
--- /dev/null
+++ b/app/services/tasks/import_json_data.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+module Tasks
+ class ImportJsonData
+ BATCH_SIZE = 1000
+
+ attr_accessor :cities, :trips, :services, :buses_hash, :buses_array, :buses_services, :cities_hash
+ attr_reader :filepath
+
+ class << self
+ def call(filepath:)
+ new(filepath).call
+ end
+ end
+
+ def initialize(filepath)
+ @filepath = filepath
+ @cities = []
+ @cities_hash = {}
+ @trips = []
+ @services = {}
+ @buses_hash = {}
+ @buses_array = []
+ @buses_services = []
+ end
+
+ def call
+ ActiveRecord::Base.transaction do
+ create_services
+
+ File.open(filepath) do |ff|
+ nesting = 0
+ str = +''
+
+ until ff.eof?
+ ch = ff.read(1) # читаем по одному символу
+ if ch == '{' # начинается объект, повышается вложенность
+ nesting += 1
+ str << ch
+ elsif ch == '}' # заканчивается объект, понижается вложенность
+ nesting -= 1
+ str << ch
+ if nesting.zero? # если закончился объкет уровня trip, парсим и импортируем его
+ trip = Oj.load(str)
+ process_trip(trip)
+ str = +''
+ end
+ elsif nesting >= 1
+ str << ch
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def create_services
+ Service::SERVICES.each do |service|
+ services[service] = Service.create!(name: service).id
+ end
+ end
+
+ def process_trip(trip)
+ bus_key = "#{trip['bus']['number']}-#{trip['bus']['model']}"
+
+ trip['bus']['services'].each do |service|
+ buses_services << [bus_key, services[service]]
+ end
+
+ buses_array << [trip['bus']['number'], trip['bus']['model']]
+
+ cities << [trip['from']]
+ cities << [trip['to']]
+
+ trips << [trip['from'], trip['to'], trip['start_time'], trip['duration_minutes'], trip['price_cents'], bus_key]
+
+ if trips.size == BATCH_SIZE
+ City.import %i[name], cities.uniq!, on_duplicate_key_ignore: true
+ update_cities_hash(cities)
+
+ Bus.import %i[number model], buses_array, on_duplicate_key_ignore: true
+ update_bus_hash(buses_array)
+
+ trips.map! do |trip|
+ [cities_hash[trip[0]], cities_hash[trip[1]], trip[2], trip[3], trip[4], buses_hash[trip[5]]]
+ end
+ Trip.import %i[from_id to_id start_time duration_minutes price_cents bus_id], trips
+
+ buses_services.map! { |buses_service| [buses_hash[buses_service.first], buses_service.last] }
+ BusesService.import %i[bus_id service_id], buses_services, on_duplicate_key_ignore: true
+
+ @buses_array = []
+ @trips = []
+ @cities = []
+ @buses_services = []
+ end
+ end
+
+ def update_bus_hash(buses_array)
+ buses_array.uniq.each do |bus|
+ key = "#{bus.first}-#{bus.last}"
+ buses_hash[*key] = Bus.find_by(number: bus.first, model: bus.last).id unless buses_hash[*key]
+ end
+ end
+
+ def update_cities_hash(cities)
+ cities.each do |city|
+ cities_hash[*city] = City.find_by(name: city).id unless cities_hash[*city]
+ end
+ end
+ end
+end
diff --git a/app/views/trips/_delimiter.html.erb b/app/views/trips/_delimiter.html.erb
deleted file mode 100644
index 3f845ad0..00000000
--- a/app/views/trips/_delimiter.html.erb
+++ /dev/null
@@ -1 +0,0 @@
-====================================================
diff --git a/app/views/trips/_services.html.erb b/app/views/trips/_services.html.erb
deleted file mode 100644
index 2de639fc..00000000
--- a/app/views/trips/_services.html.erb
+++ /dev/null
@@ -1,6 +0,0 @@
-
Сервисы в автобусе:
-
- <% services.each do |service| %>
- <%= render "service", service: service %>
- <% end %>
-
diff --git a/app/views/trips/_trip.html.erb b/app/views/trips/_trip.html.erb
index fa1de9aa..4f2243bd 100644
--- a/app/views/trips/_trip.html.erb
+++ b/app/views/trips/_trip.html.erb
@@ -1,5 +1,16 @@
+
- <%= "Отправление: #{trip.start_time}" %>
- <%= "Прибытие: #{(Time.parse(trip.start_time) + trip.duration_minutes.minutes).strftime('%H:%M')}" %>
- <%= "В пути: #{trip.duration_minutes / 60}ч. #{trip.duration_minutes % 60}мин." %>
- <%= "Цена: #{trip.price_cents / 100}р. #{trip.price_cents % 100}коп." %>
- <%= "Автобус: #{trip.bus.model} №#{trip.bus.number}" %>
+
+<% if trip.bus.services.present? %>
+ - Сервисы в автобусе:
+
+ <%= render partial: "service", collection: trip.bus.services %>
+
+<% end %>
+
+
+====================================================
diff --git a/app/views/trips/index.html.erb b/app/views/trips/index.html.erb
index a60bce41..44cb564b 100644
--- a/app/views/trips/index.html.erb
+++ b/app/views/trips/index.html.erb
@@ -2,15 +2,7 @@
<%= "Автобусы #{@from.name} – #{@to.name}" %>
- <%= "В расписании #{@trips.count} рейсов" %>
+ <%= "В расписании #{@trips.length} рейсов" %>
-<% @trips.each do |trip| %>
-
- <%= render "trip", trip: trip %>
- <% if trip.bus.services.present? %>
- <%= render "services", services: trip.bus.services %>
- <% end %>
-
- <%= render "delimiter" %>
-<% end %>
+<%= render :partial => "trip", :collection => @trips %>
diff --git a/case-study.md b/case-study.md
new file mode 100644
index 00000000..f4d907b1
--- /dev/null
+++ b/case-study.md
@@ -0,0 +1,60 @@
+## Задача 1: Импорт данных
+Нужно оптимизировать механизм перезагрузки расписания из файла так, чтобы он импортировал файл _large.json_ в БД posgresql в пределах минуты
+
+# Ход выполнения
+Сразу было видно, даже без профилировщиков, что под каждую запись в бд делается отдельный INSERT запрос - это занимает основную часть времени.
+Нужно применить подход с BATCH INSERT. Для этого использовался гем **activerecord-import**. Основная проблема была импортировать связи, т.к
+требуется внешний ключ, т.е знание id непосредственно на уровне бд. Чтобы не делать лишних SELECT запросов, использовал хранение id автобусов, городов и сервисов в хеше на уровне оперативной памяти (хеш обновлялся каждый раз при добавлении новых объектов в базу)
+
+Также добавлял индексы (в том числе и составные) на уникальность, т.к при BATCH INSERT возможно появления дубликатов и нужна валидация на уровне бд
+
+# Результат
+Для оценки скорости работы рейк таски использовал библиотеку Benchmark
+
+```
+bundle exec rake reload_json[fixtures/large.json]
+
+Total time is: 21.044087463000324
+```
+
+-> получилось добиться импорта файла _large.json_ за +- 20 секунд
+
+
+## Задача 2: Отображение расписаний
+Сами страницы расписаний тоже формируются не эффективно и при росте объёмов начинают сильно тормозить.
+
+Нужно найти и устранить проблемы, замедляющие формирование этих страниц
+
+Предварительно импортирован файл _large.json_
+
+# Ход выполнения
+Для оценки скорости работы загрузки страницы использовал гем **rack-mini-profiler**
+
+### Находка №1
+- видна проблема n+1 при прогрузке ассоциаций в index.html.erb `trip.bus.services`
+- Добавил `preload` в контроллер
+- рендер страницы ускорился с 5 секунд до 2.7 секунд
+
+### Находка №2
+- видна долгая загрузка partial _trip.html.erb и _service.html.erb
+- удалил конструкцию с `@trips.each` и переписал на более быстрый подход рендера с конструкцией `render partial: "trip", collection: @trips`
+- аналогично для сервисов: `<%= render partial: "service", collection: trip.bus.services %>`
+- рендер страницы ускорился с 2.7 секунд до 500мс
+
+### Находка №3
+- Делался лишний запрос `SELECT COUNT(*)` - исправил вызов `.count` на `.length` на @trips
+
+### Находка №4
+- Рендер вьюх всё ещё занимал основное время
+- Дропнул ненужную вьюху `_delimiter.html.erb` и просто вставил текст из неё в родительскую вьюху `_trip.html.erb`
+- рендер страницы ускорился с 500мс до 260мс
+
+# Результат
+Для оценки скорости смотрел девелопмент лог и вывод гема rack-mini-profiler
+
+```
+Completed 200 OK in 263ms (Views: 235.8ms | ActiveRecord: 25.0ms)
+
+```
+
+-> получилось добиться открытия страницы за 263мс
diff --git a/config/database.yml b/config/database.yml
index e116cfa6..a1658234 100644
--- a/config/database.yml
+++ b/config/database.yml
@@ -20,6 +20,8 @@ default: &default
# For details on connection pooling, see Rails configuration guide
# http://guides.rubyonrails.org/configuring.html#database-pooling
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
+ username: task-4_development
+ password: new_password
development:
<<: *default
diff --git a/db/migrate/20240514210140_add_uniqueness_constraint_to_bus.rb b/db/migrate/20240514210140_add_uniqueness_constraint_to_bus.rb
new file mode 100644
index 00000000..5738c8bb
--- /dev/null
+++ b/db/migrate/20240514210140_add_uniqueness_constraint_to_bus.rb
@@ -0,0 +1,5 @@
+class AddUniquenessConstraintToBus < ActiveRecord::Migration[5.2]
+ def change
+ add_index :buses, [:number, :model], unique: true
+ end
+end
diff --git a/db/migrate/20240514210534_add_uniqueness_constraint_to_services.rb b/db/migrate/20240514210534_add_uniqueness_constraint_to_services.rb
new file mode 100644
index 00000000..61a5e1f5
--- /dev/null
+++ b/db/migrate/20240514210534_add_uniqueness_constraint_to_services.rb
@@ -0,0 +1,5 @@
+class AddUniquenessConstraintToServices < ActiveRecord::Migration[5.2]
+ def change
+ add_index :services, :name, unique: true
+ end
+end
diff --git a/db/migrate/20240514210636_add_uniqueness_constraint_to_cities.rb b/db/migrate/20240514210636_add_uniqueness_constraint_to_cities.rb
new file mode 100644
index 00000000..48414cc7
--- /dev/null
+++ b/db/migrate/20240514210636_add_uniqueness_constraint_to_cities.rb
@@ -0,0 +1,5 @@
+class AddUniquenessConstraintToCities < ActiveRecord::Migration[5.2]
+ def change
+ add_index :cities, :name, unique: true
+ end
+end
diff --git a/db/migrate/20240515233732_add_uniqueness_constraint_to_buses_service.rb b/db/migrate/20240515233732_add_uniqueness_constraint_to_buses_service.rb
new file mode 100644
index 00000000..866a5ded
--- /dev/null
+++ b/db/migrate/20240515233732_add_uniqueness_constraint_to_buses_service.rb
@@ -0,0 +1,5 @@
+class AddUniquenessConstraintToBusesService < ActiveRecord::Migration[5.2]
+ def change
+ add_index :buses_services, [:bus_id, :service_id], unique: true
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index f6921e45..c76991f5 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2019_03_30_193044) do
+ActiveRecord::Schema.define(version: 2024_05_15_233732) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -18,19 +18,23 @@
create_table "buses", force: :cascade do |t|
t.string "number"
t.string "model"
+ t.index ["number", "model"], name: "index_buses_on_number_and_model", unique: true
end
create_table "buses_services", force: :cascade do |t|
t.integer "bus_id"
t.integer "service_id"
+ t.index ["bus_id", "service_id"], name: "index_buses_services_on_bus_id_and_service_id", unique: true
end
create_table "cities", force: :cascade do |t|
t.string "name"
+ t.index ["name"], name: "index_cities_on_name", unique: true
end
create_table "services", force: :cascade do |t|
t.string "name"
+ t.index ["name"], name: "index_services_on_name", unique: true
end
create_table "trips", force: :cascade do |t|
diff --git a/lib/tasks/utils.rake b/lib/tasks/utils.rake
index 540fe871..7d4804b8 100644
--- a/lib/tasks/utils.rake
+++ b/lib/tasks/utils.rake
@@ -1,34 +1,15 @@
-# Наивная загрузка данных из json-файла в БД
-# rake reload_json[fixtures/small.json]
task :reload_json, [:file_name] => :environment do |_task, args|
- json = JSON.parse(File.read(args.file_name))
-
- ActiveRecord::Base.transaction do
- City.delete_all
- Bus.delete_all
- Service.delete_all
- Trip.delete_all
- ActiveRecord::Base.connection.execute('delete from buses_services;')
+ time = Benchmark.realtime {
+ ActiveRecord::Base.transaction do
+ Service.delete_all
+ Bus.delete_all
+ Trip.delete_all
+ BusesService.delete_all
+ City.delete_all
+ end
- json.each do |trip|
- from = City.find_or_create_by(name: trip['from'])
- to = City.find_or_create_by(name: trip['to'])
- services = []
- trip['bus']['services'].each do |service|
- s = Service.find_or_create_by(name: service)
- services << s
- end
- bus = Bus.find_or_create_by(number: trip['bus']['number'])
- bus.update(model: trip['bus']['model'], services: services)
+ Tasks::ImportJsonData.call(filepath: args.file_name)
+ }
- Trip.create!(
- from: from,
- to: to,
- bus: bus,
- start_time: trip['start_time'],
- duration_minutes: trip['duration_minutes'],
- price_cents: trip['price_cents'],
- )
- end
- end
+ puts "Total time is: #{time}"
end