couchrest_session_store 0.2.4 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 1.9.3-p194
data/README.md CHANGED
@@ -2,16 +2,132 @@
2
2
 
3
3
  A simple session store based on CouchRest Model.
4
4
 
5
-
6
5
  ## Setup ##
7
6
 
8
- CouchRest::Session::Store will automatically pick up the config/couch.yml file for CouchRest Model.
9
- Cleaning up sessions requires a design document in the sessions database that enables querying by expiry. See the design directory for an example and test/setup_couch.sh for a script that puts the document on the couch for our tests.
7
+ `CouchRest::Session::Store` will automatically pick up the config/couch.yml
8
+ file used by CouchRest Model.
10
9
 
10
+ Cleaning up sessions requires a design document in the sessions database that
11
+ enables querying by expiry. See `design/Session.json` for an example. This
12
+ design document is loaded for tests, but you will need to load it on your own
13
+ in a production environment. For example:
11
14
 
15
+ curl -X PUT username:password@localhost:5984/couchrest_sessions/_design/Session --data @design/Session.json
12
16
 
13
17
  ## Options ##
14
18
 
15
- * marhal_data: (_defaults true_) - if set to false session data will be stored directly in the couch document. Otherwise it's marshalled and base64 encoded to enable restoring ruby data structures.
19
+ * marshal_data: (_defaults true_) - if set to false session data will be stored
20
+ directly in the couch document. Otherwise it's marshalled and base64 encoded
21
+ to enable restoring ruby data structures.
16
22
  * database: database to use combined with config prefix and suffix
17
- * exprire_after: livetime of a session in seconds
23
+ * expire_after: lifetime of a session in seconds.
24
+
25
+ ## Dynamic Databases ##
26
+
27
+ This gem also includes the module `CouchRest::Model::DatabaseMethod`, which
28
+ allow a Model to dynamically choose what database to use.
29
+
30
+ An example of specifying database dynamically:
31
+
32
+ class Token < CouchRest::Model::Base
33
+ include CouchRest::Model::DatabaseMethod
34
+
35
+ use_database_method :database_name
36
+
37
+ def self.database_name
38
+ time = Time.now.utc
39
+ "tokens_#{time.year}_#{time.month}"
40
+ end
41
+ end
42
+
43
+ A couple notes:
44
+
45
+ Once you include `CouchRest::Model::DatabaseMethod`, the database is no longer
46
+ automatically created. In this example, you would need to run
47
+ `Token.database.create!` or `Token.database!` in order to create the database.
48
+
49
+ The symbol passed to `database_method` must match the name of a class method,
50
+ but if there is also an instance method with the same name then this instance
51
+ method will be called when appropriate. To state the obvious, tread lightly:
52
+ there be dragons when generating database names that depend on properties of
53
+ the instance.
54
+
55
+ ## Database Rotation ##
56
+
57
+ The module `CouchRest::Model::Rotation` can be included in a Model in
58
+ order to use dynamic databases to perform database rotation.
59
+
60
+ CouchDB is not good for ephemeral data because old documents are never really
61
+ deleted: when you deleted a document, it just appends a new revision. The bulk
62
+ of the old data is not stored, but it does store a record for each doc id and
63
+ revision id for the document. In the case of ephemeral data, like tokens,
64
+ sessions, or statistics, this will quickly bloat the database with a ton of
65
+ useless deleted documents. The proper solution is to rotate the databases:
66
+ create a new one regularly and delete the old one entirely. This will allow
67
+ you to recover the storage space.
68
+
69
+ A better solution might be to just use a different database for all
70
+ ephemeral data, like MariaDB or Redis. But, if you really want to use CouchDB, this
71
+ is how you can do it.
72
+
73
+ An example of specifying database rotation:
74
+
75
+ class Token < CouchRest::Model::Base
76
+ include CouchRest::Model::Rotation
77
+
78
+ rotate_database 'tokens', :every => 30.days
79
+ end
80
+
81
+ Then, in a task triggered by a cron job:
82
+
83
+ CouchRest::Model::Base.configure do |conf|
84
+ conf.environment = Rails.env
85
+ conf.connection_config_file = File.join(Rails.root, 'config', 'couchdb.admin.yml')
86
+ end
87
+ Token.rotate_database_now(:window => 1.day)
88
+
89
+ Or perhaps:
90
+
91
+ Rails.application.eager_load!
92
+ CouchRest::Model::Rotation.descendants.each do |model|
93
+ model.rotate_database_now
94
+ end
95
+
96
+ The `:window` argument to `rotate_database_now` specifies how far in advance we
97
+ should create the new database (default 1.day). For ideal behavior, this value
98
+ should be GREATER than or equal to the frequency with which the cron job is
99
+ run. For example, if the cron job is run every hour, the argument can be
100
+ `1.hour`, `2.hours`, `1.day`, but not `20.minutes`.
101
+
102
+ The method `rotate_database_now` will do nothing if the database has already
103
+ been rotated. Otherwise, as needed, it will create the new database, create
104
+ the design documents, set up replication between the old and new databases,
105
+ and delete the old database (once it is not used anymore).
106
+
107
+ These actions will require admin access, so if your application normally runs
108
+ without admin rights you will need specify a different configuration for
109
+ CouchRest::Model before `rotate_database_now` is called.
110
+
111
+ Known issues:
112
+
113
+ * If you change the rotation period, there will be a break in the rotation
114
+ (old documents will not get replicated to the new rotated db) and the old db
115
+ will not get automatically deleted.
116
+
117
+ * Calling `Model.database.delete!` will not necessarily remove all the
118
+ relevant databases because of the way prior and future databases are kept
119
+ for the 'window' period.
120
+
121
+ ## Changes ##
122
+
123
+ 0.3.0
124
+
125
+ * Added support for dynamic and rotating databases.
126
+
127
+ 0.2.4
128
+
129
+ * Do not crash if can't connect to CouchDB
130
+
131
+ 0.2.3
132
+
133
+ * Better retry and conflict catching.d
@@ -14,11 +14,11 @@ Gem::Specification.new do |gem|
14
14
  gem.files = `git ls-files`.split("\n")
