archipelago 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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