typesense 0.1.1 → 0.5.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -3,89 +3,62 @@
3
3
  ##
4
4
  # These examples walk you through operations specifically related to search
5
5
 
6
- require_relative '../lib/typesense'
7
- require 'awesome_print'
8
-
9
- AwesomePrint.defaults = {
10
- indent: -2
11
- }
12
-
13
- ##
14
- # Setup
15
- #
16
- # Start the master
17
- # $ docker run -p 8108:8108 -it -v/tmp/typesense-data-master/:/data -it typesense/typesense:0.8.0-rc1 --data-dir /data --api-key=abcd --listen-port 8108
18
- #
19
- # Start the read replica
20
- # $ docker run -p 8109:8109 -it -v/tmp/typesense-data-read-replica-1/:/data -it typesense/typesense:0.8.0-rc1 --data-dir /data --api-key=wxyz --listen-port 8109 --master http://localhost:8108
21
-
22
- ##
23
- # Create a client
24
- typesense = Typesense::Client.new(
25
- master_node: {
26
- host: 'localhost',
27
- port: 8108,
28
- protocol: 'http',
29
- api_key: 'abcd'
30
- },
31
- read_replica_nodes: [
32
- {
33
- host: 'localhost',
34
- port: 8109,
35
- protocol: 'http',
36
- api_key: 'wxyz'
37
- }
38
- ],
39
- timeout_seconds: 10
40
- )
6
+ require_relative './client_initialization'
41
7
 
42
8
  ##
43
9
  # Create a collection