15
15
  gem.name = "couchrest_session_store"
16
16
  gem.require_paths = ["lib"]
17
- gem.version = '0.2.4'
17
+ gem.version = '0.3.0'
18
18
 
19
19
  gem.add_dependency "couchrest"
20
20
  gem.add_dependency "couchrest_model"
21
- gem.add_dependency "actionpack"
21
+ gem.add_dependency "actionpack", '~> 3.0'
22
22
 
23
23
  gem.add_development_dependency "minitest"
24
24
  gem.add_development_dependency "rake"
@@ -0,0 +1,131 @@
1
+ #
2
+ # Allow setting the database to happen dynamically.
3
+ #
4
+ # Unlike normal CouchRest::Model, the database is not automatically created
5
+ # unless you call database!()
6
+ #
7
+ # The method specified by `database_method` must exist as a class method but
8
+ # may optionally also exist as an instance method.
9
+ #
10
+
11
+ module CouchRest
12
+ module Model
13
+ module DatabaseMethod
14
+ extend ActiveSupport::Concern
15
+
16
+ def database
17
+ if self.class.database_method
18
+ self.class.server.database(call_database_method)
19
+ else
20
+ self.class.database
21
+ end
22
+ end
23
+
24
+ def database!
25
+ if self.class.database_method
26
+ self.class.server.database!(call_database_method)
27
+ else
28
+ self.class.database!
29
+ end
30
+ end
31
+
32
+ def database_exists?(db_name)
33
+ self.class.database_exists?(db_name)
34
+ end
35
+
36
+ #
37
+ # The normal CouchRest::Model::Base comparison checks if the model's
38
+ # database objects are the same. That is not good for use here, since
39
+ # the objects will always be different. Instead, we compare the string
40
+ # that each database evaluates to.
41
+ #
42
+ def ==(other)
43
+ return false unless other.is_a?(Base)
44
+ if id.nil? && other.id.nil?
45
+ to_hash == other.to_hash
46
+ else
47
+ id == other.id && database.to_s == other.database.to_s
48
+ end
49
+ end
50
+ alias :eql? :==
51
+
52
+ protected
53
+
54
+ def call_database_method
55
+ if self.respond_to?(self.class.database_method)
56
+ name = self.send(self.class.database_method)
57
+ self.class.db_name_with_prefix(name)
58
+ else
59
+ self.class.send(:call_database_method)
60
+ end
61
+ end
62
+
63
+ module ClassMethods
64
+
65
+ def database_method(method = nil)
66
+ if method
67
+ @database_method = method
68
+ end
69
+ @database_method
70
+ end
71
+ alias :use_database_method :database_method
72
+
73
+ def database
74
+ if database_method
75
+ if !self.respond_to?(database_method)
76
+ raise ArgumentError.new("Incorrect argument to database_method(): no such method '#{method}' found in class #{self}.")
77
+ end
78
+ self.server.database(call_database_method)
79
+ else
80
+ @database ||= prepare_database(super)
81
+ end
82
+ end
83
+
84
+ def database!
85
+ if database_method
86
+ self.server.database!(call_database_method)
87
+ else
88
+ @database ||= prepare_database(super)
89
+ end
90
+ end
91
+
92
+ #
93
+ # same as database(), but allows for an argument that gets passed through to
94
+ # database method.
95
+ #
96
+ def choose_database(*args)
97
+ self.server.database(call_database_method(*args))
98
+ end
99
+
100
+ def db_name_with_prefix(name)
101
+ conf = self.send(:connection_configuration)
102
+ [conf[:prefix], name, conf[:suffix]].reject{|i|i.to_s.empty?}.join(conf[:join])
103
+ end
104
+
105
+ def database_exists?(name)
106
+ name = db_name_with_prefix(name)
107
+ begin
108
+ CouchRest.head "#{self.server.uri}/#{name}"
109
+ return true
110
+ rescue RestClient::ResourceNotFound
111
+ return false
112
+ end
113
+ end
114
+
115
+ protected
116
+
117
+ def call_database_method(*args)
118
+ name = nil
119
+ method = self.method(database_method)
120
+ if method.arity == 0
121
+ name = method.call
122
+ else
123
+ name = method.call(*args)
124
+ end
125
+ db_name_with_prefix(name)
126
+ end
127
+
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,263 @@
1
+ module CouchRest
2
+ module Model
3
+ module Rotation
4
+ extend ActiveSupport::Concern
5
+ include CouchRest::Model::DatabaseMethod
6
+
7
+ included do
8
+ use_database_method :rotated_database_name
9
+ end
10
+
11
+ def create(*args)
12
+ super(*args)
13
+ rescue RestClient::ResourceNotFound => exc
14
+ raise storage_missing(exc)
15
+ end
16
+
17
+ def update(*args)
18
+ super(*args)
19
+ rescue RestClient::ResourceNotFound => exc
20
+ raise storage_missing(exc)
21
+ end
22
+
23
+ def destroy(*args)
24
+ super(*args)
25
+ rescue RestClient::ResourceNotFound => exc
26
+ raise storage_missing(exc)
27
+ end
28
+
29
+ private
30
+
31
+ # returns a special 'storage missing' exception when the db has
32
+ # not been created. very useful, since this happens a lot and a
33
+ # generic 404 is not that helpful.
34
+ def storage_missing(exc)
35
+ if exc.http_body =~ /no_db_file/
36
+ CouchRest::StorageMissing.new(exc.response, database)
37
+ else
38
+ exc
39
+ end
40
+ end
41
+
42
+ public
43
+
44
+ module ClassMethods
45
+ #
46
+ # Set up database rotation.
47
+ #
48
+ # base_name -- the name of the db before the rotation number is
49
+ # appended.
50
+ #
51
+ # options -- one of:
52
+ #
53
+ # * :every -- frequency of rotation
54
+ # * :expiration_field - what field to use to determine if a
55
+ # document is expired.
56
+ # * :timestamp_field - alternately, what field to use for the
57
+ # document timestamp.
58
+ # * :timeout -- used to expire documents with only a timestamp
59
+ # field (in minutes)
60
+ #
61
+ def rotate_database(base_name, options={})
62
+ @rotation_base_name = base_name
63
+ @rotation_every = (options.delete(:every) || 30.days).to_i
64
+ @expiration_field = options.delete(:expiration_field)
65
+ @timestamp_field = options.delete(:timestamp_field)
66
+ @timeout = options.delete(:timeout)
67
+ if options.any?
68
+ raise ArgumentError.new('Could not understand options %s' % options.keys)
69
+ end
70
+ end
71
+
72
+ #
73
+ # Check to see if dbs should be rotated. The :window
74
+ # argument specifies how far in advance we should
75
+ # create the new database (default 1.day).
76
+ #
77
+ # This method relies on the assumption that it is called
78
+ # at least once within each @rotation_every period.
79
+ #
80
+ def rotate_database_now(options={})
81
+ window = options[:window] || 1.day
82
+
83
+ now = Time.now.utc
84
+ current_name = rotated_database_name(now)
85
+ current_count = now.to_i/@rotation_every
86
+
87
+ next_time = window.from_now.utc
88
+ next_name = rotated_database_name(next_time)
89
+ next_count = current_count+1
90
+
91
+ prev_name = current_name.sub(/(\d+)$/) {|i| i.to_i-1}
92
+ replication_started = false
93
+ old_name = prev_name.sub(/(\d+)$/) {|i| i.to_i-1} # even older than prev_name
94
+ trailing_edge_time = window.ago.utc
95
+
96
+ if !database_exists?(current_name)
97
+ # we should have created the current db earlier, but if somehow
98
+ # it is missing we must make sure it exists.
99
+ create_new_rotated_database(:from => prev_name, :to => current_name)
100
+ replication_started = true
101
+ end
102
+
103
+ if next_time.to_i/@rotation_every >= next_count && !database_exists?(next_name)
104
+ # time to create the next db in advance of actually needing it.
105
+ create_new_rotated_database(:from => current_name, :to => next_name)
106
+ end
107
+
108
+ if trailing_edge_time.to_i/@rotation_every == current_count
109
+ # delete old dbs, but only after window time has past since the last rotation
110
+ if !replication_started && database_exists?(prev_name)
111
+ # delete previous, but only if we didn't just start replicating from it
112
+ self.server.database(db_name_with_prefix(prev_name)).delete!
113
+ end
114
+ if database_exists?(old_name)
115
+ # there are some edge cases, when rotate_database_now is run
116
+ # infrequently, that an older db might be left around.
117
+ self.server.database(db_name_with_prefix(old_name)).delete!
118
+ end
119
+ end
120
+ end
121
+
122
+ def rotated_database_name(time=nil)
123
+ unless @rotation_base_name && @rotation_every
124
+ raise ArgumentError.new('missing @rotation_base_name or @rotation_every')
125
+ end
126
+ time ||= Time.now.utc
127
+ units = time.to_i / @rotation_every.to_i
128
+ "#{@rotation_base_name}_#{units}"
129
+ end
130
+
131
+ #
132
+ # create a new empty database.
133
+ #
134
+ def create_database!(name=nil)
135
+ db = if name
136
+ self.server.database!(db_name_with_prefix(name))
137
+ else
138
+ self.database!
139
+ end
140
+ create_rotation_filter(db)
141
+ if self.respond_to?(:design_doc)
142
+ design_doc.sync!(db)
143
+ # or maybe this?:
144
+ #self.design_docs.each do |design|
145
+ # design.migrate(to_db)
146
+ #end
147
+ end
148
+ return db
149
+ end
150
+
151
+ protected
152
+
153
+ #
154
+ # Creates database named by options[:to]. Optionally, set up
155
+ # continuous replication from the options[:from] db, if it exists. The
156
+ # assumption is that the from db will be destroyed later, cleaning up
157
+ # the replication once it is no longer needed.
158
+ #
159
+ # This method will also copy design documents if present in the from
160
+ # db, in the CouchRest::Model, or in a database named after
161
+ # @rotation_base_name.
162
+ #
163
+ def create_new_rotated_database(options={})
164
+ from = options[:from]
165
+ to = options[:to]
166
+ to_db = self.create_database!(to)
167
+ if database_exists?(@rotation_base_name)
168
+ base_db = self.server.database(db_name_with_prefix(@rotation_base_name))
169
+ copy_design_docs(base_db, to_db)
170
+ end
171
+ if from && from != to && database_exists?(from)
172
+ from_db = self.server.database(db_name_with_prefix(from))
173
+ replicate_old_to_new(from_db, to_db)
174
+ end
175
+ end
176
+
177
+ def copy_design_docs(from, to)
178
+ params = {:startkey => '_design/', :endkey => '_design0', :include_docs => true}
179
+ from.documents(params) do |doc_hash|
180
+ design = doc_hash['doc']
181
+ begin
182
+ to.get(design['_id'])
183
+ rescue RestClient::ResourceNotFound
184
+ design.delete('_rev')
185
+ to.save_doc(design)
186
+ end
187
+ end
188
+ end
189
+
190
+ def create_rotation_filter(db)
191
+ name = 'rotation_filter'
192
+ filter_string = if @expiration_field
193
+ NOT_EXPIRED_FILTER % {:expires => @expiration_field}
194
+ elsif @timestamp_field && @timeout
195
+ NOT_TIMED_OUT_FILTER % {:timestamp => @timestamp_field, :timeout => (60 * @timeout)}
196
+ else
197
+ NOT_DELETED_FILTER
198
+ end
199
+ filters = {"not_expired" => filter_string}
200
+ db.save_doc("_id" => "_design/#{name}", "filters" => filters)
201
+ rescue RestClient::Conflict
202
+ end
203
+
204
+ #
205
+ # Replicates documents from_db to to_db, skipping documents that have
206
+ # expired or been deleted.
207
+ #
208
+ # NOTE: It would be better if we could do this:
209
+ #
210
+ # from_db.replicate_to(to_db, true, false,
211
+ # :filter => 'rotation_filter/not_expired')
212
+ #
213
+ # But replicate_to() does not support a filter argument, so we call
214
+ # the private method replication() directly.
215
+ #
216
+ def replicate_old_to_new(from_db, to_db)
217
+ create_rotation_filter(from_db)
218
+ from_db.send(:replicate, to_db, true, :source => from_db.name, :filter => 'rotation_filter/not_expired')
219
+ end
220
+
221
+ #
222
+ # Three different filters, depending on how the model is set up.
223
+ #
224
+ # NOT_EXPIRED_FILTER is used when there is a single field that
225
+ # contains an absolute time for when the document has expired. The
226
+ #
227
+ # NOT_TIMED_OUT_FILTER is used when there is a field that records the
228
+ # timestamp of the last time the document was used. The expiration in
229
+ # this case is calculated from the timestamp plus @timeout.
230
+ #
231
+ # NOT_DELETED_FILTER is used when the other two cannot be.
232
+ #
233
+ NOT_EXPIRED_FILTER = "" +
234
+ %[function(doc, req) {
235
+ if (doc._deleted) {
236
+ return false;
237
+ } else if (typeof(doc.%{expires}) != "undefined") {
238
+ return Date.now() < (new Date(doc.%{expires})).getTime();
239
+ } else {
240
+ return true;
241
+ }
242
+ }]
243
+
244
+ NOT_TIMED_OUT_FILTER = "" +
245
+ %[function(doc, req) {
246
+ if (doc._deleted) {
247
+ return false;
248
+ } else if (typeof(doc.%{timestamp}) != "undefined") {
249
+ return Date.now() < (new Date(doc.%{timestamp})).getTime() + %{timeout};
250
+ } else {
251
+ return true;
252
+ }
253
+ }]
254
+
255
+ NOT_DELETED_FILTER = "" +
256
+ %[function(doc, req) {
257
+ return !doc._deleted;
258
+ }]
259
+
260
+ end
261
+ end
262
+ end
263
+ end
@@ -1,4 +1,14 @@
1
1
  module CouchRest
