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 +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
|