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.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/HISTORY.txt +3 -0
- data/MIT-LICENSE +20 -0
- data/README.md +213 -0
- data/Rakefile +18 -0
- data/VERSION +1 -0
- data/lib/cassie/config.rb +62 -0
- data/lib/cassie/model.rb +483 -0
- data/lib/cassie/railtie.rb +20 -0
- data/lib/cassie/schema.rb +129 -0
- data/lib/cassie/testing.rb +46 -0
- data/lib/cassie.rb +317 -0
- data/lib/whi-cassie.rb +1 -0
- data/spec/cassie/config_spec.rb +56 -0
- data/spec/cassie/model_spec.rb +349 -0
- data/spec/cassie_spec.rb +147 -0
- data/spec/models/thing.rb +35 -0
- data/spec/models/type_tester.rb +23 -0
- data/spec/schema/test.cql +6 -0
- data/spec/spec_helper.rb +33 -0
- data/whi-cassie.gemspec +26 -0
- metadata +144 -0
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
|