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/tranny.rb ADDED
@@ -0,0 +1,650 @@
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 'bdb'
19
+ require 'pathname'
20
+ require 'drb'
21
+ require 'current'
22
+ require 'digest/sha1'
23
+ require 'disco'
24
+
25
+ module Archipelago
26
+
27
+ module Tranny
28
+
29
+ #
30
+ # Time before any Transaction will be automatically aborted.
31
+ #
32
+ TRANSACTION_TIMEOUT = 60 * 60
33
+
34
+ #
35
+ # If a member tries to join a transaction that it has allready joined
36
+ # it will receive this.
37
+ #
38
+ class JoinCountException < RuntimeError
39
+ def initialize(member, transaction)
40
+ super("#{member.inspect} has allready joined the transaction #{transaction.inspect}")
41
+ end
42
+ end
43
+
44
+ #
45
+ # If a member tries to commit a transaction not in :active state
46
+ # or abort a transaction in :commited state, or any other grave
47
+ # state offence
48
+ #
49
+ class IllegalOperationException < RuntimeError
50
+ def initialize(operation, transaction)
51
+ super("#{operation.inspect} is illegal for #{transaction.inspect}")
52
+ end
53
+ end
54
+
55
+ #
56
+ # If a member tries to get a transaction that the manager doesnt know about
57
+ # it gets this.
58
+ #
59
+ class UnknownTransactionException < RuntimeError
60
+ def initialize(transaction_id, manager)
61
+ super("#{manager.inspect} doesnt know about any transaction with id #{transaction_id.inspect}")
62
+ end
63
+ end
64
+
65
+ #
66
+ # If a member tries to report its state and the manager doesnt know about the
67
+ # member, it gets this.
68
+ #
69
+ class UnknownMemberException < RuntimeError
70
+ def initialize(member, transaction)
71
+ super("#{transaction.inspect} doesnt know about any member like #{member.inspect}")
72
+ end
73
+ end
74
+
75
+ #
76
+ # If a member misbehaves (or the Transaction is completely fucked up) this will be raised
77
+ #
78
+ class IllegalStateException < RuntimeError
79
+ def initialize(transaction)
80
+ super("#{transaction.inspect} is in a completely fucked up state")
81
+ end
82
+ end
83
+
84
+ #
85
+ # The manager itself.
86
+ #
87
+ # This will be the drb exported object that participants talk to,
88
+ # either directly or through a TransactionProxy.
89
+ #
90
+ # See also the TransactionProxy and Transaction classes.
91
+ #
92
+ class Manager
93
+
94
+ include DRb::DRbUndumped
95
+ include Archipelago::Disco::Publishable
96
+
97
+ attr_accessor :error_logger
98
+ attr_accessor :transaction_timeout
99
+
100
+ #
101
+ # Will use a BerkeleyHashishProvider using tranny_manager.db in the same dir to get its hashes
102
+ # if not <i>:persistence_provider</i> is given.
103
+ #
104
+ # Will create Transactions timing out after <i>:transaction_timeout</i> seconds or TRANSACTION_TIMEOUT
105
+ # if none is given.
106
+ #
107
+ # Will use Archipelago::Disco::Publishable by calling <b>initialize_publishable</b> with +options+.
108
+ #
109
+ def initialize(options = {})
110
+ @persistence_provider = options[:persistence_provider] || Archipelago::Hashish::BerkeleyHashishProvider.new(Pathname.new(File.expand_path(__FILE__)).parent.join("tranny_manager.db"))
111
+
112
+ initialize_publishable(options)
113
+
114
+ @transaction_timeout = options[:transaction_timeout] || TRANSACTION_TIMEOUT
115
+
116
+ @metadata = @persistence_provider.get_hashish("metadata")
117
+ @db = @persistence_provider.get_cached_hashish("db")
118
+ end
119
+
120
+ #
121
+ # Returns a proxy to a newly created Transaction.
122
+ #
123
+ # The Transaction will timeout after <i>:timeout</i> seconds
124
+ # or @transaction_timeout if none is given.
125
+ #
126
+ def begin(options = {})
127
+ options[:manager] = self
128
+ options[:timeout] ||= @transaction_timeout
129
+ return Transaction.new(options).proxy
130
+ end
131
+
132
+ #
133
+ # Used by transactions to notify this manager
134
+ # about an error. Delegates the actual logging
135
+ # to @error_logger.call(exception).
136
+ #
137
+ # Set @error_logger or override this method
138
+ # if you want to log any errors.
139
+ #
140
+ def log_error(exception)
141
+ self.error_logger.call(exception) if self.error_logger
142
+ end
143
+
144
+ #
145
+ # Used by a +transaction+ to store its state for future reference.
146
+ #
147
+ def store_transaction!(transaction)
148
+ @db[transaction.transaction_id] = transaction
149
+ end
150
+
151
+ #
152
+ # Used by a +transaction+ to remove its state when finished.
153
+ #
154
+ def remove_transaction!(transaction)
155
+ @db.delete(transaction.transaction_id)
156
+ end
157
+
158
+ #
159
+ # Used by a transaction proxy to run +meth+ with +args+ on its +transaction_id+.
160
+ #
161
+ def call_instance_method(transaction_id, meth, *args)
162
+ transaction = @db[transaction_id]
163
+ if transaction
164
+ return transaction.send(meth, *args)
165
+ else
166
+ raise UnknownTransactionException.new(transaction_id, self)
167
+ end
168
+ end
169
+
170
+ end
171
+
172
+
173
+
174
+
175
+ #
176
+ # A proxy to a transaction managed by this manager.
177
+ #
178
+ # Used because standard DRbObjects only use object_id
179
+ # to identify objects, while we want the more unique transaction_id.
180
+ #
181
+ # Will also remember the state of the transaction when it detects
182
+ # methods that will terminate it (:commit | :abort).
183
+ #
184
+ class TransactionProxy
185
+ attr_accessor :transaction_id
186
+ def initialize(transaction)
187
+ @manager = transaction.manager
188
+ @transaction_id = transaction.transaction_id
189
+ @state = :unknown
190
+ end
191
+ #
192
+ # Return the cached state of the wrapped Archipelago::Tranny::Transaction
193
+ # if it is known, otherwise fetch it.
194
+ #
195
+ def state
196
+ if @state == :unknown
197
+ method_missing(:state)
198
+ else
199
+ @state
200
+ end
201
+ end
202
+ #
203
+ # Implemented to ensure that all TransactionProxies with the same
204
+ # transaction_id are hashwise equal.
205
+ #
206
+ def hash
207
+ @transaction_id.hash
208
+ end
209
+ #
210
+ # Implemented to ensure that all TransactionProxies with the same
211
+ # transaction_id are hashwise equal.
212
+ #
213
+ def eql?(o)
214
+ if TransactionProxy === o
215
+ o.transaction_id == @transaction_id
216
+ else
217
+ false
218
+ end
219
+ end
220
+ #
221
+ # Forwards everything to our Transaction and remembers
222
+ # returnvalue if necessary.
223
+ #
224
+ def method_missing(meth, *args) #:nodoc:
225
+ rval = @manager.call_instance_method(@transaction_id, meth, *args)
226
+ case meth
227
+ when :abort!
228
+ @state = :aborted
229
+ when :commit!
230
+ @state = rval
231
+ end
232
+ return rval
233
+ end
234
+ end
235
+
236
+
237
+
238
+
239
+ #
240
+ # A transaction managed by the manager.
241
+ #
242
+ # A transaction can have the following states:
243
+ #
244
+ # * :active - When it is first created.
245
+ # * This transaction can be commited or aborted.
246
+ #
247
+ # * :voting - When it has started the two-phase commit and the voting has begun.
248
+ # * This transaction can be aborted.
249
+ #
250
+ # * :commited - After everyone has voted and voted either :unchanged or :commit.
251
+ # * This transaction can not be changed.
252
+ #
253
+ # * :aborted - After someone has called abort! or voted :abort in a vote!
254
+ # * This transaction can not be changed.
255
+ #
256
+ # Anyone that wants to join the transaction must implement the following methods:
257
+ #
258
+ # * abort!(transaction): Abort the provided transaction.
259
+ # * commit!(transaction): Commit the provided transaction.
260
+ # * prepare!(transaction): Prepare for commiting the provided transaction.
261
+ #
262
+ # abort! and commit! should not return any values, but can raise exceptions
263
+ # if required.
264
+ #
265
+ # prepare! must return either :abort, :commit or :unchanged, depending on what
266
+ # the member is prepared to do. :unchanged is only when the member has not changed
267
+ # state during the transaction, and means that it does not require any further
268
+ # notification on the progress of the transaction.
269
+ #
270
+ # A member that has returned :commit on the prepare! must store the transaction
271
+ # proxy in a persistent manner to be able to connect to the manager
272
+ # and get a new copy of the transaction in case of communications failure.
273
+ #
274
+ # A member that reconnects to a crashed transaction manager should not abort! the
275
+ # transaction if the transaction is still in :active state, since the transaction will
276
+ # have been aborted on commit! anyway (since the manager will not be able to prepare! the
277
+ # disconnected member after either the manager or member having crashed) if needed.
278
+ # If the disconnect was just a temporary networking problem, the transaction will
279
+ # continue as planned.
280
+ #
281
+ # A member that reconnects to a crashed transaction manager where the transaction
282
+ # is in :voting state should just wait around and see if it gets prepare! called.
283
+ # In case of temporary network failure the transaction will continue as planned, otherwise
284
+ # it will abort! automatically.
285
+ #
286
+ # A member that reconnects to a disconnected transaction manager where the transaction
287
+ # is in :commited state should just commit its state. Then it must notify the transaction
288
+ # using report_commited! so that the transaction can disappear gracefully.
289
+ #
290
+ # A member that reconnects to a disconnected transaction manager that either doesnt know
291
+ # of the transaction or returns an :aborted transaction may safely abort the state change
292
+ # and forget about the transaction.
293
+ #
294
+ class Transaction
295
+
296
+ include Archipelago::Current::Synchronized
297
+
298
+ attr_reader :state, :transaction_id, :proxy, :manager
299
+
300
+ #
301
+ # Create a transaction managed by the provided +manager+.
302
+ #
303
+ # Will have <i>:manager</i> as TransactionManager, and will timeout
304
+ # after <i>:timeout</i> seconds.
305
+ #
306
+ def initialize(options)
307
+ super()
308
+ #
309
+ # A hash where members are keys and their state the values.
310
+ #
311
+ @members = {}
312
+ @members.extend(Archipelago::Current::Synchronized)
313
+ #
314
+ # We are alive!
315
+ #
316
+ @state = :active
317
+ #
318
+ # We have a timeout!
319
+ #
320
+ @timeout = options[:timeout]
321
+ #
322
+ # We are unique!
323
+ #
324
+ @transaction_id = "#{options[:manager].service_id}:#{Time.new.to_f}:#{self.object_id}:#{rand(1 << 32)}"
325
+ #
326
+ # We have a birth time!
327
+ #
328
+ @created_at = Time.now
329
+ #
330
+ # We have a manager!
331
+ #
332
+ self.manager = options[:manager]
333
+
334
+ store_us_for_future_reference!
335
+
336
+ start_timeout_thread
337
+ end
338
+
339
+ #
340
+ # Store this manager as ours and create a proxy that knows about it.
341
+ #
342
+ # Used by Archipelago::Tranny:Manager when restoring crashed
343
+ # Archipelago::Tranny:Transactions.
344
+ #
345
+ def manager=(manager)
346
+ #
347
+ # We have a manager!
348
+ #
349
+ @manager = DRbObject.new(manager)
350
+ #
351
+ # We have a proxy to send forth into the world!
352
+ #
353
+ @proxy = TransactionProxy.new(self)
354
+ nil
355
+ end
356
+
357
+ #
358
+ # Special dump call to NOT dump our manager or proxy,
359
+ # since DRbObjects dont take kindly to being restored
360
+ # in an environment where they are are known to be invalid.
361
+ #
362
+ def _dump(l)
363
+ return Marshal.dump([
364
+ @members,
365
+ @state,
366
+ @timeout,
367
+ @transaction_id,
368
+ @created_at
369
+ ])
370
+ end
371
+
372
+ def self._load(s)
373
+ members, state, timeout, transaction_id, created_at = Marshal.load(s)
374
+ rval = self.allocate
375
+ rval.instance_variable_set(:@members, members)
376
+ rval.instance_variable_set(:@state, state)
377
+ rval.instance_variable_set(:@timeout, timeout)
378
+ rval.instance_variable_set(:@transaction_id, transaction_id)
379
+ rval.instance_variable_set(:@created_at, created_at)
380
+ rval
381
+ end
382
+
383
+ #
384
+ # Starts the thread that will abort! us automatically
385
+ # after we have lived @timeout
386
+ #
387
+ def start_timeout_thread
388
+ Thread.new do
389
+ now = Time.now
390
+ die_at = @created_at + @timeout
391
+ if die_at > now
392
+ sleep(die_at - now)
393
+ end
394
+ abort!
395
+ end
396
+ nil
397
+ end
398
+
399
+ #
400
+ # What it sounds like.
401
+ #
402
+ def store_us_for_future_reference!
403
+ @manager.store_transaction!(self)
404
+ nil
405
+ end
406
+
407
+ #
408
+ # Remove ourselves, we are redundant
409
+ #
410
+ def remove_us_we_are_redundant!
411
+ @manager.remove_transaction!(self)
412
+ nil
413
+ end
414
+
415
+ #
416
+ # Used by members that failed during commit.
417
+ #
418
+ def report_commited!(member)
419
+ raise UnknownMemberException(member, self) unless @members.include?(member)
420
+ raise IllegalOperationException(:report_commited!, self) unless self.state == :commited
421
+
422
+ @members[member] = :commited
423
+
424
+ remove_us_if_all_are_commited!
425
+ nil
426
+ end
427
+
428
+ #
429
+ # Abort the transaction, sending all participants the abort! message.
430
+ #
431
+ def abort!
432
+ synchronize do
433
+ raise IllegalOperationException.new(:abort!, self) if [:commited, :aborted].include?(self.state)
434
+
435
+ #
436
+ # Set our state.
437
+ #
438
+ @state = :aborted
439
+
440
+ store_us_for_future_reference!
441
+
442
+ #
443
+ # Abort all members.
444
+ #
445
+ threads = []
446
+ @members.clone.each do |member, state|
447
+ raise RuntimeException.new("This is not supposed to be possible, but we are in abort! with member " +
448
+ "#{member.inspect} in state #{state.inspect}") if state == :commited
449
+ if state != :aborted
450
+ threads << Thread.new do
451
+ begin
452
+ member.abort!(self.proxy)
453
+ @members.synchronize do
454
+ @members[member] = :aborted
455
+ end
456
+ rescue Exception => e
457
+ @manager.log_error(e)
458
+ #
459
+ # We must not let the other members stop just
460
+ # because one member failed. No more Mr Nice Guy!
461
+ #
462
+ end
463
+ end
464
+ end
465
+ end
466
+
467
+ #
468
+ # Wait for all members to finish.
469
+ #
470
+ threads.each do |thread|
471
+ thread.join
472
+ end
473
+
474
+ #
475
+ # No use having aborted transactions lying about.
476
+ #
477
+ # NB: This means that disconnected members that cant
478
+ # find their old transactions will have to presume they
479
+ # have been aborted.
480
+ #
481
+ remove_us_we_are_redundant!
482
+ end
483
+ nil
484
+ end
485
+
486
+ #
487
+ # Commits the transaction, returning the new state (:aborted | :commited)
488
+ #
489
+ def commit!
490
+ synchronize do
491
+ raise IllegalOperationException.new(:commit!, self) unless self.state == :active
492
+
493
+ #
494
+ # Vote for the outcome and act on it
495
+ #
496
+ case vote!
497
+ when :abort
498
+ abort!
499
+ when :commit
500
+ _commit!
501
+ when :unchanged
502
+ @state = :commited
503
+ remove_us_we_are_redundant!
504
+ end
505
+
506
+ return @state
507
+ end
508
+ nil
509
+ end
510
+
511
+ #
512
+ # Join a +member+ to this transaction.
513
+ #
514
+ # Will raise a JoinCountException if the given member has allready
515
+ # joined this transaction.
516
+ #
517
+ def join(member)
518
+ @members.synchronize do
519
+ raise IllegalOperationException.new(:join, self) unless self.state == :active
520
+ raise JoinCountException.new(member, self) if @members.include?(member)
521
+
522
+ @members[member] = :active
523
+ end
524
+ store_us_for_future_reference!
525
+ nil
526
+ end
527
+
528
+ private
529
+
530
+ #
531
+ # Commit the transaction, sending all participants the commit message.
532
+ #
533
+ # The transaction is commited by all members having commit! called.
534
+ #
535
+ def _commit!
536
+ #
537
+ # Set our state.
538
+ #
539
+ @state = :commited
540
+
541
+ store_us_for_future_reference!
542
+
543
+ #
544
+ # Commit all members.
545
+ #
546
+ threads = []
547
+ @members.clone.each do |member, state|
548
+ raise RuntimeException.new("This is not supposed to be possible, but we are in _commit with member " +
549
+ "#{member.inspect} in state #{state.inspect}") unless [:prepared, :commited].include?(state)
550
+ threads << Thread.new do
551
+ begin
552
+ member.commit!(self.proxy)
553
+ @members.synchronize do
554
+ @members[member] = :commited
555
+ end
556
+ rescue Exception => e
557
+ @manager.log_error(e)
558
+ #
559
+ # We must not let the other members stop just
560
+ # because one member failed. No more Mr Nice Guy!
561
+ #
562
+ end
563
+ end
564
+ end
565
+
566
+ #
567
+ # Wait for all members to finish.
568
+ #
569
+ threads.each do |thread|
570
+ thread.join
571
+ end
572
+
573
+ remove_us_if_all_are_commited!
574
+ end
575
+
576
+ def remove_us_if_all_are_commited!
577
+ #
578
+ # Check to see if all members have been told about the decision.
579
+ #
580
+ all_have_commited = true
581
+ @members.each do |member, state|
582
+ all_have_commited = false unless state == :comitted
583
+ end
584
+
585
+ #
586
+ # If they have, remove ourselves from the manager.
587
+ #
588
+ remove_us_we_are_redundant! if all_have_commited
589
+ end
590
+
591
+ #
592
+ # Let the members of the transaction vote for its outcome.
593
+ #
594
+ # Members vote by having prepare! called.
595
+ #
596
+ # Valid returnvalues for the prepare! call are:
597
+ # :abort, if the member wants to abort the transaction
598
+ # :commit, if the member wants to commit the transaction
599
+ # :unchanged, if the member has not changed state during the transaction
600
+ #
601
+ def vote!
602
+ raise IllegalOperationException.new(:vote!, self) unless self.state == :active
603
+
604
+ @state = :voting
605
+
606
+ store_us_for_future_reference!
607
+
608
+ return_value = :commit
609
+
610
+ threads = []
611
+ @members.clone.each do |member, state|
612
+ raise RuntimeException.new("This is not supposed to be possible, but we are in vote! with member " +
613
+ "#{member.inspect} in state #{state.inspect}") unless state == :active
614
+ threads << Thread.new do
615
+ this_result = nil
616
+ begin
617
+ this_result = member.prepare!(self.proxy)
618
+ rescue Exception => e
619
+ @manager.log_error(e)
620
+ this_result = :abort
621
+ end
622
+ @members.synchronize do
623
+ case this_result
624
+ when :abort
625
+ @members[member] = :aborted
626
+ return_value = :abort
627
+ when :unchanged
628
+ @members.delete(member)
629
+ else
630
+ @members[member] = :prepared
631
+ end
632
+ end
633
+ end
634
+ end
635
+
636
+ threads.each do |thread|
637
+ thread.join
638
+ end
639
+
640
+ if @members.empty?
641
+ return_value = :unchanged
642
+ end
643
+
644
+ return_value
645
+ end
646
+
647
+ end
648
+ end
649
+
650
+ end