2
+
3
+ class StorageMissing < RestClient::Exception
4
+ attr_reader :db
5
+ def initialize(request, db)
6
+ super(request)
7
+ @db = db.name
8
+ @message = "The database '#{db}' does not exist."
9
+ end
10
+ end
11
+
2
12
  module Session
3
13
  end
4
14
  end
@@ -5,8 +5,10 @@ class CouchRest::Session::Document < CouchRest::Document
5
5
  include CouchRest::Model::Configuration
6
6
  include CouchRest::Model::Connection
7
7
  include CouchRest::Session::Utility
8
+ include CouchRest::Model::Rotation
8
9
 
9
- use_database "sessions"
10
+ rotate_database 'sessions',
11
+ :every => 1.month, :expiration_field => :expires
10
12
 
11
13
  def self.fetch(sid)
12
14
  self.allocate.tap do |session_doc|
@@ -36,6 +38,18 @@ class CouchRest::Session::Document < CouchRest::Document
36
38
  response['rows']
37
39
  end
38
40
 
41
+ def self.create_database!(name=nil)
42
+ db = super(name)
43
+ begin
44
+ db.get('_design/Session')
45
+ rescue RestClient::ResourceNotFound
46
+ design = File.read(File.expand_path('../../../../design/Session.json', __FILE__))
47
+ design = JSON.parse(design)
48
+ db.save_doc(design.merge({"_id" => "_design/Session"}))
49
+ end
50
+ db
51
+ end
52
+
39
53
  def initialize(doc)
