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.
@@ -0,0 +1,382 @@
1
+ # frozen_string_literal: true
2
+
3
+ # test/schedules_test.rb
4
+
5
+ require File.expand_path('../test_helper', __FILE__)
6
+
7
+ describe Tuber::Schedules do
8
+ before do
9
+ @beanstalk = Tuber.new(tuber_address)
10
+ @schedules = @beanstalk.schedules
11
+ # Owners are namespaces, so a unique one keeps tests apart.
12
+ @owner = "gemtest.#{Process.pid}.#{rand(1 << 30)}"
13
+ end
14
+
15
+ after do
16
+ begin
17
+ @schedules.sync(@owner, allow_empty: true) if $tuber_schedules
18
+ rescue Tuber::UnexpectedResponse, Tuber::NotConnected
19
+ # the test already failed; don't bury its error under this one
20
+ end
21
+ end
22
+
23
+ def add_two(sync)
24
+ sync.add "rollup", tube: "gemtest.sched", cron: "5 * * * *", body: "rollup"
25
+ sync.add "checkin", tube: "gemtest.sched", every: 60, body: "checkin"
26
+ end
27
+
28
+ describe "for #sync" do
29
+ before { require_schedules! }
30
+
31
+ it "adds, keeps, changes and removes entries" do
32
+ assert_equal [2, 0, 0, 0], @schedules.sync(@owner) { |s| add_two(s) }.to_a
33
+ assert_equal [0, 0, 0, 2], @schedules.sync(@owner) { |s| add_two(s) }.to_a
34
+ assert_equal [[@owner, "checkin"], [@owner, "rollup"]], @schedules.list(@owner).sort
35
+ assert_includes @schedules.list, [@owner, "rollup"]
36
+
37
+ result = @schedules.sync(@owner) do |s|
38
+ s.add "rollup", tube: "gemtest.sched", cron: "15 * * * *", body: "rollup"
39
+ end
40
+ assert_equal [0, 1, 1, 0], result.to_a
41
+ assert_equal [[@owner, "rollup"]], @schedules.list(@owner)
42
+ end
43
+
44
+ it "lands each option in the entry tuber stores" do
45
+ @schedules.sync(@owner) do |s|
46
+ s.add "nightly", tube: "gemtest.sched", cron: "0 2 * * *", zone: "Australia/Melbourne",
47
+ body: "nightly", idp: ["nightly", 300], con: ["maintenance", 2], catchup: 300,
48
+ pri: 1024, delay: 5, ttr: 60
49
+ s.add "hourly", tube: "gemtest.sched", every: "1h", offset: "5m", body: "hourly", catchup: false
50
+ s.add "daily", tube: "gemtest.sched", spec: "@daily", body: "daily"
51
+ end
52
+
53
+ nightly = @schedules.stats(@owner, "nightly")
54
+ assert_equal "0 2 * * * Australia/Melbourne", nightly.spec
55
+ assert_equal "gemtest.sched", nightly.tube
56
+ assert_equal [1024, 5, 60], [nightly.pri, nightly.delay, nightly.ttr]
57
+ assert_equal "idp:nightly:300 con:maintenance:2", nightly.tags
58
+ assert_equal "once within 300s", nightly.catchup
59
+
60
+ hourly = @schedules.stats(@owner, "hourly")
61
+ assert_equal "every 1h offset 5m", hourly.spec
62
+ assert_equal "never", hourly.catchup
63
+ assert_nil hourly.tags
64
+
65
+ daily = @schedules.stats(@owner, "daily")
66
+ assert_equal "once", daily.catchup
67
+ config = Tuber.configuration
68
+ assert_equal [config.default_put_pri, config.default_put_delay, config.default_put_ttr],
69
+ [daily.pri, daily.delay, daily.ttr]
70
+ end
71
+
72
+ it "raises naming the refused entry, and leaves the previous set in place" do
73
+ nightly = lambda do |s|
74
+ s.add "nightly", tube: "gemtest.sched", cron: "0 2 * * *", zone: "Australia/Melbourne", body: "n"
75
+ end
76
+ @schedules.sync(@owner, &nightly)
77
+
78
+ err = assert_raises(Tuber::ScheduleSyncError) do
79
+ @schedules.sync(@owner) do |s|
80
+ s.add "nightly", tube: "gemtest.sched", cron: "0 3 * * *", zone: "Australia/Melbourne", body: "n"
81
+ s.add "typo", tube: "gemtest.sched", cron: "0 2 * * *", zone: "Australia/Melborne", body: "t"
82
+ end
83
+ end
84
+ assert_equal "BAD_SCHEDULE", err.status
85
+ assert_equal "typo", err.entry
86
+ assert_equal @owner, err.owner
87
+ refute err.applied?
88
+ assert_match %r{Australia/Melborne}, err.message
89
+
90
+ assert_equal [[@owner, "nightly"]], @schedules.list(@owner)
91
+ assert_equal "0 2 * * * Australia/Melbourne", @schedules.stats(@owner, "nightly").spec
92
+ # The connection left sync mode: the next sync goes through.
93
+ assert_equal [0, 0, 0, 1], @schedules.sync(@owner, &nightly).to_a
94
+ end
95
+
96
+ it "refuses an empty set unless allow_empty" do
97
+ @schedules.sync(@owner) { |s| add_two(s) }
98
+
99
+ assert_raises(ArgumentError) { @schedules.sync(@owner) { |_s| } }
100
+ assert_raises(ArgumentError) { @schedules.sync(@owner) }
101
+ assert_equal 2, @schedules.list(@owner).size
102
+
103
+ assert_equal [0, 0, 2, 0], @schedules.sync(@owner, allow_empty: true).to_a
104
+ assert_equal [], @schedules.list(@owner)
105
+ end
106
+
107
+ it "sends nothing when an entry is invalid" do
108
+ @schedules.sync(@owner) { |s| add_two(s) }
109
+
110
+ assert_raises(ArgumentError) do
111
+ @schedules.sync(@owner) do |s|
112
+ s.add "rollup", tube: "gemtest.sched", cron: "5 * * * *", body: "rollup"
113
+ s.add "broken", tube: "gemtest.sched", cron: "5 * * * *", every: 60, body: "broken"
114
+ end
115
+ end
116
+ assert_equal 2, @schedules.list(@owner).size
117
+ end
118
+
119
+ it "puts the body into the tube when an entry fires" do
120
+ serializer, parser = Tuber.configuration.job_serializer, Tuber.configuration.job_parser
121
+ Tuber.configure do |config|
122
+ config.job_serializer = lambda { |body| JSON.dump(body) }
123
+ config.job_parser = lambda { |body| JSON.parse(body) }
124
+ end
125
+ tube = "gemtest.fire.#{rand(1 << 30)}"
126
+ @schedules.sync(@owner) do |s|
127
+ s.add "tick", tube: tube, every: 1, body: { "class" => "TickJob", "args" => [1] }
128
+ end
129
+
130
+ job = @beanstalk.tubes.find(tube).reserve(3)
131
+ assert_equal({ "class" => "TickJob", "args" => [1] }, job.body)
132
+ job.delete
133
+ ensure
134
+ Tuber.configure do |config|
135
+ config.job_serializer = serializer
136
+ config.job_parser = parser
137
+ end
138
+ end
139
+
140
+ it "re-runs the whole sync when the connection drops partway" do
141
+ $sync_writes = []
142
+ $drop_mid_sync = true
143
+ TCPSocket.prepend Module.new {
144
+ def write(data)
145
+ $sync_writes << data.to_s if $sync_writes
146
+ super
147
+ end
148
+
149
+ def readline
150
+ if $drop_mid_sync && $sync_writes.last.to_s.start_with?("schedule-put")
151
+ $drop_mid_sync = false
152
+ close # the server sees the connection go and discards what was staged
153
+ raise EOFError
154
+ end
155
+ super
156
+ end
157
+ }
158
+
159
+ result = @schedules.sync(@owner) { |s| add_two(s) }
160
+
161
+ assert_equal 2, $sync_writes.count { |w| w.start_with?("schedule-begin") }
162
+ assert_equal [2, 0, 0, 0], result.to_a
163
+ assert_equal [[@owner, "checkin"], [@owner, "rollup"]], @schedules.list(@owner).sort
164
+ ensure
165
+ # Leave the prepended module inert for the rest of the suite.
166
+ $drop_mid_sync = false
167
+ $sync_writes = nil
168
+ end
169
+
170
+ it "isn't held up by a blocking reserve on the client's connection" do
171
+ tube = "gemtest.empty.#{rand(1 << 30)}"
172
+ reserver = Thread.new do
173
+ @beanstalk.tubes.find(tube).reserve(3)
174
+ rescue Tuber::TimedOutError
175
+ nil
176
+ end
177
+ sleep 0.3 # let the reserve take the connection
178
+
179
+ started = Time.now
180
+ @schedules.sync(@owner) { |s| add_two(s) }
181
+ assert_operator Time.now - started, :<, 1.5, "the sync waited for the reserve to time out"
182
+ ensure
183
+ reserver.join if reserver
184
+ end
185
+
186
+ it "closes the connection it syncs on, refused or not" do
187
+ # A leaked socket closes only when it is garbage collected, which would
188
+ # hide the leak; and earlier tests' unclosed clients must go first.
189
+ GC.start
190
+ GC.disable
191
+ sleep 0.2 # let the server notice anything just closed
192
+ before = @beanstalk.stats["current-connections"]
193
+
194
+ @schedules.sync(@owner) { |s| add_two(s) }
195
+ assert_raises(Tuber::ScheduleSyncError) do
196
+ @schedules.sync(@owner) do |s|
197
+ s.add "typo", tube: "gemtest.sched", cron: "0 2 * * *", zone: "Australia/Melborne", body: "t"
198
+ end
199
+ end
200
+
201
+ after = nil
202
+ 10.times do # the server notices a close a moment later
203
+ after = @beanstalk.stats["current-connections"]
204
+ break if after <= before
205
+ sleep 0.1
206
+ end
207
+ assert_operator after, :<=, before
208
+ ensure
209
+ GC.enable
210
+ end
211
+
212
+ it "leaves nothing behind when an unexpected error interrupts it" do
213
+ $fail_mid_sync = true
214
+ $sync_last_write = nil
215
+ TCPSocket.prepend Module.new {
216
+ def write(data)
217
+ $sync_last_write = data.to_s if $fail_mid_sync
218
+ super
219
+ end
220
+
221
+ def readline
222
+ if $fail_mid_sync && $sync_last_write.to_s.start_with?("schedule-put")
223
+ $fail_mid_sync = false
224
+ raise IOError, "simulated read failure" # e.g. a read timeout: not a dropped socket
225
+ end
226
+ super
227
+ end
228
+ }
229
+
230
+ assert_raises(IOError) { @schedules.sync(@owner) { |s| add_two(s) } }
231
+
232
+ # The client's connection still answers its own commands in step...
233
+ assert_equal "default", @beanstalk.connection.transmit("list-tube-used")[:id]
234
+ # ...nothing of the interrupted sync was committed, and the next sync starts clean.
235
+ assert_equal [], @schedules.list(@owner)
236
+ assert_equal [2, 0, 0, 0], @schedules.sync(@owner) { |s| add_two(s) }.to_a
237
+ ensure
238
+ $fail_mid_sync = false
239
+ end
240
+ end # sync
241
+
242
+ describe "for #stats" do
243
+ before { require_schedules! }
244
+
245
+ it "returns times as Time in the entry's zone, and counters as Integers" do
246
+ @schedules.sync(@owner) do |s|
247
+ s.add "nightly", tube: "gemtest.sched", cron: "0 2 * * *", zone: "Australia/Melbourne", body: "n"
248
+ end
249
+
250
+ stats = @schedules.stats(@owner, "nightly")
251
+ assert_equal @owner, stats.owner
252
+ assert_kind_of Time, stats.synced_at
253
+ assert_kind_of Time, stats.next_fire
254
+ assert_includes [10 * 3600, 11 * 3600], stats.next_fire.utc_offset
255
+ assert_includes [2, 3], stats.next_fire.hour # 3 on a spring-forward day
256
+ assert stats.next_fire < stats.next_fire_2
257
+ assert stats.next_fire_2 < stats.next_fire_3
258
+ assert_equal stats.next_fire_2, stats["next-fire-2"]
259
+ assert_nil stats.last_fire
260
+ assert_nil stats.last_result
261
+ assert_equal 0, stats.total_fires
262
+ assert_equal 0, stats.total_deduped_buried
263
+ assert_equal 0, stats.buried_streak
264
+ end
265
+
266
+ it "returns nil for an entry it doesn't hold" do
267
+ assert_nil @schedules.stats(@owner, "nope")
268
+ end
269
+
270
+ it "keeps a name and tube made of digits as Strings" do
271
+ @schedules.sync(@owner) { |s| s.add "2026", tube: "123", every: 60, body: "b" }
272
+ stats = @schedules.stats(@owner, "2026")
273
+ assert_equal "2026", stats.name
274
+ assert_equal "123", stats.tube
275
+ end
276
+ end # stats
277
+ end # Tuber::Schedules
278
+
279
+ # Replies a healthy server won't produce on demand. No @beanstalk here, so the
280
+ # suite's teardown doesn't talk through the stubbed socket.
281
+ describe "Tuber::Schedules sync refusals" do
282
+ before do
283
+ @client = Tuber.new(tuber_address)
284
+ @owner = "gemtest.stubbed"
285
+ TCPSocket.any_instance.stubs(:write)
286
+ end
287
+
288
+ after do
289
+ @client.close
290
+ end
291
+
292
+ def sync_one
293
+ @client.schedules.sync(@owner) { |s| s.add "a", tube: "gemtest.sched", every: 60, body: "a" }
294
+ end
295
+
296
+ it "says the set is live when the commit couldn't be written" do
297
+ TCPSocket.any_instance.stubs(:readline).returns("SYNCING #{@owner}\r\n", "STAGED\r\n", "INTERNAL_ERROR\r\n")
298
+ err = assert_raises(Tuber::ScheduleSyncError) { sync_one }
299
+ assert_equal "INTERNAL_ERROR", err.status
300
+ assert_nil err.entry
301
+ assert err.applied?
302
+ assert_match(/is live/, err.message)
303
+ end
304
+
305
+ it "says nothing changed when the commit is refused" do
306
+ TCPSocket.any_instance.stubs(:readline).returns("SYNCING #{@owner}\r\n", "STAGED\r\n", "OUT_OF_STORAGE\r\n")
307
+ err = assert_raises(Tuber::ScheduleSyncError) { sync_one }
308
+ assert_equal "OUT_OF_STORAGE", err.status
309
+ refute err.applied?
310
+ assert_match(/Nothing changed/, err.message)
311
+ end
312
+
313
+ it "aborts the sync before raising for a refused entry" do
314
+ TCPSocket.any_instance.expects(:write).with("schedule-abort\r\n")
315
+ TCPSocket.any_instance.stubs(:readline).returns("SYNCING #{@owner}\r\n", "JOB_TOO_BIG\r\n", "ABORTED\r\n")
316
+ err = assert_raises(Tuber::ScheduleSyncError) { sync_one }
317
+ assert_equal "JOB_TOO_BIG", err.status
318
+ assert_equal "a", err.entry
319
+ end
320
+
321
+ it "explains a server without schedules" do
322
+ TCPSocket.any_instance.stubs(:readline).returns("UNKNOWN_COMMAND\r\n")
323
+ err = assert_raises(Tuber::ScheduleSyncError) { sync_one }
324
+ assert_equal "UNKNOWN_COMMAND", err.status
325
+ assert_match(/no recurring schedules/, err.message)
326
+ end
327
+ end
328
+
329
+ describe Tuber::Schedules::Builder do
330
+ before do
331
+ @builder = Tuber::Schedules::Builder.new
332
+ end
333
+
334
+ it "writes the schedule-put arguments: tags before the spec" do
335
+ @builder.add "nightly", tube: "splat.maintenance", cron: "0 2 * * *", zone: "Australia/Melbourne",
336
+ body: "{}", idp: ["nightly", 300], con: "maintenance", catchup: 300, pri: 1024
337
+ entry = @builder.entries.first
338
+ assert_equal "nightly", entry.name
339
+ assert_equal "nightly splat.maintenance 1024 0 120 2 idp:nightly:300 con:maintenance catchup:300 " \
340
+ "0 2 * * * Australia/Melbourne", entry.line
341
+ assert_equal Encoding::BINARY, entry.body.encoding
342
+ end
343
+
344
+ it "counts the body in bytes" do
345
+ @builder.add "utf8", tube: "t", every: 60, body: "café"
346
+ assert_equal "utf8 t 65536 0 120 5 every 60s", @builder.entries.first.line
347
+ end
348
+
349
+ it "writes every: and offset:, and catchup: :never" do
350
+ @builder.add :hourly, tube: "t", every: 3600, offset: 300, body: "b", catchup: :never
351
+ assert_equal "hourly t 65536 0 120 1 catchup:0 every 3600s offset 300s", @builder.entries.first.line
352
+ end
353
+
354
+ it "refuses an entry tuber would refuse, or that would break the line" do
355
+ bad = [
356
+ { tube: "t", body: "b" }, # no spec
357
+ { tube: "t", body: "b", cron: "5 * * * *", every: 60 }, # two specs
358
+ { tube: "t", body: "b", every: 60, zone: "UTC" }, # zone without cron
359
+ { tube: "t", body: "b", cron: "5 * * * *", offset: "5m" }, # offset without every
360
+ { tube: "t", body: "b", spec: "5 * * * * UTC+10:00" }, # a colon reads as a tag
361
+ { tube: "t", body: "b", spec: "5 * * * *\r\nflush-tube t" }, # a second command
362
+ { tube: "-t", body: "b", every: 60 }, # bad tube name
363
+ { tube: "t", body: { "a" => 1 }, every: 60 }, # body isn't a String
364
+ { tube: "t", body: "b", every: 60, idp: "two words" }, # tag with a space
365
+ { tube: "t", body: "b", every: 60, con: "" }, # empty tag key
366
+ { tube: "t", body: "b", every: 60, catchup: -1 },
367
+ { tube: "t", body: "b", every: 60, catchup: "soon" },
368
+ ]
369
+ bad.each do |options|
370
+ assert_raises(ArgumentError, options.inspect) { Tuber::Schedules::Builder.new.add("name", **options) }
371
+ end
372
+
373
+ ["", "-name", "has space", "x" * 201, nil].each do |name|
374
+ assert_raises(ArgumentError, name.inspect) { @builder.add(name, tube: "t", every: 60, body: "b") }
375
+ end
376
+ end
377
+
378
+ it "refuses a name added twice" do
379
+ @builder.add "once", tube: "t", every: 60, body: "b"
380
+ assert_raises(ArgumentError) { @builder.add "once", tube: "t", every: 120, body: "b" }
381
+ end
382
+ end
data/test/test_helper.rb CHANGED
@@ -43,6 +43,24 @@ class MiniTest::Unit::TestCase
43
43
  skip "requires a Tuber server, not stock beanstalkd" unless tuber_server?
