whi-cassie 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/cassie.rb ADDED
@@ -0,0 +1,317 @@
1
+ require 'cassandra'
2
+
3
+ # This class provides a lightweight wrapper around the Cassandra driver. It provides
4
+ # a foundation for maintaining a connection and constructing CQL statements.
5
+ class Cassie
6
+ require File.expand_path("../cassie/config.rb", __FILE__)
7
+ require File.expand_path("../cassie/model.rb", __FILE__)
8
+ require File.expand_path("../cassie/schema.rb", __FILE__)
9
+ require File.expand_path("../cassie/testing.rb", __FILE__)
10
+ require File.expand_path("../cassie/railtie.rb", __FILE__) if defined?(Rails)
11
+
12
+ class RecordNotFound < StandardError
13
+ end
14
+
15
+ class RecordInvalid < StandardError
16
+ attr_reader :record
17
+
18
+ def initialize(record)
19
+ super
20
+ @record = record
21
+ end
22
+ end
23
+
24
+ attr_reader :config
25
+
26
+ class << self
27
+ # A singleton instance that can be shared to communicate with a Cassandra cluster.
28
+ def instance
29
+ unless defined?(@instance) && @instance
30
+ instance = new(@config)
31
+ @instance = instance
32
+ end
33
+ @instance
34
+ end
35
+
36
+ # Call this method to load the Cassie::Config from the specified file for the
37
+ # specified environment.
38
+ def configure!(options)
39
+ if defined?(@instance) && @instance
40
+ old_instance = @instance
41
+ @instance = nil
42
+ old_instance.disconnect
43
+ end
44
+ @config = Cassie::Config.new(options)
45
+ end
46
+
47
+ # This method can be used to set a consistency level for all Cassandra queries
48
+ # within a block that don't explicitly define them. It can be used where consistency
49
+ # is important (i.e. on validation queries) but where a higher level method
50
+ # doesn't provide an option to set it.
51
+ def consistency(level)
52
+ save_val = Thread.current[:cassie_consistency]
53
+ begin
54
+ Thread.current[:cassie_consistency] = level
55
+ yield
56
+ ensure
57
+ Thread.current[:cassie_consistency] = save_val
58
+ end
59
+ end
60
+
61
+ # Get a Logger compatible object if it has been set.
62
+ def logger
63
+ @logger if defined?(@logger)
64
+ end
65
+
66
+ # Set a logger with a Logger compatible object.
67
+ def logger=(value)
68
+ @logger = value
69
+ end
70
+ end
71
+
72
+ def initialize(config)
73
+ @config = config
74
+ @monitor = Monitor.new
75
+ @session = nil
76
+ @prepared_statements = {}
77
+ @last_prepare_warning = Time.now
78
+ end
79
+
80
+ # Open a connection to the Cassandra cluster.
81
+ def connect
82
+ cluster = Cassandra.cluster(config.cluster)
83
+ logger.info("Cassie.connect with #{config.sanitized_cluster}") if logger
84
+ @monitor.synchronize do
85
+ @session = cluster.connect(config.default_keyspace)
86
+ @prepared_statements = {}
87
+ end
88
+ end
89
+
90
+ # Close the connections to the Cassandra cluster.
91
+ def disconnect
92
+ logger.info("Cassie.disconnect from #{config.sanitized_cluster}") if logger
93
+ @monitor.synchronize do
94
+ @session.close if @session
95
+ @session = nil
96
+ @prepared_statements = {}
97
+ end
98
+ end
99
+
100
+ # Return true if the connection to the Cassandra cluster has been established.
101
+ def connected?
102
+ !!@session
103
+ end
104
+
105
+ # Force reconnection. If you're using this code in conjunction in a forking server environment
106
+ # like passenger or unicorn you should call this method after forking.
107
+ def reconnect
108
+ disconnect
109
+ connect
110
+ end
111
+
112
+ # Prepare a CQL statement for repeate execution. Prepared statements
113
+ # are cached on the driver until the connection is closed. Calling
114
+ # prepare multiple times with the same CQL string will return
115
+ # the prepared statement from a cache.
116
+ def prepare(cql)
117
+ raise ArgumentError.new("CQL must be a string") unless cql.is_a?(String)
118
+ statement = @prepared_statements[cql]
119
+ cache_filled_up = false
120
+ unless statement
121
+ @monitor.synchronize do
122
+ statement = session.prepare(cql)
123
+ @prepared_statements[cql] = statement
124
+ if @prepared_statements.size > config.max_prepared_statements
125
+ # Cache is full. Clear out the oldest values. Ideally we'd remove the least recently used,
126
+ # but that would require additional overhead on each query. This method will eventually
127
+ # keep the most active queries in the cache and is overall more efficient.
128
+ @prepared_statements.delete(@prepared_statements.first[0])
129
+ cache_filled_up = true
130
+ end
131
+ end
132
+ end
133
+
134
+ if cache_filled_up && logger && Time.now > @last_prepare_warning + 10
135
+ # Set a throttle on how often this message is logged so we don't kill performance enven more.
136
+ @last_prepare_warning = Time.now
137
+ logger.warn("Cassie.prepare cache filled up. Consider increasing the size from #{config.max_prepared_statements}.")
138
+ end
139
+
140
+ statement
141
+ end
142
+
143
+ # Declare and execute a batch statement. Any insert, update, or delete
144
+ # calls made within the block will add themselves to the batch which
145
+ # is executed at the end of the block.
146
+ def batch(options = nil)
147
+ if Thread.current[:cassie_batch]
148
+ yield
149
+ else
150
+ begin
151
+ batch = []
152
+ Thread.current[:cassie_batch] = batch
153
+ yield
154
+ unless batch.empty?
155
+ batch_statement = session.logged_batch
156
+ batch.each do |cql, values|
157
+ if values.blank?
158
+ batch_statement.add(cql)
159
+ else
160
+ statement = prepare(cql)
161
+ statement = statement.bind(Array(values)) if values.present?
162
+ batch_statement.add(statement)
163
+ end
164
+ end
165
+ execute(batch_statement)
166
+ end
167
+ ensure
168
+ Thread.current[:cassie_batch] = nil
169
+ end
170
+ end
171
+ end
172
+
173
+ # Find rows using the CQL statement. If the statement is a string
174
+ # and values are provided then the statement will executed as a prepared
175
+ # statement. In general all statements should be executed this way.
176
+ #
177
+ # If you have a statement without arguments, then you should call
178
+ # prepare before and pass the prepared statement if you plan on
179
+ # executing the same query multiple times.
180
+ def find(cql, values = nil, options = nil)
181
+ execute(cql, values, options)
182
+ end
183
+
184
+ # Insert a row from a hash into a table.
185
+ #
186
+ # You can specify a ttl for the created row by supplying a :ttl option.
187
+ #
188
+ # If this method is called inside a batch block it will be executed in the batch.
189
+ def insert(table, values_hash, options = nil)
190
+ columns = []
191
+ values = []
192
+ values_hash.each do |column, value|
193
+ if !value.nil?
194
+ columns << column
195
+ values << value
196
+ end
197
+ end
198
+ cql = "INSERT INTO #{table} (#{columns.join(', ')}) VALUES (#{question_marks(columns.size)})"
199
+
200
+ ttl = options[:ttl] if options
201
+ if ttl
202
+ cql << " USING TTL ?"
203
+ values << ttl
204
+ end
205
+
206
+ batch_or_execute(cql, values, options)
207
+ end
208
+
209
+ # Update a row in a table. The values to update should be passed in the
210
+ # values_hash while the primary key should be passed in the key_hash.
211
+ #
212
+ # You can specify a ttl for the created row by supplying a :ttl option.
213
+ #
214
+ # If this method is called inside a batch block it will be executed in the batch.
215
+ def update(table, values_hash, key_hash, options = nil)
216
+ key_cql, key_values = key_clause(key_hash)
217
+ update_cql = []
218
+ update_values = []
219
+ values_hash.each do |column, value|
220
+ update_cql << "#{column} = ?"
221
+ update_values << value
222
+ end
223
+ values = update_values + key_values
224
+
225
+ cql = "UPDATE #{table}"
226
+ ttl = options[:ttl] if options
227
+ if ttl
228
+ cql << " USING TTL ?"
229
+ values.unshift(ttl)
230
+ end
231
+ cql << " SET #{update_cql.join(', ')} WHERE #{key_cql}"
232
+
233
+ batch_or_execute(cql, values, options)
234
+ end
235
+
236
+ # Delete a row from a table. You should pass the primary key value
237
+ # in the key_hash.
238
+ #
239
+ # If this method is called inside a batch block it will be executed in the batch.
240
+ def delete(table, key_hash, options = nil)
241
+ key_cql, key_values = key_clause(key_hash)
242
+ cql = "DELETE FROM #{table} WHERE #{key_cql}"
243
+ batch_or_execute(cql, key_values, options)
244
+ end
245
+
246
+ # Execute an arbitrary CQL statment. If values are passed and the statement is a
247
+ # string, it will be prepared and executed as a prepared statement.
248
+ def execute(cql, values = nil, options = nil)
249
+ start_time = Time.now
250
+ begin
251
+ statement = nil
252
+ if cql.is_a?(String) && values.present?
253
+ statement = prepare(cql)
254
+ else
255
+ statement = cql
256
+ end
257
+
258
+ if values.present?
259
+ values = Array(values)
260
+ options = (options ? options.merge(:arguments => values) : {:arguments => values})
261
+ end
262
+
263
+ # Set a default consistency from a block context if it isn't explicitly set.
264
+ default_consistency = Thread.current[:cassie_consistency]
265
+ if default_consistency
266
+ options = (options ? options.reverse_merge(:consistency => default_consistency) : {:consistency => default_consistency})
267
+ end
268
+
269
+ session.execute(statement, options || {})
270
+ rescue Cassandra::Errors::IOError => e
271
+ disconnect
272
+ raise e
273
+ ensure
274
+ elapsed = Time.now - start_time
275
+ if elapsed >= 0.5 && logger
276
+ logger.warn("Slow CQL Query (#{(elapsed * 1000).round}ms): #{cql}#{' with ' + values.inspect unless values.blank?}")
277
+ end
278
+ end
279
+ end
280
+
281
+ private
282
+
283
+ def logger
284
+ self.class.logger
285
+ end
286
+
287
+ def session
288
+ connect unless connected?
289
+ @session
290
+ end
291
+
292
+ def batch_or_execute(cql, values, options = nil)
293
+ batch = Thread.current[:cassie_batch]
294
+ if batch
295
+ batch << [cql, values]
296
+ nil
297
+ else
298
+ execute(cql, values, options)
299
+ end
300
+ end
301
+
302
+ def question_marks(size)
303
+ q = '?'
304
+ (size - 1).times{ q << ',?' }
305
+ q
306
+ end
307
+
308
+ def key_clause(key_hash)
309
+ cql = []
310
+ values = []
311
+ key_hash.each do |key, value|
312
+ cql << "#{key} = ?"
313
+ values << value
314
+ end
315
+ [cql.join(' AND '), values]
316
+ end
317
+ end
data/lib/whi-cassie.rb ADDED
@@ -0,0 +1 @@
1
+ require File.expand_path("../cassie.rb", __FILE__)
@@ -0,0 +1,56 @@
1
+ require "spec_helper"
2
+
3
+ describe Cassie::Config do
4
+
5
+ let(:options) do
6
+ {
7
+ "cluster" => {
8
+ "consistency" => :one,
9
+ "timeout" => 15
10
+ },
11
+ "schema_directory" => "/tmp",
12
+ "max_prepared_statements" => 100,
13
+ "keyspaces" => {"default" => "test_default", "other" => "test_other"},
14
+ "default_keyspace" => "another"
15
+ }
16
+ end
17
+
18
+ it "should handle empty options" do
19
+ config = Cassie::Config.new({})
20
+ config.cluster.should == {}
21
+ config.keyspace_names.should == []
22
+ config.default_keyspace.should == nil
23
+ config.schema_directory.should == nil
24
+ config.max_prepared_statements.should == 1000
25
+ end
26
+
27
+ it "should have cluster options" do
28
+ config = Cassie::Config.new(options)
29
+ config.cluster.should == {:consistency => :one, :timeout => 15}
30
+ end
31
+
32
+ it "should have keyspaces" do
33
+ config = Cassie::Config.new(options)
34
+ config.keyspace(:default).should start_with("test_default")
35
+ config.keyspace("other").should start_with("test_other")
36
+ config.keyspace_names.should =~ ["default", "other"]
37
+ end
38
+
39
+ it "should have a default_keyspace" do
40
+ config = Cassie::Config.new(options)
41
+ config.default_keyspace.should == "another"
42
+ end
43
+
44
+ it "should get the schema_directory" do
45
+ config = Cassie::Config.new(options)
46
+ config.schema_directory.should == "/tmp"
47
+ Cassie::Config.new({}).schema_directory.should == nil
48
+ end
49
+
50
+ it "should get the max_prepared_statements" do
51
+ config = Cassie::Config.new(options)
52
+ config.max_prepared_statements.should == 100
53
+ Cassie::Config.new({}).max_prepared_statements.should == 1000
54
+ end
55
+
56
+ end