40
54
  @doc = doc
41
55
  end
@@ -70,6 +84,11 @@ class CouchRest::Session::Document < CouchRest::Document
70
84
  rescue RestClient::Conflict
71
85
  fetch
72
86
  retry
87
+ rescue RestClient::ResourceNotFound => exc
88
+ if exc.http_body =~ /no_db_file/
89
+ exc = CouchRest::StorageMissing.new(exc.response, database)
90
+ end
91
+ raise exc
73
92
  end
74
93
 
75
94
  def expired?
@@ -4,7 +4,8 @@ require 'couchrest_model'
4
4
  gem 'actionpack', '~> 3.0'
5
5
  require 'action_dispatch'
6
6
 
7
+ require 'couchrest/model/database_method'
8
+ require 'couchrest/model/rotation'
7
9
  require 'couchrest/session'
8
10
  require 'couchrest/session/store'
9
11
  require 'couchrest/session/document'
10
-
data/test/couch_tester.rb CHANGED
@@ -6,13 +6,12 @@
6
6
  class CouchTester < CouchRest::Document
7
7
  include CouchRest::Model::Configuration
8
8
  include CouchRest::Model::Connection
9
+ include CouchRest::Model::Rotation
9
10
 
10
- use_database 'sessions'
11
+ rotate_database 'sessions',
12
+ :every => 1.month, :expiration_field => :expires
11
13
 