44
44
  end
45
45
 
46
+ # Skips the current test unless the server runs recurring schedules. A
47
+ # Tuber older than the scheduler answers UNKNOWN_COMMAND to list-schedules.
48
+ def require_schedules!
49
+ require_tuber!
50
+ if $tuber_schedules.nil?
51
+ client = Tuber.new(tuber_address)
52
+ begin
53
+ client.connection.transmit_raw('list-schedules')
54
+ $tuber_schedules = true
55
+ rescue Tuber::UnknownCommandError
56
+ $tuber_schedules = false
57
+ ensure
58
+ client.close
59
+ end
60
+ end
61
+ skip "requires a Tuber server with recurring schedules" unless $tuber_schedules
62
+ end
63
+
46
64
  # Cleans up all jobs from specific tubes
47
65
  #
48
66
  # @example
data/test/tubes_test.rb CHANGED
@@ -128,13 +128,13 @@ describe Tuber::Tubes do
128
128
 
129
129
  it 'should send watch command with weight' do
130
130
  @connection.expects(:transmit).with("watch foo 4").returns({status: "WATCHING", id: "2"})
131
- @connection.expects(:add_to_watched).with("foo")
131
+ @connection.expects(:add_to_watched).with("foo", 4)
132
132
  @tubes.watch('foo', weight: 4)
