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 +1 -0
- data/README.md +121 -5
- data/couchrest_session_store.gemspec +2 -2
- data/lib/couchrest/model/database_method.rb +131 -0
- data/lib/couchrest/model/rotation.rb +263 -0
- data/lib/couchrest/session.rb +10 -0
- data/lib/couchrest/session/document.rb +20 -1
- data/lib/couchrest_session_store.rb +2 -1
- data/test/couch_tester.rb +3 -4
- data/test/database_method_test.rb +116 -0
- data/test/database_rotation_test.rb +88 -0
- data/test/stress_test.rb +51 -0
- data/test/test_helper.rb +3 -0
- metadata +13 -7
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
|
9
|
-
|
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
|
-
*
|
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
|
-
*
|
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.
|
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
|
data/lib/couchrest/session.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
data/test/stress_test.rb
ADDED
@@ -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.
|
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:
|
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.
|
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
|