typesense 0.2.0 → 0.4.0

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,30 +3,9 @@
3
3
  ##
4
4
  # These examples walk you through operations specifically related to overrides
5
5
  # This is a Typesense Premium feature (see: https://typesense.org/premium)
6
+ # Be sure to add `--license-key=<>` as a parameter, when starting a Typesense Premium server
6
7
 
7
- require_relative '../lib/typesense'
8
- require 'awesome_print'
9
-
10
- AwesomePrint.defaults = {
11
- indent: -2
12
- }
13
-
14
- ##
15
- # Setup
16
- #
17
- # Start the master
18
- # $ 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 --license-key=<>
19
-
20
- ##
21
- # Create a client
22
- typesense = Typesense::Client.new(
23
- master_node: {
24
- host: 'localhost',
25
- port: 8108,
26
- protocol: 'http',
27
- api_key: 'abcd'
28
- }
29
- )
8
+ require_relative './client_initialization'
30
9
 
31
10
  ##
32
11
  # Create a collection
@@ -50,31 +29,31 @@ schema = {
50
29
  'default_sorting_field' => 'num_employees'
51
30
  }
52
31
 
53
- typesense.collections.create(schema)
32
+ @typesense.collections.create(schema)
54
33
 
55
34
  # Let's create a couple documents for us to use in our search examples