133
133
  end
134
134
 
135
135
  it 'should send watch command without weight by default' do
136
136
  @connection.expects(:transmit).with("watch foo").returns({status: "WATCHING", id: "2"})
137
- @connection.expects(:add_to_watched).with("foo")
137
+ @connection.expects(:add_to_watched).with("foo", nil)
138
138
  @tubes.watch('foo')
139
139
  end
140
140
  end # watch with weight
@@ -149,13 +149,26 @@ describe Tuber::Tubes do
149
149
 
150
150
  it 'should send reserve-mode weighted command' do
151
151
  @connection.expects(:transmit).with("reserve-mode weighted").returns({status: "USING", id: "weighted"})
152
+ @connection.expects(:reserve_mode=).with(:weighted)
152
153
  @tubes.reserve_mode(:weighted)
153
154
  end
154
155
 
155
156
  it 'should send reserve-mode fifo command' do
156
157
  @connection.expects(:transmit).with("reserve-mode fifo").returns({status: "USING", id: "fifo"})
158
+ @connection.expects(:reserve_mode=).with(:fifo)
157
159
  @tubes.reserve_mode(:fifo)
158
160
  end
161
+
162
+ it 'should record the mode on the connection so a reconnect can replay it' do
163
+ require_tuber!
164
+ client = Tuber.new(tuber_address)
165
+ begin
166
+ client.tubes.reserve_mode(:weighted)
167
+ assert_equal :weighted, client.connection.reserve_mode
168
+ ensure
169
+ client.close
170
+ end
171
+ end
159
172
  end # reserve_mode
