tuber 0.5.1 → 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/Gemfile +3 -1
- data/README.md +145 -0
- data/lib/tuber/configuration.rb +18 -0
- data/lib/tuber/connection.rb +243 -23
- data/lib/tuber/errors.rb +57 -3
- data/lib/tuber/schedules.rb +288 -0
- data/lib/tuber/tube/collection.rb +6 -2
- data/lib/tuber/tube/record.rb +21 -10
- data/lib/tuber/version.rb +1 -1
- data/lib/tuber.rb +31 -1
- data/test/connection_test.rb +356 -0
- data/test/errors_test.rb +7 -0
- data/test/schedules_test.rb +382 -0
- data/test/test_helper.rb +18 -0
- data/test/tubes_test.rb +15 -2
- metadata +4 -3
- data/TODO +0 -1
data/test/connection_test.rb
CHANGED
|
@@ -228,6 +228,131 @@ describe Tuber::Connection do
|
|
|
228
228
|
# non-retransmitted command (delete/put) now surfaces as a failure.
|
|
229
229
|
$called = true
|
|
230
230
|
end
|
|
231
|
+
|
|
232
|
+
it "replays tube weights and reserve mode after reconnect" do
|
|
233
|
+
require_tuber!
|
|
234
|
+
client = Tuber.new(tuber_address)
|
|
235
|
+
client.tubes.watch("reconn_heavy", weight: 8)
|
|
236
|
+
client.tubes.watch("reconn_light", weight: 1)
|
|
237
|
+
client.tubes.ignore("default")
|
|
238
|
+
client.tubes.reserve_mode(:weighted)
|
|
239
|
+
|
|
240
|
+
$replayed = []
|
|
241
|
+
$eof_pending = true
|
|
242
|
+
TCPSocket.prepend Module.new {
|
|
243
|
+
def write(data)
|
|
244
|
+
$replayed << data.to_s if $replayed
|
|
245
|
+
super
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def readline
|
|
249
|
+
if $eof_pending
|
|
250
|
+
$eof_pending = false
|
|
251
|
+
raise EOFError
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
super
|
|
255
|
+
end
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
client.connection.transmit("list-tubes-watched")
|
|
259
|
+
sent = $replayed
|
|
260
|
+
|
|
261
|
+
assert_includes sent, "watch reconn_heavy 8\r\n"
|
|
262
|
+
assert_includes sent, "watch reconn_light 1\r\n"
|
|
263
|
+
assert_includes sent, "reserve-mode weighted\r\n"
|
|
264
|
+
# Ordering matters: watch, then drop default, then set the mode.
|
|
265
|
+
assert sent.index("watch reconn_heavy 8\r\n") < sent.index("ignore default\r\n"),
|
|
266
|
+
"expected the watches to be replayed before 'ignore default'"
|
|
267
|
+
assert sent.index("ignore default\r\n") < sent.index("reserve-mode weighted\r\n"),
|
|
268
|
+
"expected 'reserve-mode' to be replayed after the tubes are watched"
|
|
269
|
+
ensure
|
|
270
|
+
# Leave the prepended module inert for the rest of the suite.
|
|
271
|
+
$eof_pending = false
|
|
272
|
+
$replayed = nil
|
|
273
|
+
client.close if client
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
it "keeps watching default across a reconnect when default is watched" do
|
|
277
|
+
client = Tuber.new(tuber_address)
|
|
278
|
+
client.tubes.watch("reconn_extra")
|
|
279
|
+
|
|
280
|
+
$replayed = []
|
|
281
|
+
$eof_pending = true
|
|
282
|
+
TCPSocket.prepend Module.new {
|
|
283
|
+
def write(data)
|
|
284
|
+
$replayed << data.to_s if $replayed
|
|
285
|
+
super
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def readline
|
|
289
|
+
if $eof_pending
|
|
290
|
+
$eof_pending = false
|
|
291
|
+
raise EOFError
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
super
|
|
295
|
+
end
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
client.connection.transmit("list-tubes-watched")
|
|
299
|
+
sent = $replayed
|
|
300
|
+
|
|
301
|
+
assert_includes sent, "watch reconn_extra\r\n"
|
|
302
|
+
refute_includes sent, "ignore default\r\n"
|
|
303
|
+
assert_equal %w[default reconn_extra].sort,
|
|
304
|
+
client.tubes.watched.map(&:name).sort
|
|
305
|
+
ensure
|
|
306
|
+
$eof_pending = false
|
|
307
|
+
$replayed = nil
|
|
308
|
+
client.close if client
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
it "still reserves by weight after a reconnect" do
|
|
312
|
+
require_tuber!
|
|
313
|
+
consumer = Tuber.new(tuber_address)
|
|
314
|
+
consumer.tubes.watch("reconn_heavy", weight: 8)
|
|
315
|
+
consumer.tubes.watch("reconn_light", weight: 1)
|
|
316
|
+
consumer.tubes.ignore("default")
|
|
317
|
+
consumer.tubes.reserve_mode(:weighted)
|
|
318
|
+
|
|
319
|
+
producer = Tuber.new(tuber_address)
|
|
320
|
+
100.times do |i|
|
|
321
|
+
producer.tubes["reconn_heavy"].put("h#{i}")
|
|
322
|
+
producer.tubes["reconn_light"].put("l#{i}")
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
$eof_pending = true
|
|
326
|
+
TCPSocket.prepend Module.new {
|
|
327
|
+
def readline
|
|
328
|
+
if $eof_pending
|
|
329
|
+
$eof_pending = false
|
|
330
|
+
raise EOFError
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
super
|
|
334
|
+
end
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
counts = Hash.new(0)
|
|
338
|
+
60.times do
|
|
339
|
+
job = consumer.tubes.reserve(2)
|
|
340
|
+
counts[job.body[0]] += 1
|
|
341
|
+
job.delete
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# 8:1 over interleaved puts is ~53/7; FIFO would be ~30/30.
|
|
345
|
+
assert counts["h"] > 3 * counts["l"],
|
|
346
|
+
"expected weighted reserve after reconnect, got heavy=#{counts["h"]} light=#{counts["l"]}"
|
|
347
|
+
ensure
|
|
348
|
+
$eof_pending = false
|
|
349
|
+
if producer
|
|
350
|
+
producer.connection.transmit("flush-tube reconn_heavy") rescue nil
|
|
351
|
+
producer.connection.transmit("flush-tube reconn_light") rescue nil
|
|
352
|
+
producer.close
|
|
353
|
+
end
|
|
354
|
+
consumer.close if consumer
|
|
355
|
+
end
|
|
231
356
|
end # transmit
|
|
232
357
|
|
|
233
358
|
describe 'for idempotent insert response' do
|
|
@@ -251,6 +376,217 @@ describe Tuber::Connection do
|
|
|
251
376
|
end
|
|
252
377
|
end # idempotent insert response
|
|
253
378
|
|
|
379
|
+
describe 'for #reconnect!' do
|
|
380
|
+
before do
|
|
381
|
+
@host = tuber_address
|
|
382
|
+
@bc = Tuber::Connection.new(@host)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
after do
|
|
386
|
+
@bc.close rescue nil
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
it "re-establishes a connection that was closed" do
|
|
390
|
+
@bc.close
|
|
391
|
+
assert_nil @bc.connection
|
|
392
|
+
|
|
393
|
+
@bc.reconnect!
|
|
394
|
+
|
|
395
|
+
assert_kind_of TCPSocket, @bc.connection
|
|
396
|
+
assert_equal 'OK', @bc.transmit('stats')[:status]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
it "replays watched tubes, weights and reserve mode" do
|
|
400
|
+
require_tuber!
|
|
401
|
+
client = Tuber.new(tuber_address)
|
|
402
|
+
client.tubes.watch("reconn_bang_heavy", weight: 8)
|
|
403
|
+
client.tubes.watch("reconn_bang_light", weight: 1)
|
|
404
|
+
client.tubes.ignore("default")
|
|
405
|
+
client.tubes.reserve_mode(:weighted)
|
|
406
|
+
|
|
407
|
+
$replayed = []
|
|
408
|
+
TCPSocket.prepend Module.new {
|
|
409
|
+
def write(data)
|
|
410
|
+
$replayed << data.to_s if $replayed
|
|
411
|
+
super
|
|
412
|
+
end
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
client.connection.reconnect!
|
|
416
|
+
sent = $replayed
|
|
417
|
+
|
|
418
|
+
assert_includes sent, "watch reconn_bang_heavy 8\r\n"
|
|
419
|
+
assert_includes sent, "watch reconn_bang_light 1\r\n"
|
|
420
|
+
assert_includes sent, "ignore default\r\n"
|
|
421
|
+
assert_includes sent, "reserve-mode weighted\r\n"
|
|
422
|
+
assert_equal %w[reconn_bang_heavy reconn_bang_light].sort,
|
|
423
|
+
client.tubes.watched.map(&:name).sort
|
|
424
|
+
ensure
|
|
425
|
+
# Leave the prepended module inert for the rest of the suite.
|
|
426
|
+
$replayed = nil
|
|
427
|
+
client.close if client
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
it "retries while the server is still refusing connections" do
|
|
431
|
+
# The outage shape: the socket is gone and the server will not accept
|
|
432
|
+
# for another second or two. A single connect attempt loses that race.
|
|
433
|
+
@bc.close
|
|
434
|
+
real_socket = TCPSocket.new(tuber_address, 11300)
|
|
435
|
+
TCPSocket.stubs(:new).
|
|
436
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
437
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
438
|
+
returns(real_socket)
|
|
439
|
+
|
|
440
|
+
@bc.reconnect!(retry_interval: 0.01)
|
|
441
|
+
|
|
442
|
+
assert_same real_socket, @bc.connection
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
it "retries a connect that fails for a reason other than ECONNREFUSED" do
|
|
446
|
+
# A remote host mid-restart answers EHOSTUNREACH or ETIMEDOUT as readily
|
|
447
|
+
# as ECONNREFUSED; those must not escape as a raw Errno either.
|
|
448
|
+
@bc.close
|
|
449
|
+
real_socket = TCPSocket.new(tuber_address, 11300)
|
|
450
|
+
TCPSocket.stubs(:new).
|
|
451
|
+
raises(Errno::EHOSTUNREACH.new).then.
|
|
452
|
+
returns(real_socket)
|
|
453
|
+
|
|
454
|
+
@bc.reconnect!(retry_interval: 0.01)
|
|
455
|
+
|
|
456
|
+
assert_same real_socket, @bc.connection
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
it "raises NotConnected naming the underlying error when the server stays down" do
|
|
460
|
+
@bc.close
|
|
461
|
+
TCPSocket.stubs(:new).raises(Errno::ECONNREFUSED.new)
|
|
462
|
+
|
|
463
|
+
err = assert_raises(Tuber::NotConnected) { @bc.reconnect!(retry_interval: 0.01) }
|
|
464
|
+
assert_match(/ECONNREFUSED/, err.message)
|
|
465
|
+
assert_kind_of Errno::ECONNREFUSED, err.cause
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
it "is reachable from the Tuber instance" do
|
|
469
|
+
client = Tuber.new(tuber_address)
|
|
470
|
+
client.tubes.watch("reconn_delegate")
|
|
471
|
+
|
|
472
|
+
client.reconnect!
|
|
473
|
+
|
|
474
|
+
assert_includes client.tubes.watched.map(&:name), "reconn_delegate"
|
|
475
|
+
ensure
|
|
476
|
+
client.close if client
|
|
477
|
+
end
|
|
478
|
+
end # reconnect!
|
|
479
|
+
|
|
480
|
+
describe 'for connect retries' do
|
|
481
|
+
after do
|
|
482
|
+
Tuber.configure do |config|
|
|
483
|
+
config.connect_retries = 0
|
|
484
|
+
config.connect_retry_interval = Tuber::Connection::DEFAULT_RETRY_INTERVAL
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
it "should make a single connect attempt by default" do
|
|
489
|
+
TCPSocket.expects(:new).times(1).raises(Errno::ECONNREFUSED.new)
|
|
490
|
+
|
|
491
|
+
assert_raises(Tuber::NotConnected) { Tuber::Connection.new('localhost') }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
it "should retry the initial connect when connect_retries is configured" do
|
|
495
|
+
real_socket = TCPSocket.new(tuber_address, 11300)
|
|
496
|
+
Tuber.configure do |config|
|
|
497
|
+
config.connect_retries = 3
|
|
498
|
+
config.connect_retry_interval = 0.01
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
TCPSocket.stubs(:new).
|
|
502
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
503
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
504
|
+
returns(real_socket)
|
|
505
|
+
|
|
506
|
+
bc = Tuber::Connection.new(tuber_address)
|
|
507
|
+
|
|
508
|
+
assert_same real_socket, bc.connection
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
it "should give up after connect_retries and name the cause" do
|
|
512
|
+
Tuber.configure do |config|
|
|
513
|
+
config.connect_retries = 2
|
|
514
|
+
config.connect_retry_interval = 0.01
|
|
515
|
+
end
|
|
516
|
+
TCPSocket.expects(:new).times(3).raises(Errno::ECONNREFUSED.new)
|
|
517
|
+
|
|
518
|
+
err = assert_raises(Tuber::NotConnected) { Tuber::Connection.new('localhost') }
|
|
519
|
+
assert_match(/ECONNREFUSED/, err.message)
|
|
520
|
+
assert_kind_of Errno::ECONNREFUSED, err.cause
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
it "should lift an established connection's reconnect budget too" do
|
|
524
|
+
# One knob for "how long should a client ride out a server restart":
|
|
525
|
+
# raising connect_retries has to cover the connection you already hold,
|
|
526
|
+
# not only a cold start.
|
|
527
|
+
bc = Tuber::Connection.new(tuber_address)
|
|
528
|
+
real_socket = TCPSocket.new(tuber_address, 11300)
|
|
529
|
+
|
|
530
|
+
Tuber.configure do |config|
|
|
531
|
+
config.connect_retries = 5
|
|
532
|
+
config.connect_retry_interval = 0.01
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
# One dropped read, then four refused connects: past the built-in budget
|
|
536
|
+
# of MAX_RETRIES, inside the configured one.
|
|
537
|
+
$eof_pending = true
|
|
538
|
+
TCPSocket.prepend Module.new {
|
|
539
|
+
def readline
|
|
540
|
+
if $eof_pending
|
|
541
|
+
$eof_pending = false
|
|
542
|
+
raise EOFError
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
super
|
|
546
|
+
end
|
|
547
|
+
}
|
|
548
|
+
TCPSocket.stubs(:new).
|
|
549
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
550
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
551
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
552
|
+
raises(Errno::ECONNREFUSED.new).then.
|
|
553
|
+
returns(real_socket)
|
|
554
|
+
|
|
555
|
+
assert_equal 'OK', bc.transmit('stats')[:status]
|
|
556
|
+
assert_same real_socket, bc.connection
|
|
557
|
+
ensure
|
|
558
|
+
$eof_pending = false
|
|
559
|
+
end
|
|
560
|
+
end # connect retries
|
|
561
|
+
|
|
562
|
+
describe 'for NotConnected messages' do
|
|
563
|
+
it "should keep the historical message as a prefix" do
|
|
564
|
+
# Consumers match on this string. The cause is appended, never inserted.
|
|
565
|
+
TCPSocket.stubs(:new).raises(Errno::ECONNREFUSED.new)
|
|
566
|
+
|
|
567
|
+
err = assert_raises(Tuber::NotConnected) { Tuber::Connection.new('localhost:8544') }
|
|
568
|
+
|
|
569
|
+
assert err.message.start_with?("Connection to beanstalk 'localhost:8544' is closed!"),
|
|
570
|
+
"expected the historical message as a prefix, got #{err.message.inspect}"
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
it "should not blame an unrelated in-flight exception" do
|
|
574
|
+
# Commands are very often issued from inside someone else's rescue block,
|
|
575
|
+
# where $! is still set. The cause named has to be the one that stopped
|
|
576
|
+
# this connect, not whatever the caller happened to be handling.
|
|
577
|
+
bc = Tuber::Connection.new(tuber_address)
|
|
578
|
+
bc.close
|
|
579
|
+
|
|
580
|
+
err = begin
|
|
581
|
+
raise ArgumentError, "something else entirely"
|
|
582
|
+
rescue ArgumentError
|
|
583
|
+
assert_raises(Tuber::NotConnected) { bc.transmit('stats') }
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
assert_equal "Connection to beanstalk '#{bc.host}:#{bc.port}' is closed!", err.message
|
|
587
|
+
end
|
|
588
|
+
end # NotConnected messages
|
|
589
|
+
|
|
254
590
|
describe 'for #close' do
|
|
255
591
|
before do
|
|
256
592
|
@host = 'localhost'
|
|
@@ -288,4 +624,24 @@ describe Tuber::Connection do
|
|
|
288
624
|
assert_raises(Tuber::DrainingError) { @bc.transmit("put 0 0 100 4\r\ntest") }
|
|
289
625
|
end
|
|
290
626
|
end # drain and undrain
|
|
627
|
+
|
|
628
|
+
describe 'for a put refused with OUT_OF_STORAGE' do
|
|
629
|
+
# Tuber answers OUT_OF_STORAGE once a persistent server's disk budget
|
|
630
|
+
# (--max-storage-bytes) is spent. It must raise like OUT_OF_MEMORY does,
|
|
631
|
+
# not come back as a hash a caller would read as a successful put.
|
|
632
|
+
it "should raise" do
|
|
633
|
+
@bc = Tuber::Connection.new('localhost')
|
|
634
|
+
TCPSocket.any_instance.stubs(:write)
|
|
635
|
+
TCPSocket.any_instance.expects(:readline).returns("OUT_OF_STORAGE\r\n")
|
|
636
|
+
err = begin
|
|
637
|
+
@bc.transmit("put 0 0 100 4\r\ntest")
|
|
638
|
+
nil
|
|
639
|
+
rescue Tuber::UnexpectedResponse => e
|
|
640
|
+
e
|
|
641
|
+
end
|
|
642
|
+
refute_nil err, "OUT_OF_STORAGE came back as a result instead of raising"
|
|
643
|
+
assert_equal 'OUT_OF_STORAGE', err.status
|
|
644
|
+
assert_equal 'OutOfStorageError', err.class.name.split('::').last
|
|
645
|
+
end
|
|
646
|
+
end # OUT_OF_STORAGE
|
|
291
647
|
end # Tuber::Connection
|
data/test/errors_test.rb
CHANGED
|
@@ -32,4 +32,11 @@ describe "Tuber::Errors" do
|
|
|
32
32
|
assert_equal 'reserve 0', @klazz.cmd
|
|
33
33
|
assert_equal 'EXPECTED_CRLF', @klazz.status
|
|
34
34
|
end
|
|
35
|
+
|
|
36
|
+
it 'should raise proper exception for invalid status OUT_OF_STORAGE' do
|
|
37
|
+
@klazz = Tuber::UnexpectedResponse.from_status("OUT_OF_STORAGE", "put 0 0 120 4")
|
|
38
|
+
assert_kind_of(Tuber::UnexpectedResponse, @klazz)
|
|
39
|
+
assert_equal 'OutOfStorageError', @klazz.class.name.split('::').last
|
|
40
|
+
assert_equal 'OUT_OF_STORAGE', @klazz.status
|
|
41
|
+
end
|
|
35
42
|
end
|