12
14
  def initialize(options = {})
13
- if options[:database]
14
- self.class.use_database options[:database]
15
- end
16
15
  end
17
16
 
18
17
  def get(sid)
@@ -0,0 +1,116 @@
1
+ require_relative 'test_helper'
2
+
3
+ class DatabaseMethodTest < MiniTest::Test
4
+
5
+ class TestModel < CouchRest::Model::Base
6
+ include CouchRest::Model::DatabaseMethod
7
+
8
+ use_database_method :db_name
9
+ property :dbname, String
10
+ property :confirm, String
11
+
12
+ def db_name
13
+ "test_db_#{self[:dbname]}"
14
+ end
15
+ end
16
+
17
+ def test_instance_method
18
+ doc1 = TestModel.new({:dbname => 'one'})
19
+ doc1.database.create!
20
+ assert doc1.database.root.ends_with?('test_db_one')
21
+ assert doc1.save
22
+ doc1.update_attributes(:confirm => 'yep')
23
+
24
+ doc2 = TestModel.new({:dbname => 'two'})
25
+ doc2.database.create!
26
+ assert doc2.database.root.ends_with?('test_db_two')
27
+ assert doc2.save
28
+ doc2.confirm = 'sure'
29
+ doc2.save!
30
+
31
+ doc1_copy = CouchRest.get([doc1.database.root, doc1.id].join('/'))
32
+ assert_equal "yep", doc1_copy["confirm"]
33
+
34
+ doc2_copy = CouchRest.get([doc2.database.root, doc2.id].join('/'))
35
+ assert_equal "sure", doc2_copy["confirm"]
36
+
37
+ doc1.database.delete!
38
+ doc2.database.delete!
39
+ end
40
+
41
+ def test_switch_db
42
+ doc_red = TestModel.new({:dbname => 'red', :confirm => 'rose'})
43
+ doc_red.database.create!
44
+ root = doc_red.database.root
45
+
46
+ doc_blue = doc_red.clone
47
+ doc_blue.dbname = 'blue'
48
+ doc_blue.database!
49
+ doc_blue.save!
50
+
51
+ doc_blue_copy = CouchRest.get([root.sub('red','blue'), doc_blue.id].join('/'))
52
+ assert_equal "rose", doc_blue_copy["confirm"]
53
+
54
+ doc_red.database.delete!
55
+ doc_blue.database.delete!
56
+ end
57
+
58
+ #
59
+ # A test scenario for database_method in which some user accounts
60
+ # are stored in a seperate temporary database (so that the test
61
+ # accounts don't bloat the normal database).
62
+ #
63
+
64
+ class User < CouchRest::Model::Base
65
+ include CouchRest::Model::DatabaseMethod
66
+
67
+ use_database_method :db_name
68
+ property :login, String
69
+ before_save :create_db
70
+
71
+ class << self
72
+ def get(id, db = database)
73
+ result = super(id, db)
74
+ if result.nil?
75
+ return super(id, choose_database('test-user'))
76
+ else
77
+ return result
78
+ end
79
+ end
80
+ alias :find :get
81
+ end
82
+
83
+ protected
84
+
85
+ def self.db_name(login = nil)
86
+ if !login.nil? && login =~ /test-user/
87
+ 'tmp_users'
88
+ else
89
+ 'users'
90
+ end
91
+ end
92
+
93
+ def db_name
94
+ self.class.db_name(self.login)
95
+ end
96
+
97
+ def create_db
98
+ unless database_exists?(db_name)
99
+ self.database!
100
+ end
101
+ end
102
+
103
+ end
104
+
105
+ def test_tmp_user_db
106
+ user1 = User.new({:login => 'test-user-1'})
107
+ assert user1.save
108
+ assert User.find(user1.id), 'should find user in tmp_users'
109
+ assert_equal user1.login, User.find(user1.id).login
110
+ assert_equal 'test-user-1', User.server.database('couchrest_tmp_users').get(user1.id)['login']
111
+ assert_raises RestClient::ResourceNotFound do
112
+ User.server.database('couchrest_users').get(user1.id)
113
+ end
114
+ end
115
+
116
+ end
@@ -0,0 +1,88 @@
1
+ require_relative 'test_helper'
2
+
3
+ class RotationTest < MiniTest::Test
4
+
5
+ class Token < CouchRest::Model::Base
6
+ include CouchRest::Model::Rotation
7
+ property :token, String
8
+ rotate_database 'test_rotate', :every => 1.day
9
+ end
10
+
11
+ TEST_DB_RE = /test_rotate_\d+/
12
+
13
+ def test_rotate
14
+ delete_all_dbs
15
+ doc = nil
16
+ original_name = nil
17
+ next_db_name = nil
18
+
19
+ Time.stub :now, Time.gm(2015,3,7,0) do
20
+ Token.create_database!
21
+ doc = Token.create!(:token => 'aaaa')
22
+ original_name = Token.rotated_database_name
23
+ assert database_exists?(original_name)
24
+ assert_equal 1, count_dbs
25
+ end
26
+
27
+ # do nothing yet
28
+ Time.stub :now, Time.gm(2015,3,7,22) do
29
+ Token.rotate_database_now(:window => 1.hour)
30
+ assert_equal original_name, Token.rotated_database_name
31
+ assert_equal 1, count_dbs
32
+ end
33
+
34
+ # create next db, but don't switch yet.
35
+ Time.stub :now, Time.gm(2015,3,7,23) do
36
+ Token.rotate_database_now(:window => 1.hour)
37
+ assert_equal 2, count_dbs
38
+ next_db_name = Token.rotated_database_name(Time.gm(2015,3,8))
39
+ assert original_name != next_db_name
40
+ assert database_exists?(next_db_name)
41
+ sleep 0.2 # allow time for documents to replicate
42
+ assert_equal(
43
+ Token.get(doc.id).token,
44
+ Token.get(doc.id, database(next_db_name)).token
45
+ )
46
+ end
47
+
48
+ # use next db
49
+ Time.stub :now, Time.gm(2015,3,8) do
50
+ Token.rotate_database_now(:window => 1.hour)
51
+ assert_equal 2, count_dbs
52
+ assert_equal next_db_name, Token.rotated_database_name
53
+ token = Token.get(doc.id)
54
+ token.update_attributes(:token => 'bbbb')
55
+ assert_equal 'bbbb', Token.get(doc.id).token
56
+ assert_equal 'aaaa', Token.get(doc.id, database(original_name)).token
57
+ end
58
+
59
+ # delete prior db
60
+ Time.stub :now, Time.gm(2015,3,8,1) do
61
+ Token.rotate_database_now(:window => 1.hour)
62
+ assert_equal 1, count_dbs
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def database(db_name)
69
+ Token.server.database(Token.db_name_with_prefix(db_name))
70
+ end
71
+
72
+ def database_exists?(dbname)
73
+ Token.database_exists?(dbname)
74
+ end
75
+
76
+ def delete_all_dbs(regexp=TEST_DB_RE)
77
+ Token.server.databases.each do |db|
78
+ if regexp.match(db)
79
+ Token.server.database(db).delete!
80
+ end
81
+ end
82
+ end
83
+
84
+ def count_dbs(regexp=TEST_DB_RE)
85
+ Token.server.databases.grep(regexp).count
86
+ end
87
+
88
+ end
@@ -0,0 +1,51 @@
1
+ require_relative 'test_helper'
2
+
3
+ #
4
+ # This doesn't really test much, but is useful if you want to see what happens
5
+ # when you have a lot of documents.
6
+ #
7
+
8
+ class StressTest < MiniTest::Test
9
+
10
+ COUNT = 200 # change to 200,000 if you dare
11
+
12
+ class Stress < CouchRest::Model::Base
13
+ include CouchRest::Model::Rotation
14
+ property :token, String
15
+ property :expires_at, Time
16
+ rotate_database 'stress_test', :every => 1.day, :expiration_field => :expires_at
17
+ end
18
+
19
+ def test_stress
20
+ delete_all_dbs /^couchrest_stress_test_\d+$/
21
+
22
+ Stress.database!
23
+ COUNT.times do |i|
24
+ doc = Stress.create!(:token => SecureRandom.hex(32), :expires_at => expires(i))
25
+ end
26
+
27
+ Time.stub :now, 1.day.from_now do
28
+ Stress.rotate_database_now(:window => 1.hour)
29
+ sleep 0.5
30
+ assert_equal (COUNT/100)+1, Stress.database.info["doc_count"]
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def delete_all_dbs(regexp=TEST_DB_RE)
37
+ Stress.server.databases.each do |db|
38
+ if regexp.match(db)
39
+ Stress.server.database(db).delete!
40
+ end
41
+ end
42
+ end
43
+
44
+ def expires(i)
45
+ if i % 100 == 0
46
+ 1.hour.from_now.utc
47
+ else
48
+ 1.hour.ago.utc
49
+ end
50
+ end
51
+ end
data/test/test_helper.rb CHANGED
@@ -4,3 +4,6 @@ require 'minitest/autorun'
4
4
  require File.expand_path(File.dirname(__FILE__) + '/../lib/couchrest_session_store.rb')
