pg_pipeline 0.2.4 → 0.3.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 +85 -0
- data/DESIGN.md +40 -0
- data/README.md +105 -1
- data/lib/pg_pipeline/bounded_queue.rb +10 -12
- data/lib/pg_pipeline/client.rb +14 -21
- data/lib/pg_pipeline/connection_driver.rb +215 -109
- data/lib/pg_pipeline/pool.rb +111 -61
- data/lib/pg_pipeline/prepared_statement.rb +1 -1
- data/lib/pg_pipeline/request.rb +69 -11
- data/lib/pg_pipeline/runtime/notification.rb +32 -0
- data/lib/pg_pipeline/runtime/queue.rb +31 -0
- data/lib/pg_pipeline/runtime/semaphore.rb +138 -0
- data/lib/pg_pipeline/runtime/task.rb +124 -0
- data/lib/pg_pipeline/runtime.rb +98 -0
- data/lib/pg_pipeline/session.rb +4 -2
- data/lib/pg_pipeline/session_guard.rb +40 -24
- data/lib/pg_pipeline/version.rb +1 -1
- data/lib/pg_pipeline.rb +1 -0
- metadata +26 -19
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "pg"
|
|
4
|
-
require "async"
|
|
5
|
-
require "async/queue"
|
|
6
4
|
|
|
7
5
|
require_relative "errors"
|
|
6
|
+
require_relative "runtime"
|
|
8
7
|
require_relative "bounded_queue"
|
|
9
8
|
require_relative "server_caps"
|
|
10
9
|
require_relative "request"
|
|
@@ -20,6 +19,17 @@ module PgPipeline
|
|
|
20
19
|
:draining, :needs_flush, :writer_armed, :request_event_pending,
|
|
21
20
|
:owner_task, :reader_task, :writer_task
|
|
22
21
|
|
|
22
|
+
attr_writer :readable_events, :results_read, :units_completed, :flush_calls,
|
|
23
|
+
:flush_incomplete, :dispatches, :leaked_watchers
|
|
24
|
+
|
|
25
|
+
def readable_events = @readable_events || 0
|
|
26
|
+
def results_read = @results_read || 0
|
|
27
|
+
def units_completed = @units_completed || 0
|
|
28
|
+
def flush_calls = @flush_calls || 0
|
|
29
|
+
def flush_incomplete = @flush_incomplete || 0
|
|
30
|
+
def dispatches = @dispatches || 0
|
|
31
|
+
def leaked_watchers = @leaked_watchers || 0
|
|
32
|
+
|
|
23
33
|
def initialize(conn, max_pending: DEFAULT_MAX_PENDING, max_in_flight: DEFAULT_MAX_IN_FLIGHT)
|
|
24
34
|
@max_pending = DriverOps.positive_integer!(max_pending, :max_pending)
|
|
25
35
|
@max_in_flight = DriverOps.positive_integer!(max_in_flight, :max_in_flight)
|
|
@@ -27,30 +37,22 @@ module PgPipeline
|
|
|
27
37
|
@conn = conn
|
|
28
38
|
@caps = ServerCaps.from_connection(conn)
|
|
29
39
|
@caps.assert_supported!
|
|
40
|
+
DriverOps.warn_flush_coupling_once(@caps)
|
|
30
41
|
|
|
31
42
|
@requests = BoundedQueue.new(@max_pending)
|
|
32
|
-
@events =
|
|
33
|
-
@reader_rearm =
|
|
34
|
-
@writer_commands =
|
|
43
|
+
@events = Runtime::Queue.new
|
|
44
|
+
@reader_rearm = Runtime::Queue.new
|
|
45
|
+
@writer_commands = Runtime::Queue.new
|
|
35
46
|
|
|
36
|
-
@
|
|
37
|
-
@
|
|
38
|
-
|
|
47
|
+
@accepting = @running = @draining = @needs_flush = @writer_armed = @request_event_pending = false
|
|
48
|
+
@readable_events = @results_read = @units_completed = @flush_calls =
|
|
49
|
+
@flush_incomplete = @dispatches = @leaked_watchers = @submitting = 0
|
|
50
|
+
@socket = @owner_task = @reader_task = @writer_task = @dispatching = nil
|
|
39
51
|
|
|
40
|
-
@
|
|
41
|
-
@running = false
|
|
42
|
-
@draining = false
|
|
43
|
-
@needs_flush = false
|
|
44
|
-
@writer_armed = false
|
|
45
|
-
@request_event_pending = false
|
|
46
|
-
|
|
47
|
-
@socket = nil
|
|
48
|
-
@owner_task = nil
|
|
49
|
-
@reader_task = nil
|
|
50
|
-
@writer_task = nil
|
|
52
|
+
@inflight = []
|
|
51
53
|
end
|
|
52
54
|
|
|
53
|
-
def start
|
|
55
|
+
def start = DriverOps.start(self)
|
|
54
56
|
def submit(request) = DriverOps.submit(self, request)
|
|
55
57
|
def load = @requests.size + @inflight.size + @submitting + (@dispatching ? 1 : 0)
|
|
56
58
|
def available? = @accepting && @running
|
|
@@ -63,29 +65,38 @@ module PgPipeline
|
|
|
63
65
|
pending: @requests.size,
|
|
64
66
|
in_flight: @inflight.size,
|
|
65
67
|
submitting: @submitting,
|
|
66
|
-
needs_flush: @needs_flush
|
|
68
|
+
needs_flush: @needs_flush,
|
|
69
|
+
fast_sync: @caps.fast_sync?,
|
|
70
|
+
readable_events: readable_events,
|
|
71
|
+
results_read: results_read,
|
|
72
|
+
units_completed: units_completed,
|
|
73
|
+
flush_calls: flush_calls,
|
|
74
|
+
flush_incomplete: flush_incomplete,
|
|
75
|
+
dispatches: dispatches,
|
|
76
|
+
units_per_readable: DriverOps.ratio(units_completed, readable_events),
|
|
77
|
+
results_per_readable: DriverOps.ratio(results_read, readable_events),
|
|
78
|
+
flush_calls_per_unit: DriverOps.ratio(flush_calls, units_completed),
|
|
79
|
+
leaked_watchers: leaked_watchers
|
|
67
80
|
}
|
|
68
81
|
end
|
|
69
82
|
|
|
70
83
|
def health_check(timeout)
|
|
71
84
|
return true unless available?
|
|
72
85
|
|
|
73
|
-
probe = Request.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
probe.cancel! unless probe.settled?
|
|
88
|
-
end
|
|
86
|
+
probe = Request.build("SELECT 1", nil)
|
|
87
|
+
submit(probe)
|
|
88
|
+
Runtime.with_timeout(timeout) { probe.wait }
|
|
89
|
+
true
|
|
90
|
+
rescue Runtime::TimeoutError
|
|
91
|
+
DriverOps.abort_timed_out_health_probe(self, probe)
|
|
92
|
+
rescue QueryError, PipelineAbortedError
|
|
93
|
+
true
|
|
94
|
+
rescue ShutdownError, NotDispatchedError
|
|
95
|
+
true
|
|
96
|
+
rescue ConnectionLostError, PG::Error
|
|
97
|
+
false
|
|
98
|
+
ensure
|
|
99
|
+
probe.cancel! if probe && !probe.settled?
|
|
89
100
|
end
|
|
90
101
|
|
|
91
102
|
def graceful_close = DriverOps.graceful_close(self)
|
|
@@ -96,19 +107,30 @@ module PgPipeline
|
|
|
96
107
|
end
|
|
97
108
|
|
|
98
109
|
module DriverOps
|
|
110
|
+
WATCHER_JOIN_TIMEOUT = 2.0
|
|
111
|
+
OWNER_JOIN_TIMEOUT = 5.0
|
|
112
|
+
|
|
99
113
|
module_function
|
|
100
114
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
PG::PGRES_COMMAND_OK,
|
|
104
|
-
PG::PGRES_TUPLES_OK
|
|
105
|
-
].freeze
|
|
115
|
+
def ratio(numerator, denominator)
|
|
116
|
+
return 0.0 if denominator.zero?
|
|
106
117
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
(numerator.to_f / denominator).round(3)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def warn_flush_coupling_once(caps)
|
|
122
|
+
return if @flush_coupling_warned
|
|
123
|
+
return if caps.fast_sync?
|
|
124
|
+
return if ENV["PG_PIPELINE_SILENCE_WARNINGS"]
|
|
125
|
+
|
|
126
|
+
@flush_coupling_warned = true
|
|
127
|
+
warn(
|
|
128
|
+
"pg_pipeline: libpq #{caps.libpq_version} couples pipeline Sync with flush " \
|
|
129
|
+
"(no PQsendPipelineSync). Queries still pipeline and still amortise RTT, but " \
|
|
130
|
+
"one flush per unit caps local throughput; libpq >= 17 is recommended for " \
|
|
131
|
+
"maximum throughput. Set PG_PIPELINE_SILENCE_WARNINGS=1 to silence this."
|
|
132
|
+
)
|
|
133
|
+
end
|
|
112
134
|
|
|
113
135
|
def positive_integer!(value, name)
|
|
114
136
|
integer = Integer(value)
|
|
@@ -119,8 +141,9 @@ module PgPipeline
|
|
|
119
141
|
raise ArgumentError, "#{name} must be an integer >= 1"
|
|
120
142
|
end
|
|
121
143
|
|
|
122
|
-
def start(d
|
|
144
|
+
def start(d)
|
|
123
145
|
raise Error, "driver already started" if d.running
|
|
146
|
+
raise Error, "driver start requires an active Fiber scheduler" unless Fiber.scheduler
|
|
124
147
|
|
|
125
148
|
d.conn.setnonblocking(true)
|
|
126
149
|
d.conn.enter_pipeline_mode
|
|
@@ -129,15 +152,15 @@ module PgPipeline
|
|
|
129
152
|
d.accepting = true
|
|
130
153
|
d.running = true
|
|
131
154
|
|
|
132
|
-
d.reader_task =
|
|
133
|
-
d.writer_task =
|
|
134
|
-
d.owner_task =
|
|
155
|
+
d.reader_task = Runtime.spawn(name: :reader) { reader_watcher(d) }
|
|
156
|
+
d.writer_task = Runtime.spawn(name: :writer) { writer_watcher(d) }
|
|
157
|
+
d.owner_task = Runtime.spawn(name: :owner) { owner_loop(d) }
|
|
135
158
|
d
|
|
136
159
|
rescue Exception
|
|
137
160
|
d.accepting = false
|
|
138
161
|
d.running = false
|
|
139
|
-
|
|
140
|
-
|
|
162
|
+
teardown_watchers(d)
|
|
163
|
+
join_owner(d)
|
|
141
164
|
raise
|
|
142
165
|
end
|
|
143
166
|
|
|
@@ -164,7 +187,7 @@ module PgPipeline
|
|
|
164
187
|
d.accepting = false
|
|
165
188
|
d.requests.close(ShutdownError.new("driver is closing"))
|
|
166
189
|
d.events.enqueue(:close)
|
|
167
|
-
d
|
|
190
|
+
join_owner(d)
|
|
168
191
|
nil
|
|
169
192
|
end
|
|
170
193
|
|
|
@@ -174,7 +197,7 @@ module PgPipeline
|
|
|
174
197
|
d.accepting = false
|
|
175
198
|
d.requests.close(not_dispatched_error(error))
|
|
176
199
|
d.events.enqueue([:abort, error])
|
|
177
|
-
|
|
200
|
+
join_owner(d)
|
|
178
201
|
nil
|
|
179
202
|
end
|
|
180
203
|
|
|
@@ -196,7 +219,12 @@ module PgPipeline
|
|
|
196
219
|
end
|
|
197
220
|
|
|
198
221
|
def owner_loop(d)
|
|
199
|
-
|
|
222
|
+
while d.running
|
|
223
|
+
event = d.events.dequeue
|
|
224
|
+
break if event.nil?
|
|
225
|
+
|
|
226
|
+
process_event(d, event)
|
|
227
|
+
end
|
|
200
228
|
rescue StandardError => e
|
|
201
229
|
fatal_close(d, ConnectionLostError.new("driver crashed: #{e.class}: #{e.message}"))
|
|
202
230
|
ensure
|
|
@@ -213,6 +241,7 @@ module PgPipeline
|
|
|
213
241
|
nil
|
|
214
242
|
when :readable
|
|
215
243
|
begin
|
|
244
|
+
d.readable_events += 1
|
|
216
245
|
read_available(d)
|
|
217
246
|
input_changed = true
|
|
218
247
|
ensure
|
|
@@ -238,6 +267,7 @@ module PgPipeline
|
|
|
238
267
|
|
|
239
268
|
def notify_requests(d)
|
|
240
269
|
return if d.request_event_pending
|
|
270
|
+
return unless d.running
|
|
241
271
|
|
|
242
272
|
d.request_event_pending = true
|
|
243
273
|
d.events.enqueue(:requests)
|
|
@@ -260,6 +290,7 @@ module PgPipeline
|
|
|
260
290
|
request.dispatched!
|
|
261
291
|
d.inflight << request
|
|
262
292
|
d.dispatching = nil
|
|
293
|
+
d.dispatches += 1
|
|
263
294
|
dispatched = true
|
|
264
295
|
end
|
|
265
296
|
|
|
@@ -286,9 +317,6 @@ module PgPipeline
|
|
|
286
317
|
rescue ProtocolError
|
|
287
318
|
raise
|
|
288
319
|
rescue StandardError => e
|
|
289
|
-
# ruby-pg prepares/encodes query parameters before calling PQsend*. A
|
|
290
|
-
# Ruby-side encoder/coercion exception therefore belongs only to this
|
|
291
|
-
# request and must not poison unrelated work already in the pipeline.
|
|
292
320
|
request.reject!(e)
|
|
293
321
|
return false
|
|
294
322
|
end
|
|
@@ -327,10 +355,13 @@ module PgPipeline
|
|
|
327
355
|
def flush_output(d)
|
|
328
356
|
return unless d.running
|
|
329
357
|
|
|
358
|
+
d.flush_calls += 1
|
|
359
|
+
|
|
330
360
|
if d.conn.sync_flush
|
|
331
361
|
d.needs_flush = false
|
|
332
362
|
else
|
|
333
363
|
d.needs_flush = true
|
|
364
|
+
d.flush_incomplete += 1
|
|
334
365
|
arm_writer(d)
|
|
335
366
|
end
|
|
336
367
|
rescue PG::Error => e
|
|
@@ -339,6 +370,7 @@ module PgPipeline
|
|
|
339
370
|
|
|
340
371
|
def arm_writer(d)
|
|
341
372
|
return if d.writer_armed
|
|
373
|
+
return unless d.running
|
|
342
374
|
|
|
343
375
|
d.writer_armed = true
|
|
344
376
|
d.writer_commands.enqueue(:wait_writable)
|
|
@@ -351,42 +383,52 @@ module PgPipeline
|
|
|
351
383
|
end
|
|
352
384
|
|
|
353
385
|
def drain_results(d)
|
|
354
|
-
|
|
355
|
-
result = d.conn.sync_get_result
|
|
356
|
-
request = d.inflight.first
|
|
357
|
-
raise ProtocolError, "result without an in-flight request" unless request
|
|
386
|
+
read = 0
|
|
358
387
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
388
|
+
begin
|
|
389
|
+
while !d.inflight.empty? && !d.conn.is_busy
|
|
390
|
+
result = d.conn.sync_get_result
|
|
391
|
+
read += 1
|
|
392
|
+
request = d.inflight.first
|
|
393
|
+
raise ProtocolError, "result without an in-flight request" unless request
|
|
394
|
+
|
|
395
|
+
if result.nil?
|
|
396
|
+
request.query_boundary!
|
|
397
|
+
next
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
status = result.result_status
|
|
401
|
+
|
|
402
|
+
case status
|
|
403
|
+
when PG::PGRES_TUPLES_OK
|
|
404
|
+
ensure_before_query_boundary!(request, status)
|
|
405
|
+
request.accept_result(result)
|
|
406
|
+
when PG::PGRES_PIPELINE_SYNC
|
|
407
|
+
clear_result(result)
|
|
408
|
+
complete_front(d, request)
|
|
409
|
+
when PG::PGRES_COMMAND_OK, PG::PGRES_EMPTY_QUERY
|
|
410
|
+
ensure_before_query_boundary!(request, status)
|
|
411
|
+
request.accept_result(result)
|
|
412
|
+
when PG::PGRES_FATAL_ERROR
|
|
413
|
+
ensure_before_query_boundary!(request, status)
|
|
414
|
+
request.record_error!(query_error(result), result: result)
|
|
415
|
+
when PG::PGRES_PIPELINE_ABORTED
|
|
416
|
+
ensure_before_query_boundary!(request, status)
|
|
417
|
+
clear_result(result)
|
|
418
|
+
request.record_error!(PipelineAbortedError.new("pipeline unit aborted"))
|
|
419
|
+
when PG::PGRES_BAD_RESPONSE
|
|
420
|
+
clear_result(result)
|
|
421
|
+
raise ProtocolError, "server response was not understood"
|
|
422
|
+
when PG::PGRES_COPY_IN, PG::PGRES_COPY_OUT, PG::PGRES_COPY_BOTH
|
|
423
|
+
clear_result(result)
|
|
424
|
+
raise ProtocolError, "COPY is not supported on the multiplexed pipeline"
|
|
425
|
+
else
|
|
426
|
+
clear_result(result)
|
|
427
|
+
raise ProtocolError, "unexpected pipeline result status #{status}"
|
|
428
|
+
end
|
|
389
429
|
end
|
|
430
|
+
ensure
|
|
431
|
+
d.results_read += read if read.positive?
|
|
390
432
|
end
|
|
391
433
|
end
|
|
392
434
|
|
|
@@ -400,6 +442,7 @@ module PgPipeline
|
|
|
400
442
|
raise ProtocolError, "sync does not match FIFO front" unless request.equal?(d.inflight.first)
|
|
401
443
|
|
|
402
444
|
d.inflight.shift
|
|
445
|
+
d.units_completed += 1
|
|
403
446
|
request.finish!
|
|
404
447
|
end
|
|
405
448
|
|
|
@@ -417,14 +460,11 @@ module PgPipeline
|
|
|
417
460
|
d.accepting = false
|
|
418
461
|
d.running = false
|
|
419
462
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
stop_watchers(d)
|
|
426
|
-
safe_close_conn(d)
|
|
427
|
-
end
|
|
463
|
+
d.conn.exit_pipeline_mode
|
|
464
|
+
rescue PG::Error => e
|
|
465
|
+
fail_all(d, ConnectionLostError.new("failed to exit pipeline mode: #{e.message}"))
|
|
466
|
+
ensure
|
|
467
|
+
teardown_watchers(d)
|
|
428
468
|
end
|
|
429
469
|
|
|
430
470
|
def fatal_close(d, error)
|
|
@@ -434,8 +474,8 @@ module PgPipeline
|
|
|
434
474
|
d.running = false
|
|
435
475
|
d.requests.close(not_dispatched_error(error))
|
|
436
476
|
fail_all(d, error)
|
|
437
|
-
|
|
438
|
-
|
|
477
|
+
|
|
478
|
+
teardown_watchers(d)
|
|
439
479
|
end
|
|
440
480
|
|
|
441
481
|
def fail_all(d, error)
|
|
@@ -500,21 +540,87 @@ module PgPipeline
|
|
|
500
540
|
end
|
|
501
541
|
end
|
|
502
542
|
|
|
503
|
-
def
|
|
504
|
-
|
|
505
|
-
|
|
543
|
+
def teardown_watchers(d)
|
|
544
|
+
tasks = release_watchers(d)
|
|
545
|
+
close_wait_points(d)
|
|
546
|
+
begin
|
|
547
|
+
d.socket&.close
|
|
548
|
+
rescue StandardError
|
|
549
|
+
nil
|
|
550
|
+
end
|
|
551
|
+
stop_watchers(tasks)
|
|
552
|
+
join_watchers(d, tasks)
|
|
553
|
+
d.socket = nil
|
|
554
|
+
safe_close_conn(d)
|
|
555
|
+
nil
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def release_watchers(d)
|
|
559
|
+
tasks = [d.reader_task, d.writer_task].compact
|
|
506
560
|
d.reader_task = nil
|
|
507
561
|
d.writer_task = nil
|
|
562
|
+
tasks
|
|
563
|
+
end
|
|
508
564
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
565
|
+
def close_wait_points(d)
|
|
566
|
+
[d.reader_rearm, d.writer_commands, d.events].each do |queue|
|
|
567
|
+
queue&.close
|
|
568
|
+
rescue StandardError
|
|
569
|
+
nil
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def stop_watchers(tasks)
|
|
574
|
+
Array(tasks).each do |task|
|
|
575
|
+
task.stop
|
|
576
|
+
rescue Runtime::Cancel, StandardError
|
|
512
577
|
nil
|
|
513
578
|
end
|
|
514
579
|
|
|
515
580
|
nil
|
|
516
581
|
end
|
|
517
582
|
|
|
583
|
+
def join_watchers(d, tasks)
|
|
584
|
+
Array(tasks).each do |task|
|
|
585
|
+
task.wait(WATCHER_JOIN_TIMEOUT)
|
|
586
|
+
rescue Runtime::TimeoutError
|
|
587
|
+
d.leaked_watchers += 1
|
|
588
|
+
warn_leaked_task(d, task, WATCHER_JOIN_TIMEOUT)
|
|
589
|
+
rescue Runtime::Cancel, StandardError
|
|
590
|
+
nil
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
nil
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
def join_owner(d)
|
|
597
|
+
owner = d.owner_task
|
|
598
|
+
return if owner.nil?
|
|
599
|
+
return if Fiber.current.equal?(owner.fiber)
|
|
600
|
+
|
|
601
|
+
begin
|
|
602
|
+
owner.wait(OWNER_JOIN_TIMEOUT)
|
|
603
|
+
rescue Runtime::TimeoutError
|
|
604
|
+
d.leaked_watchers += 1
|
|
605
|
+
warn_leaked_task(d, owner, OWNER_JOIN_TIMEOUT)
|
|
606
|
+
rescue Runtime::Cancel, StandardError
|
|
607
|
+
nil
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
nil
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def warn_leaked_task(d, task, timeout)
|
|
614
|
+
return if ENV["PG_PIPELINE_SILENCE_WARNINGS"]
|
|
615
|
+
|
|
616
|
+
warn(
|
|
617
|
+
"pg_pipeline: task #{task.name.inspect} did not exit within " \
|
|
618
|
+
"#{timeout}s and has been leaked (total #{d.leaked_watchers}). " \
|
|
619
|
+
"Closing the duplexed socket_io / connection did not release the fiber " \
|
|
620
|
+
"(often parked in wait_readable/wait_writable)."
|
|
621
|
+
)
|
|
622
|
+
end
|
|
623
|
+
|
|
518
624
|
def safe_close_conn(d)
|
|
519
625
|
d.conn.close unless d.conn.finished?
|
|
520
626
|
rescue StandardError
|