Skip to content

Commit

Permalink
DIY Elasticsearch
Browse files Browse the repository at this point in the history
  • Loading branch information
dbackeus committed May 3, 2023
1 parent aad6aed commit 3826c87
Show file tree
Hide file tree
Showing 10 changed files with 208 additions and 1 deletion.
5 changes: 5 additions & 0 deletions app/controllers/search_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class SearchController < ApplicationController
def index
@results = Elasticsearch.search(params[:query])
end
end
5 changes: 5 additions & 0 deletions app/jobs/elasticsearch_delete_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class ElasticsearchDeleteJob < ApplicationJob
def perform(elasticsearch_id)
Elasticsearch.delete(elasticsearch_id)
end
end
8 changes: 8 additions & 0 deletions app/jobs/elasticsearch_index_job.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class ElasticsearchIndexJob < ApplicationJob
def perform(klass, id)
model = klass.constantize.find_by_id(id)
return unless model

Elasticsearch.index(model)
end
end
30 changes: 30 additions & 0 deletions app/models/concerns/elasticsearchable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module Elasticsearchable
extend ActiveSupport::Concern

included do
after_commit on: %i[create update], if: :should_index? do
ElasticsearchIndexJob.perform_later(self.class.name, id)
end

after_commit on: :destroy do
ElasticsearchDeleteJob.perform_later(elasticsearch_id)
end
end

# Override this method to control when the model should be indexed
def should_index?
true
end

def elasticsearch_id
"#{self.class.name}-#{id}"
end

def elasticsearch_title
title
end

def elasticsearch_content
raise NotImplementedError
end
end
125 changes: 125 additions & 0 deletions app/models/elasticsearch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
module Elasticsearch
INDEX = "searchables".freeze

def self.index(active_record_instance)
connection_pool.with do |client|
client.index(
INDEX,
active_record_instance.elasticsearch_id,
title: active_record_instance.elasticsearch_title,
content: active_record_instance.elasticsearch_content,
updated_at: active_record_instance.updated_at,
created_at: active_record_instance.created_at,
)
end
end

def self.delete(id)
connection_pool.with do |client|
client.delete(INDEX, id)
end
end

def self.search(query)
return [] if query.blank?

result = connection_pool.with do |client|
client.search(
INDEX,
_source: false,
stored_fields: %w[_id],
query: {
multi_match: {
query: query,
fields: %w[title^2 content],
},
},
)
end

ids = result.fetch(:hits).fetch(:hits).map { |hit| hit.fetch(:_id) }

activerecord_class_and_ids =
ids.each_with_object({}) do |id, hash|
klass, id = id.split("-")
hash[klass] ||= []
hash[klass] << id
end

instances = activerecord_class_and_ids.flat_map do |klass, ids|
klass.constantize.where(id: ids)
end

instances.sort_by do |instance|
ids.index(instance.elasticsearch_id)
end
end

def self.connection_pool
@connection_pool ||= ConnectionPool.new(size: (ENV["RAILS_MAX_THREADS"] || 5).to_i, timeout: 5) do
Client.new
end
end

class Client
HttpError = Class.new(StandardError)

REQUEST_METHOD_TO_CLASS = {
get: Net::HTTP::Get,
post: Net::HTTP::Post,
put: Net::HTTP::Put,
delete: Net::HTTP::Delete,
}.freeze

def initialize
@url = ENV["ELASTICSEARCH_URL"] || "http://localhost:9200"
end

# https://www.elastic.co/guide/en/elasticsearch/reference/7.17/docs-index_.html#docs-index-api-request
def index(index, id, document)
request(:put, "#{index}/_doc/#{id}", document)
end

# https://www.elastic.co/guide/en/elasticsearch/reference/7.17/docs-delete.html#docs-delete-api-request
def delete(index, id)
request(:delete, "#{index}/_doc/#{id}")
end

# Search API reference:
# https://www.elastic.co/guide/en/elasticsearch/reference/7.17/search-search.html#search-search
# Query body reference:
# https://www.elastic.co/guide/en/elasticsearch/reference/7.17/search-search.html#search-search-api-request-body
def search(index, query)
request(:get, "#{index}/_search", query)
end

def request(method, path, params = nil)
uri = URI("#{@url}/#{path}")

request = REQUEST_METHOD_TO_CLASS.fetch(method).new(uri)
request.content_type = "application/json"
request.body = params&.to_json

Rails.logger.debug "[Elasticsearch/request] #{request.method} #{request.uri} #{request.body}" if Rails.logger.debug?

response = connection.request(request)

Rails.logger.debug "[Elasticsearch/response] #{response.code}, body: #{response.body}" if Rails.logger.debug?

raise HttpError, "status: #{response.code}, body: #{response.body}" unless response.is_a?(Net::HTTPSuccess)

JSON.parse(response.body, symbolize_names: true) if response.body.present?
end

private

def connection
@connection ||= begin
uri = URI.parse(@url)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http
end
end
end
end
10 changes: 10 additions & 0 deletions app/models/link.rb
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
class Link < ApplicationRecord
include Elasticsearchable

validates_presence_of :url
validate :validate_format_of_url
validates_inclusion_of :state, in: %w[pending success error]

after_create_commit :enqueue_crawl_job
after_update_commit -> { broadcast_replace_later_to "links", target: "link_#{id}" }

def elasticsearch_content
description
end

private

def should_index?
state == "success"
end

def enqueue_crawl_job
CrawlLinkJob.perform_later(id)
end
Expand Down
6 changes: 6 additions & 0 deletions app/models/post.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
class Post < ApplicationRecord
include Elasticsearchable

validates_presence_of :title
validates_presence_of :body

def elasticsearch_content
body
end
end
1 change: 1 addition & 0 deletions app/views/layouts/application.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<nav style="font-size: 1.5rem;">
<%= link_to "Posts", posts_path %>
<%= link_to "Links", links_path %>
<%= link_to "Search", search_path %>
</nav>
<%= yield %>
</body>
Expand Down
16 changes: 16 additions & 0 deletions app/views/search/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<h1>Search</h1>

<%= form_with url: search_path, method: :get do |form| %>
<%= form.label :query %>
<%= form.text_field :query, value: params[:query], autofocus: true %>
<%= form.submit "Search" %>
<% end %>
<% if @results.present? %>
<h2>Results</h2>
<ul>
<% @results.each do |result| %>
<li><%= link_to result.title, result %></li>
<% end %>
</ul>
<% end %>
3 changes: 2 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
Rails.application.routes.draw do
resources :links
resources :posts
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html

get "search" => "search#index", as: :search

# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
Expand Down

0 comments on commit 3826c87

Please sign in to comment.