Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Elasticsearch without external dependencies #1

Open
wants to merge 1 commit into
base: before-elastic
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
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