whi-cassie 1.0.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.
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