160
173
 
161
174
  describe "for #reserve" do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tuber
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dan Milne
@@ -97,7 +97,6 @@ files:
97
97
  - LICENSE.txt
98
98
  - README.md
99
99
  - Rakefile
100
- - TODO
101
100
  - examples/demo.rb
102
101
  - lib/tuber.rb
103
102
  - lib/tuber/configuration.rb
@@ -106,6 +105,7 @@ files:
106
105
  - lib/tuber/job.rb
107
106
  - lib/tuber/job/collection.rb
108
107
  - lib/tuber/job/record.rb
108
+ - lib/tuber/schedules.rb
109
109
  - lib/tuber/stats.rb
110
110
  - lib/tuber/stats/fast_struct.rb
111
111
  - lib/tuber/stats/stat_struct.rb
@@ -118,6 +118,7 @@ files:
118
118
  - test/job_test.rb
119
119
  - test/jobs_test.rb
120
120
  - test/prompt_regexp_test.rb
121
+ - test/schedules_test.rb
121
122
  - test/stat_struct_test.rb
122
123
  - test/stats_test.rb
123
124
  - test/test_helper.rb
@@ -146,7 +147,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
146
147
  - !ruby/object:Gem::Version
147
148
  version: '0'
148
149
  requirements: []
149
- rubygems_version: 4.0.16
150
+ rubygems_version: 4.0.20
150
151
  specification_version: 4
151
152
  summary: Ruby client for the Tuber job queue server.
152
153
  test_files: []
data/TODO DELETED
@@ -1 +0,0 @@
1
- - Remove connection from pool if it's not responding and be able to add more connections and reattempt later