44
10
  schema = {
45
- 'name' => 'companies',
46
- 'fields' => [
11
+ 'name' => 'companies',
12
+ 'fields' => [
47
13
  {
48
14
  'name' => 'company_name',
49
15
  'type' => 'string'
50
16
  },
51
17
  {
52
- 'name' => 'num_employees',
53
- 'type' => 'int32'
18
+ 'name' => 'num_employees',
19
+ 'type' => 'int32'
54
20
  },
55
21
  {
56
- 'name' => 'country',
57
- 'type' => 'string',
22
+ 'name' => 'country',
23
+ 'type' => 'string',
58
24
  'facet' => true
59
25
  }
60
26
  ],
61
27
  'default_sorting_field' => 'num_employees'
62
28
  }
63
29
 
64
- typesense.collections.create(schema)
30
+ # Delete the collection if it already exists
31
+ begin
32
+ @typesense.collections['companies'].delete
33
+ rescue Typesense::Error::ObjectNotFound
34
+ end
35
+
36
+ # Now create the collection
37
+ @typesense.collections.create(schema)
65
38
 
66
39
  # Let's create a couple documents for us to use in our search examples
67
- typesense.collections['companies'].documents.create(
40
+ @typesense.collections['companies'].documents.create(
68
41
  'id' => '124',
69
42
  'company_name' => 'Stark Industries',
70
43
  'num_employees' => 5215,
71
44
  'country' => 'USA'
72
45
  )
73
46
 
74
- typesense.collections['companies'].documents.create(
47
+ @typesense.collections['companies'].documents.create(
75
48
  'id' => '127',
76
49
  'company_name' => 'Stark Corp',
77
50
  'num_employees' => 1031,
78
51
  'country' => 'USA'
79
52
  )
80
53
 
81
- typesense.collections['companies'].documents.create(
54
+ @typesense.collections['companies'].documents.create(
82
55
  'id' => '125',
83
56
  'company_name' => 'Acme Corp',
84
57
  'num_employees' => 1002,
85
58
  'country' => 'France'
86
59
  )
87
60
 
88
- typesense.collections['companies'].documents.create(
61
+ @typesense.collections['companies'].documents.create(
89
62
  'id' => '126',
90
63
  'company_name' => 'Doofenshmirtz Inc',
91
64
  'num_employees' => 2,
@@ -94,7 +67,7 @@ typesense.collections['companies'].documents.create(
94
67
 
95
68
  ##
96
69
  # Search for documents
97
- results = typesense.collections['companies'].documents.search(
70
+ results = @typesense.collections['companies'].documents.search(
98
71
  'q' => 'Stark',
99
72
  'query_by' => 'company_name'
100
73
  )
@@ -133,11 +106,11 @@ ap results
133
106
 
134
107
  ##
135
108
  # Search for more documents
136
- results = typesense.collections['companies'].documents.search(
109
+ results = @typesense.collections['companies'].documents.search(
137
110
  'q' => 'Inc',
138
- 'query_by' => 'company_name',
111
+ 'query_by' => 'company_name',
139
112
  'filter_by' => 'num_employees:<100',
140
- 'sort_by' => 'num_employees:desc'
113
+ 'sort_by' => 'num_employees:desc'
141
114
  )
142
115
  ap results
143
116
 
@@ -163,8 +136,8 @@ ap results
163
136
 
164
137
  ##
165
138
  # Search for more documents
166
- results = typesense.collections['companies'].documents.search(
167
- 'q' => 'Non-existent',
139
+ results = @typesense.collections['companies'].documents.search(
140
+ 'q' => 'Non-existent',
168
141
  'query_by' => 'company_name'
169
142
  )
170
143
  ap results
@@ -179,4 +152,4 @@ ap results
179
152
  ##
180
153
  # Cleanup
181
154
  # Drop the collection
182
- typesense.collections['companies'].delete
155
+ @typesense.collections['companies'].delete
@@ -11,5 +11,13 @@ require_relative 'typesense/collections'
11
11
  require_relative 'typesense/collection'
12
12
  require_relative 'typesense/documents'
13
13
  require_relative 'typesense/document'
14
+ require_relative 'typesense/overrides'
15
+ require_relative 'typesense/override'
16
+ require_relative 'typesense/aliases'
17
+ require_relative 'typesense/alias'
18
+ require_relative 'typesense/keys'
19
+ require_relative 'typesense/key'
14
20
  require_relative 'typesense/debug'
21
+ require_relative 'typesense/health'
22
+ require_relative 'typesense/metrics'
15
23
  require_relative 'typesense/error'
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesense
4
+ class Alias
5
+ def initialize(name, api_call)
6
+ @name = name
7
+ @api_call = api_call
8
+ end
9
+
10
+ def retrieve
11
+ @api_call.get(endpoint_path)
12
+ end
13
+
14
+ def delete
15
+ @api_call.delete(endpoint_path)
16
+ end
17
+
18
+ private
19
+
20
+ def endpoint_path
21
+ "#{Aliases::RESOURCE_PATH}/#{@name}"
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Typesense
4
+ class Aliases
5
+ RESOURCE_PATH = '/aliases'
6
+
7
+ def initialize(api_call)
8
+ @api_call = api_call
9
+ @aliases = {}
10
+ end
11
+
12
+ def upsert(alias_name, mapping)
13
+ @api_call.put(endpoint_path(alias_name), mapping)
14
+ end
15
+
16
+ def retrieve
17
+ @api_call.get(RESOURCE_PATH)
18
+ end
19
+
20
+ def [](alias_name)
21
+ @aliases[alias_name] ||= Alias.new(alias_name, @api_call)
22
+ end
23
+
24
+ private
25
+
26
+ def endpoint_path(alias_name)
27
+ "#{Aliases::RESOURCE_PATH}/#{alias_name}"
28
+ end
29
+ end
30
+ end
@@ -10,106 +10,223 @@ module Typesense
10
10
 
11
11
  def initialize(configuration)
12
12
  @configuration = configuration
13
+
14
+ @api_key = @configuration.api_key
15
+ @nodes = @configuration.nodes.dup # Make a copy, since we'll be adding additional metadata to the nodes
16
+ @nearest_node = @configuration.nearest_node.dup
17
+ @connection_timeout_seconds = @configuration.connection_timeout_seconds
18
+ @healthcheck_interval_seconds = @configuration.healthcheck_interval_seconds
19
+ @num_retries_per_request = @configuration.num_retries
20
+ @retry_interval_seconds = @configuration.retry_interval_seconds
21
+
22
+ @logger = @configuration.logger
23
+
24
+ initialize_metadata_for_nodes
25
+ @current_node_index = -1
13
26
  end
14
27
 
15
28
  def post(endpoint, parameters = {})
16
- perform_with_error_handling(:do_not_use_read_replicas) do
17
- self.class.post(uri_for(endpoint),
18
- default_options.merge(
19
- body: parameters.to_json,
20
- headers: default_headers.merge('Content-Type' => 'application/json')
21
- ))
22
- end.parsed_response
29
+ headers, body = extract_headers_and_body_from(parameters)
30
+
31
+ perform_request :post,
32
+ endpoint,
33
+ body: body,
34
+ headers: default_headers.merge(headers)
23
35
  end
24
36
 
25
- def get(endpoint, parameters = {})
26
- get_unparsed_response(endpoint, parameters).parsed_response
37
+ def put(endpoint, parameters = {})
38
+ headers, body = extract_headers_and_body_from(parameters)
39
+
40
+ perform_request :put,
41
+ endpoint,
42
+ body: body,
43
+ headers: default_headers.merge(headers)
27
44
  end
28
45
 
29
- def get_unparsed_response(endpoint, parameters = {})
30
- perform_with_error_handling(:use_read_replicas) do |node, node_index|
31
- self.class.get(uri_for(endpoint, node, node_index),
32
- default_options.merge(
33
- query: parameters,
34
- headers: default_headers
35
- ))
36
- end
46
+ def get(endpoint, parameters = {})
47
+ headers, query = extract_headers_and_query_from(parameters)
48
+
49
+ perform_request :get,
50
+ endpoint,
51
+ query: query,
52
+ headers: default_headers.merge(headers)
37
53
  end
38
54
 
39
55
  def delete(endpoint, parameters = {})
40
- perform_with_error_handling(:do_not_use_read_replicas) do
41
- self.class.delete(uri_for(endpoint),
42
- default_options.merge(
43
- query: parameters,
44
- headers: default_headers
45
- ))
46
- end.parsed_response
56
+ headers, query = extract_headers_and_query_from(parameters)
57
+
58
+ perform_request :delete,
59
+ endpoint,
60
+ query: query,
61
+ headers: default_headers.merge(headers)
62
+ end
63
+
64
+ def perform_request(method, endpoint, options = {})
65
+ @configuration.validate!
66
+ last_exception = nil
67
+ @logger.debug "Performing #{method.to_s.upcase} request: #{endpoint}"
68
+ (1..(@num_retries_per_request + 1)).each do |num_tries|
69
+ node = next_node
70
+
71
+ @logger.debug "Attempting #{method.to_s.upcase} request Try ##{num_tries} to Node #{node[:index]}"
72
+
73
+ begin
74
+ response_object = self.class.send(method,
75
+ uri_for(endpoint, node),
76
+ default_options.merge(options))
77
+ response_code = response_object.response.code.to_i
78
+ set_node_healthcheck(node, is_healthy: true) if response_code >= 1 && response_code <= 499
79
+
80
+ @logger.debug "Request to Node #{node[:index]} was successfully made (at the network layer). Response Code was #{response_code}."
81
+
82
+ # If response is 2xx return the object, else raise the response as an exception
83
+ return response_object.parsed_response if response_object.response.code_type <= Net::HTTPSuccess # 2xx
84
+
85
+ raise custom_exception_klass_for(response_object.response), response_object.parsed_response['message'] || 'Error message not available'
86
+ rescue Net::ReadTimeout, Net::OpenTimeout,
87
+ EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError,
88
+ Errno::EINVAL, Errno::ENETDOWN, Errno::ENETUNREACH, Errno::ENETRESET, Errno::ECONNABORTED, Errno::ECONNRESET,
89
+ Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTDOWN, Errno::EHOSTUNREACH,
90
+ Timeout::Error, HTTParty::ResponseError, Typesense::Error::ServerError, Typesense::Error::HTTPStatus0Error => e
91
+ # Rescue network layer exceptions and HTTP 5xx errors, so the loop can continue.
92
+ # Using loops for retries instead of rescue...retry to maintain consistency with client libraries in
93
+ # other languages that might not support the same construct.
94
+ set_node_healthcheck(node, is_healthy: false)
95
+ last_exception = e
96
+ @logger.warn "Request to Node #{node[:index]} failed due to \"#{e.class}: #{e.message}\""
97
+ @logger.warn "Sleeping for #{@retry_interval_seconds}s and then retrying request..."
98
+ sleep @retry_interval_seconds
99
+ end
100
+ end
101
+ @logger.debug "No retries left. Raising last error \"#{last_exception.class}: #{last_exception.message}\"..."
102
+ raise last_exception
47
103
  end
48
104
 
49
105
  private
50
106
 
51
- def uri_for(endpoint, node = :master, node_index = 0)
52
- if node == :read_replica
53
- "#{@configuration.read_replica_nodes[node_index][:protocol]}://#{@configuration.read_replica_nodes[node_index][:host]}:#{@configuration.read_replica_nodes[node_index][:port]}#{endpoint}"
107
+ def extract_headers_and_body_from(parameters)
108
+ if json_request?(parameters)
109
+ headers = { 'Content-Type' => 'application/json' }
110
+ body = sanitize_parameters(parameters).to_json
54
111
  else
55
- "#{@configuration.master_node[:protocol]}://#{@configuration.master_node[:host]}:#{@configuration.master_node[:port]}#{endpoint}"
112
+ headers = {}
113
+ body = parameters[:body]
56
114
  end
115
+ [headers, body]
57
116
  end
58
117
 
59
- def perform_with_error_handling(use_read_replicas = :do_not_use_read_replicas)
60
- @configuration.validate!
118
+ def extract_headers_and_query_from(parameters)
119
+ if json_request?(parameters)
120
+ headers = { 'Content-Type' => 'application/json' }
121
+ query = sanitize_parameters(parameters)
122
+ else
123
+ headers = {}
124
+ query = parameters[:query]
125
+ end
126
+ [headers, query]
127
+ end
128
+
129
+ def json_request?(parameters)
130
+ parameters[:as_json].nil? ? true : parameters[:as_json]
131
+ end
61
132
 
62
- node = :master
63
- node_index = -1
64
-
65
- begin
66
- response_object = yield node, node_index
67
-
68
- return response_object if response_object.response.code_type <= Net::HTTPSuccess # 2xx
69
-
70
- error_klass = if response_object.response.code_type <= Net::HTTPBadRequest # 400
71
- Error::RequestMalformed
72
- elsif response_object.response.code_type <= Net::HTTPUnauthorized # 401
73
- Error::RequestUnauthorized
74
- elsif response_object.response.code_type <= Net::HTTPNotFound # 404
75
- Error::ObjectNotFound
76
- elsif response_object.response.code_type <= Net::HTTPConflict # 409
77
- Error::ObjectAlreadyExists
78
- elsif response_object.response.code_type <= Net::HTTPUnprocessableEntity # 422
79
- Error::ObjectUnprocessable
80
- elsif response_object.response.code_type <= Net::HTTPServerError # 5xx
81
- Error::ServerError
82
- else
83
- Error
84
- end
85
-
86
- raise error_klass, response_object.parsed_response['message']
87
- rescue Net::ReadTimeout, Net::OpenTimeout,
88
- EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError,
89
- Errno::EINVAL, Errno::ENETDOWN, Errno::ENETUNREACH, Errno::ENETRESET, Errno::ECONNABORTED, Errno::ECONNRESET,
90
- Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTDOWN, Errno::EHOSTUNREACH,
91
- Timeout::Error, Error::ServerError, HTTParty::ResponseError
92
- if (use_read_replicas == :use_read_replicas || use_read_replicas == true) &&
93
- !@configuration.read_replica_nodes.nil?
94
- node = :read_replica
95
- node_index += 1
96
-
97
- retry unless @configuration.read_replica_nodes[node_index].nil?
133
+ def sanitize_parameters(parameters)
134
+ sanitized_parameters = parameters.dup
135
+ sanitized_parameters.delete(:as_json)
136
+ sanitized_parameters.delete(:body)
137
+ sanitized_parameters.delete(:query)
138
+
139
+ sanitized_parameters
140
+ end
141
+
142
+ def uri_for(endpoint, node)
143
+ "#{node[:protocol]}://#{node[:host]}:#{node[:port]}#{endpoint}"
144
+ end
145
+
146
+ ## Attempts to find the next healthy node, looping through the list of nodes once.
147
+ # But if no healthy nodes are found, it will just return the next node, even if it's unhealthy
148
+ # so we can try the request for good measure, in case that node has become healthy since
149
+ def next_node
150
+ # Check if nearest_node is set and is healthy, if so return it
151
+ unless @nearest_node.nil?
152
+ @logger.debug "Nodes health: Node #{@nearest_node[:index]} is #{@nearest_node[:is_healthy] == true ? 'Healthy' : 'Unhealthy'}"
153
+ if @nearest_node[:is_healthy] == true || node_due_for_healthcheck?(@nearest_node)
154
+ @logger.debug "Updated current node to Node #{@nearest_node[:index]}"
155
+ return @nearest_node
98
156
  end
157
+ @logger.debug 'Falling back to individual nodes'
158
+ end
159
+
160
+ # Fallback to nodes as usual
161
+ @logger.debug "Nodes health: #{@nodes.each_with_index.map { |node, i| "Node #{i} is #{node[:is_healthy] == true ? 'Healthy' : 'Unhealthy'}" }.join(' || ')}"
162
+ candidate_node = nil
163
+ (0..@nodes.length).each do |_i|
164
+ @current_node_index = (@current_node_index + 1) % @nodes.length
165
+ candidate_node = @nodes[@current_node_index]
166
+ if candidate_node[:is_healthy] == true || node_due_for_healthcheck?(candidate_node)
167
+ @logger.debug "Updated current node to Node #{candidate_node[:index]}"
168
+ return candidate_node
169
+ end
170
+ end
171
+
172
+ # None of the nodes are marked healthy, but some of them could have become healthy since last health check.
173
+ # So we will just return the next node.
174
+ @logger.debug "No healthy nodes were found. Returning the next node, Node #{candidate_node[:index]}"
175
+ candidate_node
176
+ end
177
+
178
+ def node_due_for_healthcheck?(node)
179
+ is_due_for_check = Time.now.to_i - node[:last_access_timestamp] > @healthcheck_interval_seconds
180
+ @logger.debug "Node #{node[:index]} has exceeded healthcheck_interval_seconds of #{@healthcheck_interval_seconds}. Adding it back into rotation." if is_due_for_check
181
+ is_due_for_check
182
+ end
183
+
184
+ def initialize_metadata_for_nodes
185
+ unless @nearest_node.nil?
186
+ @nearest_node[:index] = 'nearest_node'
187
+ set_node_healthcheck(@nearest_node, is_healthy: true)
188
+ end
189
+ @nodes.each_with_index do |node, index|
190
+ node[:index] = index
191
+ set_node_healthcheck(node, is_healthy: true)
192
+ end
193
+ end
99
194
 
100
- raise
195
+ def set_node_healthcheck(node, is_healthy:)
196
+ node[:is_healthy] = is_healthy
197
+ node[:last_access_timestamp] = Time.now.to_i
198
+ end
199
+
200
+ def custom_exception_klass_for(response)
201
+ response_code_type = response.code_type
202
+ if response_code_type <= Net::HTTPBadRequest # 400
203
+ Typesense::Error::RequestMalformed
204
+ elsif response_code_type <= Net::HTTPUnauthorized # 401
205
+ Typesense::Error::RequestUnauthorized
206
+ elsif response_code_type <= Net::HTTPNotFound # 404
207
+ Typesense::Error::ObjectNotFound
208
+ elsif response_code_type <= Net::HTTPConflict # 409
209
+ Typesense::Error::ObjectAlreadyExists
210
+ elsif response_code_type <= Net::HTTPUnprocessableEntity # 422
211
+ Typesense::Error::ObjectUnprocessable
212
+ elsif response_code_type <= Net::HTTPServerError # 5xx
213
+ Typesense::Error::ServerError
214
+ elsif response.code.to_i.zero?
215
+ Typesense::Error::HTTPStatus0Error
216
+ else
217
+ Typesense::Error::HTTPError
101
218
  end
102
219
  end
103
220
 
104
221
  def default_options
105
222
  {
106
- timeout: @configuration.timeout_seconds
223
+ timeout: @connection_timeout_seconds
107
224
  }
108
225
  end
109
226
 
110
227
  def default_headers
111
228
  {
112
- API_KEY_HEADER_NAME.to_s => @configuration.master_node[:api_key]
229
+ API_KEY_HEADER_NAME.to_s => @api_key
113
230
  }
114
231
  end
115
232
  end