couchrest_session_store 0.2.4 → 0.3.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/.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