archipelago 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/GPL-2 +339 -0
- data/README +39 -0
- data/TODO +3 -0
- data/lib/archipelago.rb +24 -0
- data/lib/current.rb +128 -0
- data/lib/disco.rb +548 -0
- data/lib/hashish.rb +199 -0
- data/lib/pirate.rb +205 -0
- data/lib/tranny.rb +650 -0
- data/lib/treasure.rb +679 -0
- data/profiles/1000xChest#join!-prepare!-commit!.rb +19 -0
- data/profiles/1000xDubloon#[]=(t).rb +19 -0
- data/profiles/1000xDubloon#method_missing(t).rb +21 -0
- data/profiles/README +3 -0
- data/profiles/profile_helper.rb +25 -0
- data/scripts/chest.rb +20 -0
- data/scripts/console +3 -0
- data/scripts/pirate.rb +6 -0
- data/scripts/tranny.rb +20 -0
- data/tests/current_test.rb +28 -0
- data/tests/disco_test.rb +179 -0
- data/tests/pirate_test.rb +75 -0
- data/tests/test_helper.rb +60 -0
- data/tests/tranny_test.rb +70 -0
- data/tests/treasure_test.rb +257 -0
- metadata +69 -0
data/lib/disco.rb
ADDED
@@ -0,0 +1,548 @@
|
|
1
|
+
# Archipelago - a distributed computing toolkit for ruby
|
2
|
+
# Copyright (C) 2006 Martin Kihlgren <zond at troja dot ath dot cx>
|
3
|
+
#
|
4
|
+
# This program is free software; you can redistribute it and/or
|
5
|
+
# modify it under the terms of the GNU General Public License
|
6
|
+
# as published by the Free Software Foundation; either version 2
|
7
|
+
# of the License, or (at your option) any later version.
|
8
|
+
#
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
12
|
+
# GNU General Public License for more details.
|
13
|
+
#
|
14
|
+
# You should have received a copy of the GNU General Public License
|
15
|
+
# along with this program; if not, write to the Free Software
|
16
|
+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
17
|
+
|
18
|
+
require 'socket'
|
19
|
+
require 'thread'
|
20
|
+
require 'ipaddr'
|
21
|
+
require 'pp'
|
22
|
+
require 'current'
|
23
|
+
require 'drb'
|
24
|
+
require 'set'
|
25
|
+
|
26
|
+
module Archipelago
|
27
|
+
|
28
|
+
module Disco
|
29
|
+
|
30
|
+
#
|
31
|
+
# Default address to use.
|
32
|
+
#
|
33
|
+
ADDRESS = "234.2.4.2"
|
34
|
+
#
|
35
|
+
# Default port to use.
|
36
|
+
#
|
37
|
+
PORT = 25242
|
38
|
+
#
|
39
|
+
# Default port range to use for unicast.
|
40
|
+
#
|
41
|
+
UNIPORTS = 25243..26243
|
42
|
+
#
|
43
|
+
# Default lookup timeout.
|
44
|
+
#
|
45
|
+
LOOKUP_TIMEOUT = 10
|
46
|
+
#
|
47
|
+
# Default initial pause between resending lookup queries.
|
48
|
+
# Will be doubled for each resend.
|
49
|
+
#
|
50
|
+
INITIAL_LOOKUP_STANDOFF = 0.1
|
51
|
+
#
|
52
|
+
# Default pause between trying to validate all services we
|
53
|
+
# know about.
|
54
|
+
#
|
55
|
+
VALIDATION_INTERVAL = 60
|
56
|
+
#
|
57
|
+
# Only save stuff that we KNOW we want.
|
58
|
+
#
|
59
|
+
THRIFTY_CACHING = true
|
60
|
+
#
|
61
|
+
# Only reply to the one actually asking about a service.
|
62
|
+
#
|
63
|
+
THRIFTY_REPLYING = true
|
64
|
+
#
|
65
|
+
# Dont send on publish, only on query.
|
66
|
+
#
|
67
|
+
THRIFTY_PUBLISHING = false
|
68
|
+
|
69
|
+
#
|
70
|
+
# A module to simplify publishing services.
|
71
|
+
#
|
72
|
+
# If you include it you can use the publish! method
|
73
|
+
# at your convenience.
|
74
|
+
#
|
75
|
+
# If you want to customize the publishing related behaviour you can
|
76
|
+
# call <b>initialize_publishable</b> with a Hash of options.
|
77
|
+
#
|
78
|
+
# See Archipelago::Treasure::Chest or Archipelago::Tranny::Manager for examples.
|
79
|
+
#
|
80
|
+
# It will store the service_id of this service in a directory beside this
|
81
|
+
# file (publishable.rb) named as the class you include into unless you
|
82
|
+
# define <b>@persistence_provider</b> before you call <b>initialize_publishable</b>.
|
83
|
+
#
|
84
|
+
module Publishable
|
85
|
+
|
86
|
+
#
|
87
|
+
# Will initialize this instance with @service_description and @jockey_options
|
88
|
+
# and merge these with the optionally given <i>:service_description</i> and
|
89
|
+
# <i>:jockey_options</i>.
|
90
|
+
#
|
91
|
+
def initialize_publishable(options = {})
|
92
|
+
@service_description = {
|
93
|
+
:service_id => service_id,
|
94
|
+
:validator => DRbObject.new(self),
|
95
|
+
:service => DRbObject.new(self),
|
96
|
+
:class => self.class.name
|
97
|
+
}.merge(options[:service_description] || {})
|
98
|
+
@jockey_options = options[:jockey_options] || {}
|
99
|
+
end
|
100
|
+
|
101
|
+
#
|
102
|
+
# Create an Archipelago::Disco::Jockey for this instance using @jockey_options
|
103
|
+
# or optionally given <i>:jockey_options</i>.
|
104
|
+
#
|
105
|
+
# Will publish this service using @service_description or optionally given
|
106
|
+
# <i>:service_description</i>.
|
107
|
+
#
|
108
|
+
def publish!(options = {})
|
109
|
+
@jockey ||= Archipelago::Disco::Jockey.new(@jockey_options.merge(options[:jockey_options] || {}))
|
110
|
+
@jockey.publish(Archipelago::Disco::Record.new(@service_description.merge(options[:service_description] || {})))
|
111
|
+
end
|
112
|
+
|
113
|
+
#
|
114
|
+
# We are always valid if we are able to reply.
|
115
|
+
#
|
116
|
+
def valid?
|
117
|
+
true
|
118
|
+
end
|
119
|
+
|
120
|
+
#
|
121
|
+
# Returns our semi-unique id so that we can be found again.
|
122
|
+
#
|
123
|
+
def service_id
|
124
|
+
#
|
125
|
+
# The provider of happy magic persistent hashes of different kinds.
|
126
|
+
#
|
127
|
+
@persistence_provider ||= Archipelago::Hashish::BerkeleyHashishProvider.new(Pathname.new(File.expand_path(__FILE__)).parent.join(self.class.name + ".db"))
|
128
|
+
#
|
129
|
+
# Stuff that didnt fit in any of the other databases.
|
130
|
+
#
|
131
|
+
@metadata ||= @persistence_provider.get_hashish("metadata")
|
132
|
+
service_id = @metadata["service_id"]
|
133
|
+
unless service_id
|
134
|
+
host = "#{Socket::gethostbyname(Socket::gethostname)[0]}" rescue "localhost"
|
135
|
+
service_id = @metadata["service_id"] ||= Digest::SHA1.hexdigest("#{host}:#{Time.new.to_f}:#{self.object_id}:#{rand(1 << 32)}")
|
136
|
+
end
|
137
|
+
return service_id
|
138
|
+
end
|
139
|
+
|
140
|
+
end
|
141
|
+
|
142
|
+
#
|
143
|
+
# A mock validator to be used for dumb systems that dont want
|
144
|
+
# to validate.
|
145
|
+
#
|
146
|
+
class MockValidator
|
147
|
+
def valid?
|
148
|
+
true
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
#
|
153
|
+
# A Hash-like description of a service.
|
154
|
+
#
|
155
|
+
class ServiceDescription
|
156
|
+
IGNORABLE_ATTRIBUTES = Set[:unicast_reply]
|
157
|
+
attr_reader :attributes
|
158
|
+
#
|
159
|
+
# Initialize this service description with a hash
|
160
|
+
# that describes its attributes.
|
161
|
+
#
|
162
|
+
def initialize(hash = {})
|
163
|
+
@attributes = hash
|
164
|
+
end
|
165
|
+
#
|
166
|
+
# Forwards as much as possible to our Hash.
|
167
|
+
#
|
168
|
+
def method_missing(meth, *args, &block)
|
169
|
+
if @attributes.respond_to?(meth)
|
170
|
+
if block
|
171
|
+
@attributes.send(meth, *args, &block)
|
172
|
+
else
|
173
|
+
@attributes.send(meth, *args)
|
174
|
+
end
|
175
|
+
else
|
176
|
+
super(*args)
|
177
|
+
end
|
178
|
+
end
|
179
|
+
#
|
180
|
+
# Returns whether this ServiceDescription matches the given +match+.
|
181
|
+
#
|
182
|
+
def matches?(match)
|
183
|
+
match.each do |key, value|
|
184
|
+
unless IGNORABLE_ATTRIBUTES.include?(key)
|
185
|
+
return false unless @attributes.include?(key) && (value.nil? || @attributes[key] == value)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
true
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
#
|
193
|
+
# A class used to query the Disco network for services.
|
194
|
+
#
|
195
|
+
class Query < ServiceDescription
|
196
|
+
end
|
197
|
+
|
198
|
+
#
|
199
|
+
# A class used to define an existing service.
|
200
|
+
#
|
201
|
+
class Record < ServiceDescription
|
202
|
+
#
|
203
|
+
# Initialize this Record with a hash that must contain an <i>:service_id</i> and a <i>:validator</i>.
|
204
|
+
#
|
205
|
+
def initialize(hash)
|
206
|
+
raise "Record must have an :service_id" unless hash.include?(:service_id)
|
207
|
+
raise "Record must have a :validator" unless hash.include?(:validator)
|
208
|
+
super(hash)
|
209
|
+
end
|
210
|
+
#
|
211
|
+
# Returns whether this service is still valid.
|
212
|
+
#
|
213
|
+
def valid?
|
214
|
+
begin
|
215
|
+
self[:validator].valid?
|
216
|
+
rescue DRb::DRbError => e
|
217
|
+
false
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
#
|
223
|
+
# A container of services.
|
224
|
+
#
|
225
|
+
class ServiceLocker
|
226
|
+
attr_reader :hash
|
227
|
+
include Archipelago::Current::Synchronized
|
228
|
+
def initialize(hash = nil)
|
229
|
+
super
|
230
|
+
@hash = hash || {}
|
231
|
+
end
|
232
|
+
#
|
233
|
+
# Merge this locker with another.
|
234
|
+
#
|
235
|
+
def merge(sd)
|
236
|
+
rval = @hash.clone
|
237
|
+
rval.merge!(sd.hash)
|
238
|
+
ServiceLocker.new(rval)
|
239
|
+
end
|
240
|
+
#
|
241
|
+
# Forwards as much as possible to our Hash.
|
242
|
+
#
|
243
|
+
def method_missing(meth, *args, &block)
|
244
|
+
if @hash.respond_to?(meth)
|
245
|
+
synchronize do
|
246
|
+
if block
|
247
|
+
@hash.send(meth, *args, &block)
|
248
|
+
else
|
249
|
+
@hash.send(meth, *args)
|
250
|
+
end
|
251
|
+
end
|
252
|
+
else
|
253
|
+
super(*args)
|
254
|
+
end
|
255
|
+
end
|
256
|
+
#
|
257
|
+
# Find all containing services matching +match+.
|
258
|
+
#
|
259
|
+
def get_services(match)
|
260
|
+
rval = ServiceLocker.new
|
261
|
+
self.each do |service_id, service_data|
|
262
|
+
rval[service_id] = service_data if service_data.matches?(match) && service_data.valid?
|
263
|
+
end
|
264
|
+
return rval
|
265
|
+
end
|
266
|
+
#
|
267
|
+
# Remove all non-valid services.
|
268
|
+
#
|
269
|
+
def validate!
|
270
|
+
self.clone.each do |service_id, service_data|
|
271
|
+
self.delete(service_id) unless service_data.valid?
|
272
|
+
end
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
#
|
277
|
+
# The main discovery class used to both publish and lookup services.
|
278
|
+
#
|
279
|
+
class Jockey
|
280
|
+
|
281
|
+
attr_reader :new_service_semaphore
|
282
|
+
|
283
|
+
#
|
284
|
+
# Will create a Jockey service running on <i>:address</i> and <i>:port</i> or
|
285
|
+
# ADDRESS and PORT if none are given.
|
286
|
+
#
|
287
|
+
# Will the first available unicast port within <i>:uniports</i> or if not given UNIPORTS for receiving unicast messages.
|
288
|
+
#
|
289
|
+
# Will have a default <i>:lookup_timeout</i> of LOOKUP_TIMEOUT, a default
|
290
|
+
# <i>:initial_lookup_standoff</i> of INITIAL_LOOKUP_STANDOFF and a default
|
291
|
+
# <i>:validation_interval</i> of VALIDATION_INTERVAL.
|
292
|
+
#
|
293
|
+
# Will only cache (and validate, which saves network traffic) stuff
|
294
|
+
# that has been looked up before if <i>:thrifty_caching</i>, or THRIFTY_CACHING if not given.
|
295
|
+
#
|
296
|
+
# Will only reply to the one that sent out the query (and therefore save lots of network traffic)
|
297
|
+
# if <i>:thrifty_replying</i>, or THRIFTY_REPLYING if not given.
|
298
|
+
#
|
299
|
+
# Will send out a multicast when a new service is published unless <i>:thrifty_publishing</i>, or
|
300
|
+
# THRIFTY_PUBLISHING if not given.
|
301
|
+
#
|
302
|
+
# Will reply to all queries to which it has matching local services with a unicast message if <i>:thrifty_replying</i>,
|
303
|
+
# or if not given THRIFTY_REPLYING. Otherwise will reply with multicasts.
|
304
|
+
#
|
305
|
+
def initialize(options = {})
|
306
|
+
@thrifty_caching = options.include?(:thrifty_caching) ? options[:thrifty_caching] : THRIFTY_CACHING
|
307
|
+
@thrifty_replying = options.include?(:thrifty_replying) ? options[:thrifty_replying] : THRIFTY_REPLYING
|
308
|
+
@thrifty_publishing = options.include?(:thrifty_publishing) ? options[:thrifty_publishing] : THRIFTY_PUBLISHING
|
309
|
+
@lookup_timeout = options[:lookup_timeout] || LOOKUP_TIMEOUT
|
310
|
+
@initial_lookup_standoff = options[:initial_lookup_standoff] || INITIAL_LOOKUP_STANDOFF
|
311
|
+
|
312
|
+
@remote_services = ServiceLocker.new
|
313
|
+
@local_services = ServiceLocker.new
|
314
|
+
@subscribed_services = Set.new
|
315
|
+
|
316
|
+
@incoming = Queue.new
|
317
|
+
@outgoing = Queue.new
|
318
|
+
|
319
|
+
@new_service_semaphore = MonitorMixin::ConditionVariable.new(Archipelago::Current::Lock.new)
|
320
|
+
|
321
|
+
@listener = UDPSocket.new
|
322
|
+
@unilistener = UDPSocket.new
|
323
|
+
|
324
|
+
@listener.setsockopt(Socket::IPPROTO_IP,
|
325
|
+
Socket::IP_ADD_MEMBERSHIP,
|
326
|
+
IPAddr.new(options[:address] || ADDRESS).hton + Socket.gethostbyname("0.0.0.0")[3])
|
327
|
+
|
328
|
+
@listener.setsockopt(Socket::SOL_SOCKET,
|
329
|
+
Socket::SO_REUSEADDR,
|
330
|
+
true)
|
331
|
+
begin
|
332
|
+
@listener.setsockopt(Socket::SOL_SOCKET,
|
333
|
+
Socket::SO_REUSEPORT,
|
334
|
+
true)
|
335
|
+
rescue
|
336
|
+
# /moo
|
337
|
+
end
|
338
|
+
@listener.bind('', options[:port] || PORT)
|
339
|
+
|
340
|
+
uniports = options[:uniports] || UNIPORTS
|
341
|
+
this_port = uniports.min
|
342
|
+
begin
|
343
|
+
@unilistener.bind('', this_port)
|
344
|
+
rescue Errno::EADDRINUSE => e
|
345
|
+
if this_port < uniports.max
|
346
|
+
this_port += 1
|
347
|
+
retry
|
348
|
+
else
|
349
|
+
raise e
|
350
|
+
end
|
351
|
+
end
|
352
|
+
@unicast_address = "#{Socket::gethostbyname(Socket::gethostname)[0]}:#{this_port}" rescue "localhost:#{this_port}"
|
353
|
+
|
354
|
+
@sender = UDPSocket.new
|
355
|
+
@sender.connect(options[:address] || ADDRESS, options[:port] || PORT)
|
356
|
+
|
357
|
+
@unisender = UDPSocket.new
|
358
|
+
|
359
|
+
start_listener
|
360
|
+
start_unilistener
|
361
|
+
start_shouter
|
362
|
+
start_picker
|
363
|
+
start_validator(options[:validation_interval] || VALIDATION_INTERVAL)
|
364
|
+
end
|
365
|
+
|
366
|
+
#
|
367
|
+
# Stops all the threads in this instance.
|
368
|
+
#
|
369
|
+
def stop
|
370
|
+
@listener_thread.kill
|
371
|
+
@unilistener_thread.kill
|
372
|
+
@shouter_thread.kill
|
373
|
+
@picker_thread.kill
|
374
|
+
@validator_thread.kill
|
375
|
+
end
|
376
|
+
|
377
|
+
#
|
378
|
+
# Lookup any services matching +match+, optionally with a +timeout+.
|
379
|
+
#
|
380
|
+
# Will immediately return if we know of matching and valid services,
|
381
|
+
# will otherwise send out regular Queries and return as soon as
|
382
|
+
# matching services are found, or when the +timeout+ runs out.
|
383
|
+
#
|
384
|
+
def lookup(match, timeout = @lookup_timeout)
|
385
|
+
match[:unicast_reply] = @unicast_address
|
386
|
+
@subscribed_services << match if @thrifty_caching
|
387
|
+
standoff = @initial_lookup_standoff
|
388
|
+
|
389
|
+
@outgoing << [nil, match]
|
390
|
+
known_services = @remote_services.get_services(match).merge(@local_services.get_services(match))
|
391
|
+
return known_services unless known_services.empty?
|
392
|
+
|
393
|
+
@new_service_semaphore.wait(standoff)
|
394
|
+
standoff *= 2
|
395
|
+
|
396
|
+
t = Time.new
|
397
|
+
while Time.new < t + timeout
|
398
|
+
known_services = @remote_services.get_services(match).merge(@local_services.get_services(match))
|
399
|
+
return known_services unless known_services.empty?
|
400
|
+
|
401
|
+
@new_service_semaphore.wait(standoff)
|
402
|
+
standoff *= 2
|
403
|
+
|
404
|
+
@outgoing << [nil, match]
|
405
|
+
end
|
406
|
+
|
407
|
+
ServiceLocker.new
|
408
|
+
end
|
409
|
+
|
410
|
+
#
|
411
|
+
# Record the given +service+ and broadcast about it.
|
412
|
+
#
|
413
|
+
def publish(service)
|
414
|
+
if service.valid?
|
415
|
+
@local_services[service[:service_id]] = service
|
416
|
+
@new_service_semaphore.broadcast
|
417
|
+
unless @thrifty_publishing
|
418
|
+
@outgoing << [nil, service]
|
419
|
+
end
|
420
|
+
end
|
421
|
+
end
|
422
|
+
|
423
|
+
private
|
424
|
+
|
425
|
+
#
|
426
|
+
# Start the validating thread.
|
427
|
+
#
|
428
|
+
def start_validator(validation_interval)
|
429
|
+
@validator_thread = Thread.new do
|
430
|
+
loop do
|
431
|
+
begin
|
432
|
+
@local_services.validate!
|
433
|
+
@remote_services.validate!
|
434
|
+
sleep(validation_interval)
|
435
|
+
rescue Exception => e
|
436
|
+
puts e
|
437
|
+
pp e.backtrace
|
438
|
+
end
|
439
|
+
end
|
440
|
+
end
|
441
|
+
end
|
442
|
+
|
443
|
+
#
|
444
|
+
# Start the thread sending Records and Queries
|
445
|
+
#
|
446
|
+
def start_shouter
|
447
|
+
@shouter_thread = Thread.new do
|
448
|
+
loop do
|
449
|
+
begin
|
450
|
+
recipient, data = @outgoing.pop
|
451
|
+
if recipient
|
452
|
+
address, port = recipient.split(/:/)
|
453
|
+
@unisender.send(Marshal.dump(data), 0, address, port.to_i)
|
454
|
+
else
|
455
|
+
begin
|
456
|
+
@sender.write(Marshal.dump(data))
|
457
|
+
rescue Errno::ECONNREFUSED => e
|
458
|
+
retry
|
459
|
+
end
|
460
|
+
end
|
461
|
+
rescue Exception => e
|
462
|
+
puts e
|
463
|
+
pp e.backtrace
|
464
|
+
end
|
465
|
+
end
|
466
|
+
end
|
467
|
+
end
|
468
|
+
|
469
|
+
#
|
470
|
+
# Start the thread receiving Records and Queries
|
471
|
+
#
|
472
|
+
def start_listener
|
473
|
+
@listener_thread = Thread.new do
|
474
|
+
loop do
|
475
|
+
begin
|
476
|
+
@incoming << Marshal.load(@listener.recv(1024))
|
477
|
+
rescue Exception => e
|
478
|
+
puts e
|
479
|
+
pp e.backtrace
|
480
|
+
end
|
481
|
+
end
|
482
|
+
end
|
483
|
+
end
|
484
|
+
|
485
|
+
#
|
486
|
+
# Start the thread receiving Records and Queries
|
487
|
+
# on unicast.
|
488
|
+
#
|
489
|
+
def start_unilistener
|
490
|
+
@unilistener_thread = Thread.new do
|
491
|
+
loop do
|
492
|
+
begin
|
493
|
+
@incoming << Marshal.load(@unilistener.recv(1024))
|
494
|
+
rescue Exception => e
|
495
|
+
puts e
|
496
|
+
pp e.backtrace
|
497
|
+
end
|
498
|
+
end
|
499
|
+
end
|
500
|
+
end
|
501
|
+
|
502
|
+
#
|
503
|
+
# Start the thread picking incoming Records and Queries and
|
504
|
+
# handling them properly
|
505
|
+
#
|
506
|
+
def start_picker
|
507
|
+
@picker_thread = Thread.new do
|
508
|
+
loop do
|
509
|
+
begin
|
510
|
+
data = @incoming.pop
|
511
|
+
if Archipelago::Disco::Query === data
|
512
|
+
@local_services.get_services(data).each do |service_id, service_data|
|
513
|
+
if @thrifty_replying
|
514
|
+
@outgoing << [data[:unicast_reply], service_data]
|
515
|
+
else
|
516
|
+
@outgoing << [nil, service_data]
|
517
|
+
end
|
518
|
+
end
|
519
|
+
elsif Archipelago::Disco::Record === data
|
520
|
+
if interesting?(data) && data.valid?
|
521
|
+
@remote_services[data[:service_id]] = data
|
522
|
+
@new_service_semaphore.broadcast
|
523
|
+
end
|
524
|
+
end
|
525
|
+
rescue Exception => e
|
526
|
+
puts e
|
527
|
+
pp e.backtrace
|
528
|
+
end
|
529
|
+
end
|
530
|
+
end
|
531
|
+
end
|
532
|
+
|
533
|
+
#
|
534
|
+
# Are we generous in our caching, or have we been
|
535
|
+
# asked about this type of +publish+ before?
|
536
|
+
#
|
537
|
+
def interesting?(publish)
|
538
|
+
@subscribed_services.each do |subscribed|
|
539
|
+
return true if publish.matches?(subscribed)
|
540
|
+
end
|
541
|
+
return !@thrifty_caching
|
542
|
+
end
|
543
|
+
|
544
|
+
end
|
545
|
+
|
546
|
+
end
|
547
|
+
|
548
|
+
end
|