typesense 0.1.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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