5
5
  require File.expand_path(File.dirname(__FILE__) + '/couch_tester.rb')
6
6
  require File.expand_path(File.dirname(__FILE__) + '/test_clock.rb')
7
+
8
+ # Create the session db if it does not already exist.
9
+ CouchRest::Session::Document.create_database!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: couchrest_session_store
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.3.0
5
5
  prerelease:
6
6
  platform: ruby
7
7
  authors:
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2013-12-13 00:00:00.000000000 Z
12
+ date: 2015-03-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: couchrest
@@ -48,17 +48,17 @@ dependencies:
48
48
  requirement: !ruby/object:Gem::Requirement
49
49
  none: false
50
50
  requirements:
51
- - - ! '>='
51
+ - - ~>
52
52
  - !ruby/object:Gem::Version
53
- version: '0'
53
+ version: '3.0'
54
54
  type: :runtime
55
55
  prerelease: false
56
56
  version_requirements: !ruby/object:Gem::Requirement
57
57
  none: false
58
58
  requirements:
59
- - - ! '>='
59
+ - - ~>
60
60
  - !ruby/object:Gem::Version
61
- version: '0'
61
+ version: '3.0'
62
62
  - !ruby/object:Gem::Dependency
63
63
  name: minitest
64
64
  requirement: !ruby/object:Gem::Requirement
