waitmate 0.1.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.
@@ -0,0 +1,358 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ begin
6
+ require "solid_cache" if defined?(Rails)
7
+ rescue LoadError
8
+ # Solid Cache is optional; instantiation without the gem raises ConfigurationError.
9
+ end
10
+
11
+ module Waitmate
12
+ module Store
13
+ # SQL-backed fallback adapter built on top of SolidCache::Entry.
14
+ #
15
+ # Solid Cache is a fixed-schema key/value store, so Waitmate queue state
16
+ # is separated into two key prefixes:
17
+ # waitmate:waiting:{queue}:{identity} — waiting entries
18
+ # waitmate:active:{queue}:{identity} — admitted entries
19
+ #
20
+ # This mirrors the Redis adapter's separate sorted sets and lets SQL
21
+ # +LIKE+ narrow scans to one state before Ruby-side JSON parsing.
22
+ # Active scans are bounded by +max_concurrent+; waiting scans are
23
+ # bounded by queue depth ahead of the target entry.
24
+ #
25
+ # FIFO ordering relies on an +enqueued_at+ timestamp stored in the JSON
26
+ # value, avoiding reliance on the binary +created_at+ column which has
27
+ # cross-Rails-version comparison issues.
28
+ #
29
+ # Single-key lookups use +SolidCache::Entry.read+ / +delete_by_key+
30
+ # which operate on the indexed +key_hash+ integer column, avoiding
31
+ # binary +key+ column comparison entirely.
32
+ #
33
+ # Admission is serialized per queue with a mutex row so capacity
34
+ # accounting never follows a read-modify-write path under concurrency.
35
+ class SolidCache
36
+ KEY_PREFIX = "waitmate"
37
+
38
+ def initialize(config: Waitmate.configuration)
39
+ begin
40
+ require "solid_cache" unless defined?(::SolidCache)
41
+ rescue LoadError
42
+ # Fall through to the contract check below.
43
+ end
44
+
45
+ unless defined?(::SolidCache::Entry)
46
+ raise ConfigurationError,
47
+ "Solid Cache gem is not available. Add `gem 'solid_cache'` to your Gemfile " \
48
+ "to use the Solid Cache adapter."
49
+ end
50
+
51
+ @queue_ttl = config.queue_ttl
52
+ end
53
+
54
+ # enqueue(queue_name, identity, ttl: nil) -> Integer (1-based position) or 0 (already active)
55
+ def enqueue(queue_name, identity, ttl: nil)
56
+ ttl ||= @queue_ttl
57
+ now = current_timestamp
58
+ expires_at = now + ttl
59
+ a_key = active_entry_key(queue_name, identity)
60
+ w_key = waiting_entry_key(queue_name, identity)
61
+
62
+ with_ar do
63
+ # Already active and not expired?
64
+ active_value = read_value(a_key)
65
+ if active_value
66
+ return 0 if active_value["expires_at"] > now
67
+ end
68
+
69
+ # Already waiting and not expired?
70
+ waiting_value = read_value(w_key)
71
+ if waiting_value
72
+ if waiting_value["expires_at"] > now
73
+ waiting_value["expires_at"] = expires_at
74
+ ::SolidCache::Entry.write(w_key, waiting_value.to_json)
75
+ return waiting_position(queue_name, waiting_value["enqueued_at"], now)
76
+ end
77
+ # Expired waiting entry — delete so the new write gets a fresh enqueued_at
78
+ ::SolidCache::Entry.delete_by_key(w_key)
79
+ end
80
+
81
+ # New entry
82
+ value = {state: "waiting", expires_at: expires_at, enqueued_at: now}
83
+ ::SolidCache::Entry.write(w_key, value.to_json)
84
+ waiting_position(queue_name, now, now)
85
+ end
86
+ end
87
+
88
+ # position(queue_name, identity) -> Integer (1-based), 0 (active), or nil
89
+ def position(queue_name, identity)
90
+ now = current_timestamp
91
+ a_key = active_entry_key(queue_name, identity)
92
+ w_key = waiting_entry_key(queue_name, identity)
93
+
94
+ with_ar do
95
+ # Check active
96
+ active_value = read_value(a_key)
97
+ if active_value
98
+ if active_value["expires_at"] <= now
99
+ ::SolidCache::Entry.delete_by_key(a_key)
100
+ return nil
101
+ end
102
+ return 0
103
+ end
104
+
105
+ # Check waiting
106
+ waiting_value = read_value(w_key)
107
+ if waiting_value
108
+ if waiting_value["expires_at"] <= now
109
+ ::SolidCache::Entry.delete_by_key(w_key)
110
+ return nil
111
+ end
112
+ return waiting_position(queue_name, waiting_value["enqueued_at"], now)
113
+ end
114
+
115
+ nil
116
+ end
117
+ end
118
+
119
+ # active_count(queue_name) -> Integer
120
+ def active_count(queue_name)
121
+ now = current_timestamp
122
+
123
+ with_ar do
124
+ active_count_internal(queue_name, now)
125
+ end
126
+ end
127
+
128
+ # admit(queue_name, max_concurrent, count: max_concurrent) -> Array<String>
129
+ def admit(queue_name, max_concurrent, count: max_concurrent)
130
+ now = current_timestamp
131
+ active_ttl = @queue_ttl
132
+
133
+ with_ar do
134
+ admitted = []
135
+
136
+ ActiveRecord::Base.transaction do
137
+ ensure_mutex(queue_name)
138
+ expire_stale_internal(queue_name, now)
139
+
140
+ active = active_count_internal(queue_name, now)
141
+ available = max_concurrent - active
142
+ break admitted if available <= 0
143
+
144
+ admit_count = [available, count].min
145
+
146
+ # Separate waiting prefix guarantees all returned rows are waiting.
147
+ # No Ruby-side state filter needed — fixes the S2-NEWBUG capacity bug.
148
+ # Sort by enqueued_at from the JSON value for FIFO ordering.
149
+ # uncached is required because SolidCache's own write/delete methods do
150
+ # not dirty the Rails query cache, so a subsequent scan in the same
151
+ # request (e.g. release → admit) could read stale waiting rows.
152
+ waiting = ::SolidCache::Entry.uncached do
153
+ ::SolidCache::Entry
154
+ .where("key LIKE ?", waiting_key_pattern(queue_name))
155
+ .to_a
156
+ .map { |entry| [entry, parse_value(entry.value, key: entry.key)] }
157
+ .select { |_, value| value["expires_at"] > now }
158
+ .sort_by { |_, value| value["enqueued_at"] }
159
+ .first(admit_count)
160
+ end
161
+
162
+ waiting.each do |entry, value|
163
+ identity = identity_from_key(entry.key, queue_name)
164
+ value["state"] = "active"
165
+ value["expires_at"] = now + active_ttl
166
+ ::SolidCache::Entry.delete_by_key(waiting_entry_key(queue_name, identity))
167
+ ::SolidCache::Entry.write(active_entry_key(queue_name, identity), value.to_json)
168
+ admitted << identity
169
+ end
170
+ end
171
+
172
+ admitted
173
+ end
174
+ end
175
+
176
+ # release(queue_name, identity) -> Boolean
177
+ def release(queue_name, identity)
178
+ key = active_entry_key(queue_name, identity)
179
+
180
+ with_ar do
181
+ result = ::SolidCache::Entry.delete_by_key(key)
182
+ result.is_a?(Integer) ? result > 0 : result
183
+ end
184
+ end
185
+
186
+ # heartbeat(queue_name, identity, ttl: nil) -> Boolean
187
+ def heartbeat(queue_name, identity, ttl: nil)
188
+ ttl ||= @queue_ttl
189
+ now = current_timestamp
190
+ w_key = waiting_entry_key(queue_name, identity)
191
+ a_key = active_entry_key(queue_name, identity)
192
+
193
+ with_ar do
194
+ # Check waiting first
195
+ value = read_value(w_key)
196
+ if value
197
+ return false if value["expires_at"] <= now
198
+ value["expires_at"] = now + ttl
199
+ ::SolidCache::Entry.write(w_key, value.to_json)
200
+ return true
201
+ end
202
+
203
+ # Check active
204
+ value = read_value(a_key)
205
+ if value
206
+ return false if value["expires_at"] <= now
207
+ value["expires_at"] = now + ttl
208
+ ::SolidCache::Entry.write(a_key, value.to_json)
209
+ return true
210
+ end
211
+
212
+ false
213
+ end
214
+ end
215
+
216
+ # expire_stale(queue_name) -> Hash {waiting: Integer, active: Integer}
217
+ def expire_stale(queue_name)
218
+ now = current_timestamp
219
+
220
+ with_ar do
221
+ ActiveRecord::Base.transaction do
222
+ ensure_mutex(queue_name)
223
+ expire_stale_internal(queue_name, now)
224
+ end
225
+ end
226
+ end
227
+
228
+ private
229
+
230
+ def waiting_entry_key(queue_name, identity)
231
+ "#{KEY_PREFIX}:waiting:#{queue_name}:#{identity}"
232
+ end
233
+
234
+ def active_entry_key(queue_name, identity)
235
+ "#{KEY_PREFIX}:active:#{queue_name}:#{identity}"
236
+ end
237
+
238
+ def mutex_key(queue_name)
239
+ "#{KEY_PREFIX}:mutex:#{queue_name}"
240
+ end
241
+
242
+ def waiting_key_pattern(queue_name)
243
+ "#{KEY_PREFIX}:waiting:#{ActiveRecord::Base.sanitize_sql_like(queue_name)}:%"
244
+ end
245
+
246
+ def active_key_pattern(queue_name)
247
+ "#{KEY_PREFIX}:active:#{ActiveRecord::Base.sanitize_sql_like(queue_name)}:%"
248
+ end
249
+
250
+ def identity_from_key(key, queue_name)
251
+ key_s = key.to_s
252
+ %w[waiting active].each do |state|
253
+ prefix = "#{KEY_PREFIX}:#{state}:#{queue_name}:"
254
+ return key_s.sub(prefix, "") if key_s.start_with?(prefix)
255
+ end
256
+ key_s
257
+ end
258
+
259
+ def current_timestamp
260
+ ::Time.now.to_f
261
+ end
262
+
263
+ # Reads and parses a JSON value using Solid Cache's key_hash-based read.
264
+ # Returns nil if the key does not exist.
265
+ def read_value(key)
266
+ raw = ::SolidCache::Entry.read(key)
267
+ return nil unless raw
268
+ parse_value(raw, key: key)
269
+ end
270
+
271
+ # Parses a JSON value. On parse failure, includes a hashed key fragment
272
+ # for debugging without leaking PII (L0036).
273
+ def parse_value(value, key: nil)
274
+ JSON.parse(value.to_s)
275
+ rescue JSON::ParserError => e
276
+ if key
277
+ digest = Digest::SHA256.hexdigest(key.to_s)[0, 12]
278
+ raise JSON::ParserError, "#{e.message} [key digest: #{digest}]"
279
+ end
280
+ raise
281
+ end
282
+
283
+ # Counts active entries by scanning only the active key prefix.
284
+ # Materializes active rows only (bounded by max_concurrent), not the
285
+ # full queue. Ruby-side JSON parsing for expires_at is unavoidable
286
+ # because Solid Cache's KV schema has no per-entry TTL column.
287
+ def active_count_internal(queue_name, now)
288
+ ::SolidCache::Entry.uncached do
289
+ ::SolidCache::Entry
290
+ .where("key LIKE ?", active_key_pattern(queue_name))
291
+ .count do |entry|
292
+ parse_value(entry.value, key: entry.key)["expires_at"] > now
293
+ end
294
+ end
295
+ end
296
+
297
+ # Counts non-expired waiting entries with enqueued_at <= target.
298
+ # Scans only the waiting key prefix; does not touch active rows.
299
+ def waiting_position(queue_name, enqueued_at, now)
300
+ ::SolidCache::Entry.uncached do
301
+ ::SolidCache::Entry
302
+ .where("key LIKE ?", waiting_key_pattern(queue_name))
303
+ .count do |entry|
304
+ value = parse_value(entry.value, key: entry.key)
305
+ value["expires_at"] > now && value["enqueued_at"] <= enqueued_at
306
+ end
307
+ end
308
+ end
309
+
310
+ def ensure_mutex(queue_name)
311
+ key = mutex_key(queue_name)
312
+ ::SolidCache::Entry.write(key, "1")
313
+ # Verify the mutex row exists using read (key_hash-based lookup)
314
+ raise Error, "Waitmate Solid Cache mutex acquisition failed" unless ::SolidCache::Entry.read(key)
315
+ end
316
+
317
+ # Purges expired entries from both waiting and active prefixes.
318
+ # Called inside the mutex-protected transaction so no concurrent
319
+ # reader sees partially-purged state.
320
+ def expire_stale_internal(queue_name, now)
321
+ waiting_count = 0
322
+ active_count = 0
323
+
324
+ ::SolidCache::Entry.uncached do
325
+ ::SolidCache::Entry.where("key LIKE ?", waiting_key_pattern(queue_name)).each do |entry|
326
+ if parse_value(entry.value, key: entry.key)["expires_at"] <= now
327
+ ::SolidCache::Entry.delete_by_key(entry.key)
328
+ waiting_count += 1
329
+ end
330
+ end
331
+ end
332
+
333
+ ::SolidCache::Entry.uncached do
334
+ ::SolidCache::Entry.where("key LIKE ?", active_key_pattern(queue_name)).each do |entry|
335
+ if parse_value(entry.value, key: entry.key)["expires_at"] <= now
336
+ ::SolidCache::Entry.delete_by_key(entry.key)
337
+ active_count += 1
338
+ end
339
+ end
340
+ end
341
+
342
+ {waiting: waiting_count, active: active_count}
343
+ end
344
+
345
+ def with_ar
346
+ yield
347
+ rescue ActiveRecord::ActiveRecordError => e
348
+ raise ConnectionError,
349
+ "Waitmate could not reach Solid Cache (#{e.class}: #{e.message}). " \
350
+ "Verify the database connection."
351
+ rescue JSON::ParserError => e
352
+ raise Error, "Waitmate Solid Cache store corrupted value (#{e.class}: #{e.message})"
353
+ rescue => e
354
+ raise Error, "Waitmate Solid Cache store error (#{e.class}: #{e.message})"
355
+ end
356
+ end
357
+ end
358
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waitmate
4
+ # Public seam for all queue/storage operations. Host controllers and engine UI
5
+ # call +Waitmate::Store+, never a concrete adapter.
6
+ module Store
7
+ class Error < Waitmate::Error; end
8
+ class ConfigurationError < Error; end
9
+ class ConnectionError < Error; end
10
+
11
+ @adapter = nil
12
+
13
+ class << self
14
+ # enqueue(queue_name, identity, ttl: nil) -> Integer (1-based position) or 0 (already active)
15
+ def enqueue(queue_name, identity, ttl: nil)
16
+ adapter.enqueue(queue_name, identity, ttl: ttl)
17
+ end
18
+
19
+ # position(queue_name, identity) -> Integer (1-based), 0 (active), or nil
20
+ def position(queue_name, identity)
21
+ adapter.position(queue_name, identity)
22
+ end
23
+
24
+ # active_count(queue_name) -> Integer
25
+ def active_count(queue_name)
26
+ adapter.active_count(queue_name)
27
+ end
28
+
29
+ # admit(queue_name, max_concurrent, count: max_concurrent) -> Array<String>
30
+ def admit(queue_name, max_concurrent, count: max_concurrent)
31
+ adapter.admit(queue_name, max_concurrent, count: count)
32
+ end
33
+
34
+ # release(queue_name, identity) -> Boolean
35
+ def release(queue_name, identity)
36
+ adapter.release(queue_name, identity)
37
+ end
38
+
39
+ # heartbeat(queue_name, identity, ttl: nil) -> Boolean
40
+ def heartbeat(queue_name, identity, ttl: nil)
41
+ adapter.heartbeat(queue_name, identity, ttl: ttl)
42
+ end
43
+
44
+ # expire_stale(queue_name) -> Hash {waiting: Integer, active: Integer}
45
+ def expire_stale(queue_name)
46
+ adapter.expire_stale(queue_name)
47
+ end
48
+
49
+ def adapter
50
+ @adapter ||= build_adapter(Waitmate.configuration.adapter)
51
+ end
52
+
53
+ attr_writer :adapter
54
+
55
+ def reset_adapter!
56
+ @adapter = nil
57
+ end
58
+
59
+ private
60
+
61
+ def build_adapter(name)
62
+ case name
63
+ when :redis
64
+ Redis.new
65
+ when :solid_cache
66
+ SolidCache.new
67
+ else
68
+ raise ConfigurationError, "Unknown Waitmate adapter: #{name.inspect}"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "active_support"
5
+ require "active_support/message_encryptor"
6
+
7
+ module Waitmate
8
+ module Ticket
9
+ PURPOSE = "waitmate:ticket"
10
+ SALT = "waitmate ticket v1"
11
+ KEY_LENGTH = 32
12
+
13
+ class Result
14
+ attr_reader :reason
15
+
16
+ def initialize(success:, reason: nil)
17
+ @success = success
18
+ @reason = reason
19
+ end
20
+
21
+ def success?
22
+ @success
23
+ end
24
+ end
25
+
26
+ class << self
27
+ def issue(queue_name:, session_id:)
28
+ encryptor.encrypt_and_sign(
29
+ payload(queue_name, session_id),
30
+ expires_in: Waitmate.configuration.ticket_ttl,
31
+ purpose: PURPOSE
32
+ )
33
+ end
34
+
35
+ def verify(token:, queue_name:, session_id:)
36
+ data = encryptor.decrypt_and_verify(token.to_s, purpose: PURPOSE)
37
+ return failure(:invalid) unless valid_payload?(data, queue_name, session_id)
38
+
39
+ success
40
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage
41
+ failure(:invalid)
42
+ end
43
+
44
+ private
45
+
46
+ def payload(queue_name, session_id)
47
+ {
48
+ "queue_name" => queue_name.to_s,
49
+ "identity_digest" => identity_digest(session_id)
50
+ }
51
+ end
52
+
53
+ def identity_digest(session_id)
54
+ Digest::SHA256.hexdigest(session_id.to_s)
55
+ end
56
+
57
+ def valid_payload?(data, queue_name, session_id)
58
+ data.is_a?(Hash) &&
59
+ data["queue_name"] == queue_name.to_s &&
60
+ data["identity_digest"] == identity_digest(session_id)
61
+ end
62
+
63
+ def encryptor
64
+ @encryptor ||= ActiveSupport::MessageEncryptor.new(
65
+ Rails.application.key_generator.generate_key(SALT, KEY_LENGTH)
66
+ )
67
+ end
68
+
69
+ def success
70
+ Result.new(success: true, reason: nil)
71
+ end
72
+
73
+ def failure(reason)
74
+ Result.new(success: false, reason: reason)
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Waitmate
4
+ VERSION = "0.1.0"
5
+ RUBY_MINIMUM_VERSION = "3.2.0"
6
+ RAILS_MINIMUM_VERSION = "7.1.0"
7
+ end
data/lib/waitmate.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "waitmate/version"
4
+
5
+ module Waitmate
6
+ class Error < StandardError; end
7
+ end
8
+
9
+ require_relative "waitmate/configuration"
10
+ require_relative "waitmate/ticket"
11
+ require_relative "waitmate/store"
12
+ require_relative "waitmate/store/redis"
13
+ require_relative "waitmate/store/solid_cache"
14
+ require_relative "waitmate/controller_concern"
15
+ require_relative "waitmate/engine" if defined?(Rails::Engine)
16
+
17
+ module Waitmate
18
+ class << self
19
+ def configuration
20
+ @configuration ||= Configuration.new
21
+ end
22
+
23
+ def configure
24
+ yield(configuration)
25
+ end
26
+
27
+ def reset_configuration!
28
+ @configuration = Configuration.new
29
+ end
30
+ end
31
+ end
metadata ADDED
@@ -0,0 +1,112 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: waitmate
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - BartOz
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 7.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 7.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: redis
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: solid_cache
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0.7'
47
+ - - "<"
48
+ - !ruby/object:Gem::Version
49
+ version: '2'
50
+ type: :runtime
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - ">="
55
+ - !ruby/object:Gem::Version
56
+ version: '0.7'
57
+ - - "<"
58
+ - !ruby/object:Gem::Version
59
+ version: '2'
60
+ description: Protects expensive controller actions from thundering-herd overload by
61
+ queuing overflow users, issuing signed admission tickets, and letting users wait
62
+ on a lightweight Engine-provided page.
63
+ email:
64
+ - bartek.ozdoba@gmail.com
65
+ executables: []
66
+ extensions: []
67
+ extra_rdoc_files: []
68
+ files:
69
+ - LICENSE.txt
70
+ - README.md
71
+ - app/controllers/waitmate/application_controller.rb
72
+ - app/controllers/waitmate/rooms_controller.rb
73
+ - app/views/waitmate/rooms/show.html.erb
74
+ - bin/quality
75
+ - config/routes.rb
76
+ - lib/generators/waitmate/install_generator.rb
77
+ - lib/generators/waitmate/templates/waitmate.rb
78
+ - lib/waitmate.rb
79
+ - lib/waitmate/configuration.rb
80
+ - lib/waitmate/controller_concern.rb
81
+ - lib/waitmate/engine.rb
82
+ - lib/waitmate/store.rb
83
+ - lib/waitmate/store/redis.rb
84
+ - lib/waitmate/store/solid_cache.rb
85
+ - lib/waitmate/ticket.rb
86
+ - lib/waitmate/version.rb
87
+ homepage: https://github.com/bart-oz/waitmate
88
+ licenses:
89
+ - MIT
90
+ metadata:
91
+ homepage_uri: https://github.com/bart-oz/waitmate
92
+ source_code_uri: https://github.com/bart-oz/waitmate
93
+ changelog_uri: https://github.com/bart-oz/waitmate/blob/main/CHANGELOG.md
94
+ rubygems_mfa_required: 'true'
95
+ rdoc_options: []
96
+ require_paths:
97
+ - lib
98
+ required_ruby_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 3.2.0
103
+ required_rubygems_version: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - ">="
106
+ - !ruby/object:Gem::Version
107
+ version: '0'
108
+ requirements: []
109
+ rubygems_version: 4.0.6
110
+ specification_version: 4
111
+ summary: Virtual waiting room for Rails applications
112
+ test_files: []