56
- typesense.collections['companies'].documents.create(
35
+ @typesense.collections['companies'].documents.create(
57
36
  'id' => '124',
58
37
  'company_name' => 'Stark Industries',
59
38
  'num_employees' => 5215,
60
39
  'country' => 'USA'
61
40
  )
62
41
 
63
- typesense.collections['companies'].documents.create(
42
+ @typesense.collections['companies'].documents.create(
64
43
  'id' => '127',
65
44
  'company_name' => 'Stark Corp',
66
45
  'num_employees' => 1031,
67
46
  'country' => 'USA'
68
47
  )
69
48
 
70
- typesense.collections['companies'].documents.create(
49
+ @typesense.collections['companies'].documents.create(
71
50
  'id' => '125',
72
51
  'company_name' => 'Acme Corp',
73
52
  'num_employees' => 1002,
74
53
  'country' => 'France'
75
54
  )
76
55
 
77
- typesense.collections['companies'].documents.create(
56
+ @typesense.collections['companies'].documents.create(
78
57
  'id' => '126',
79
58
  'company_name' => 'Doofenshmirtz Inc',
80
59
  'num_employees' => 2,
@@ -84,7 +63,7 @@ typesense.collections['companies'].documents.create(
84
63
  ##
85
64
  # Create overrides
86
65
 
87
- typesense.collections['companies'].overrides.create(
66
+ @typesense.collections['companies'].overrides.create(
88
67
  "id": 'promote-doofenshmirtz',
89
68
  "rule": {
90
69
  "query": 'doofen',
@@ -92,7 +71,7 @@ typesense.collections['companies'].overrides.create(
92
71
  },
93
72
  "includes": [{ 'id' => '126', 'position' => 1 }]
94
73
  )
95
- typesense.collections['companies'].overrides.create(
74
+ @typesense.collections['companies'].overrides.create(
96
75
  "id": 'promote-acme',
97
76
  "rule": {
98
77
  "query": 'stark',
@@ -103,19 +82,19 @@ typesense.collections['companies'].overrides.create(
103
82
 
104
83
  ##
105
84
  # Search for documents
106
- results = typesense.collections['companies'].documents.search(
85
+ results = @typesense.collections['companies'].documents.search(
107
86
  'q' => 'doofen',
108
87
  'query_by' => 'company_name'
109
88
  )
110
89
  ap results
111
90
 
112
- results = typesense.collections['companies'].documents.search(
91
+ results = @typesense.collections['companies'].documents.search(
113
92
  'q' => 'stark',
114
93
  'query_by' => 'company_name'
115
94
  )
116
95
  ap results
117
96
 
118
- results = typesense.collections['companies'].documents.search(
97
+ results = @typesense.collections['companies'].documents.search(
119
98
  'q' => 'Inc',
120
99
  'query_by' => 'company_name',
121
100
  'filter_by' => 'num_employees:<100',
@@ -126,4 +105,4 @@ ap results
126
105
  ##
127
106
  # Cleanup
128
107
  # Drop the collection
129
- typesense.collections['companies'].delete
108
+ @typesense.collections['companies'].delete
data/examples/search.rb CHANGED
@@ -3,41 +3,7 @@
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
@@ -61,31 +27,38 @@ schema = {
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,7 +106,7 @@ 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
111
  'query_by' => 'company_name',
139
112
  'filter_by' => 'num_employees:<100',
@@ -163,7 +136,7 @@ ap results
163
136
 
164
137
  ##
165
138
  # Search for more documents
166
- results = typesense.collections['companies'].documents.search(
139
+ results = @typesense.collections['companies'].documents.search(
167
140
  'q' => 'Non-existent',
168
141
  'query_by' => 'company_name'
169
142
  )
@@ -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
@@ -2,17 +2,17 @@
2
2
 
3
3
  module Typesense
4
4
  class Alias
5
- def initialize(configuration, name)
6
- @configuration = configuration
7
- @name = name
5
+ def initialize(name, api_call)
6
+ @name = name
7
+ @api_call = api_call
8
8
  end
9
9
 
10
10
  def retrieve
11
- ApiCall.new(@configuration).get(endpoint_path)
11
+ @api_call.get(endpoint_path)
12
12
  end
13
13
 
14
14
  def delete
15
- ApiCall.new(@configuration).delete(endpoint_path)
15
+ @api_call.delete(endpoint_path)
16
16
  end
17
17
 
18
18
  private
@@ -4,21 +4,21 @@ module Typesense
4
4
  class Aliases
5
5
  RESOURCE_PATH = '/aliases'
6
6
 
7
- def initialize(configuration)
8
- @configuration = configuration
7
+ def initialize(api_call)
8
+ @api_call = api_call
9
9
  @aliases = {}
10
10
  end
11
11
 
12
12
  def upsert(alias_name, mapping)
13
- ApiCall.new(@configuration).put(endpoint_path(alias_name), mapping)
13
+ @api_call.put(endpoint_path(alias_name), mapping)
14
14
  end
15
15
 
16
16
  def retrieve
17
- ApiCall.new(@configuration).get(RESOURCE_PATH)
17
+ @api_call.get(RESOURCE_PATH)
18
18
  end
19
19
 
20
20
  def [](alias_name)
21
- @aliases[alias_name] ||= Alias.new(@configuration, alias_name)
21
+ @aliases[alias_name] ||= Alias.new(alias_name, @api_call)
22
22
  end
23
23
 
24
24
  private
@@ -10,116 +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
37
  def put(endpoint, parameters = {})
26
- perform_with_error_handling(:do_not_use_read_replicas) do
27
- self.class.put(uri_for(endpoint),
28
- default_options.merge(
29
- body: parameters.to_json,
30
- headers: default_headers.merge('Content-Type' => 'application/json')
31
- ))
32
- end.parsed_response
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)
33
44
  end
34
45
 
35
46
  def get(endpoint, parameters = {})
36
- get_unparsed_response(endpoint, parameters).parsed_response
37
- end
47
+ headers, query = extract_headers_and_query_from(parameters)
38
48
 
39
- def get_unparsed_response(endpoint, parameters = {})
40
- perform_with_error_handling(:use_read_replicas) do |node, node_index|
41
- self.class.get(uri_for(endpoint, node, node_index),
42
- default_options.merge(
43
- query: parameters,
44
- headers: default_headers
45
- ))
46
- end
49
+ perform_request :get,
50
+ endpoint,
51
+ query: query,
52
+ headers: default_headers.merge(headers)
47
53
  end
48
54
 
49
55
  def delete(endpoint, parameters = {})
50
- perform_with_error_handling(:do_not_use_read_replicas) do
51
- self.class.delete(uri_for(endpoint),
52
- default_options.merge(
53
- query: parameters,
54
- headers: default_headers
55
- ))
56
- 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
57
103
  end
58
104
 
59
105
  private
60
106
 
61
- def uri_for(endpoint, node = :master, node_index = 0)
62
- if node == :read_replica
63
- "#{@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
64
111
  else
65
- "#{@configuration.master_node[:protocol]}://#{@configuration.master_node[:host]}:#{@configuration.master_node[:port]}#{endpoint}"
112
+ headers = {}
113
+ body = parameters[:body]
66
114
  end
115
+ [headers, body]
67
116
  end
68
117
 
69
- def perform_with_error_handling(use_read_replicas = :do_not_use_read_replicas)
70
- @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
132
+
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)
71
138
 
72
- node = :master
73
- node_index = -1
74
-
75
- begin
76
- response_object = yield node, node_index
77
-
78
- return response_object if response_object.response.code_type <= Net::HTTPSuccess # 2xx
79
-
80
- error_klass = if response_object.response.code_type <= Net::HTTPBadRequest # 400
81
- Error::RequestMalformed
82
- elsif response_object.response.code_type <= Net::HTTPUnauthorized # 401
83
- Error::RequestUnauthorized
84
- elsif response_object.response.code_type <= Net::HTTPNotFound # 404
85
- Error::ObjectNotFound
86
- elsif response_object.response.code_type <= Net::HTTPConflict # 409
87
- Error::ObjectAlreadyExists
88
- elsif response_object.response.code_type <= Net::HTTPUnprocessableEntity # 422
89
- Error::ObjectUnprocessable
90
- elsif response_object.response.code_type <= Net::HTTPServerError # 5xx
91
- Error::ServerError
92
- else
93
- Error
94
- end
95
-
96
- raise error_klass, response_object.parsed_response['message']
97
- rescue Net::ReadTimeout, Net::OpenTimeout,
98
- EOFError, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError,
99
- Errno::EINVAL, Errno::ENETDOWN, Errno::ENETUNREACH, Errno::ENETRESET, Errno::ECONNABORTED, Errno::ECONNRESET,
100
- Errno::ETIMEDOUT, Errno::ECONNREFUSED, Errno::EHOSTDOWN, Errno::EHOSTUNREACH,
101
- Timeout::Error, Error::ServerError, HTTParty::ResponseError
102
- if ([:use_read_replicas, true].include? use_read_replicas) &&
103
- !@configuration.read_replica_nodes.nil?
104
- node = :read_replica
105
- node_index += 1
106
-
107
- retry unless @configuration.read_replica_nodes[node_index].nil?
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
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
108
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
109
177
 
110
- raise
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
194
+
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
111
218
  end
112
219
  end
113
220
 
114
221
  def default_options
115
222
  {
116
- timeout: @configuration.timeout_seconds
223
+ timeout: @connection_timeout_seconds
117
224
  }
118
225
  end
119
226
 
120
227
  def default_headers
121
228
  {
122
- API_KEY_HEADER_NAME.to_s => @configuration.master_node[:api_key]
229
+ API_KEY_HEADER_NAME.to_s => @api_key
123
230
  }
124
231
  end
125
232
  end