@@ -98,21 +98,27 @@ executables: []
98
98
  extensions: []
99
99
  extra_rdoc_files: []
100
100
  files:
101
+ - .ruby-version
101
102
  - .travis.yml
102
103
  - Gemfile
103
104
  - README.md
104
105
  - Rakefile
105
106
  - couchrest_session_store.gemspec
106
107
  - design/Session.json
108
+ - lib/couchrest/model/database_method.rb
109
+ - lib/couchrest/model/rotation.rb
107
110
  - lib/couchrest/session.rb
108
111
  - lib/couchrest/session/document.rb
109
112
  - lib/couchrest/session/store.rb
110
113
  - lib/couchrest/session/utility.rb
111
114
  - lib/couchrest_session_store.rb
112
115
  - test/couch_tester.rb
116
+ - test/database_method_test.rb
117
+ - test/database_rotation_test.rb
113
118
  - test/session_document_test.rb
114
119
  - test/session_store_test.rb
115
120
  - test/setup_couch.sh
121
+ - test/stress_test.rb
116
122
  - test/test_clock.rb
117
123
  - test/test_helper.rb
118
124
  homepage: http://github.com/azul/couchrest_session_store
@@ -135,7 +141,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
135
141
  version: '0'
136
142
  requirements: []
137
143
  rubyforge_project:
138
- rubygems_version: 1.8.25
144
+ rubygems_version: 1.8.23
139
145
  signing_key:
140
146
  specification_version: 3
141
147
  summary: A Rails Session Store based on CouchRest Model