archipelago 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.
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