tupelo 0.14 → 0.15
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/README.md +27 -12
- data/bin/tspy +4 -4
- data/bin/tup +28 -18
- data/example/app-and-tup.rb +3 -3
- data/example/broker-queue.rb +35 -0
- data/example/chat/chat-nohistory.rb +2 -2
- data/example/chat/chat.rb +2 -2
- data/example/child-of-child.rb +34 -0
- data/example/fish01.rb +48 -0
- data/example/map-reduce/remote-map-reduce.rb +3 -1
- data/example/pregel/dist-opt.rb +15 -0
- data/example/small.rb +8 -8
- data/example/subspaces/addr-book-v1.rb +106 -0
- data/example/subspaces/sorted-set-space-OLD.rb +130 -0
- data/example/tcp.rb +9 -10
- data/example/tiny-client.rb +4 -4
- data/example/tiny-service.rb +12 -0
- data/lib/tupelo/app/builder.rb +4 -4
- data/lib/tupelo/app.rb +18 -20
- data/lib/tupelo/client/transaction.rb +9 -23
- data/lib/tupelo/client/worker.rb +16 -22
- data/lib/tupelo/client.rb +4 -0
- data/lib/tupelo/version.rb +1 -1
- metadata +96 -91
- data/example/map-reduce/prime-factor-balanced.rb +0 -55
- data/example/tiny-server.rb +0 -12
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'rbtree'
|
2
|
+
|
3
|
+
class SortedSetTemplate
|
4
|
+
class << self
|
5
|
+
alias [] new
|
6
|
+
end
|
7
|
+
|
8
|
+
# cmd can be "next", "prev", "first", "last"
|
9
|
+
# for next/prev, args is ["name"]
|
10
|
+
# for first/last, args is empty
|
11
|
+
def initialize tag, cmd, *args
|
12
|
+
@tag = tag
|
13
|
+
@cmd = cmd
|
14
|
+
@args = args
|
15
|
+
end
|
16
|
+
|
17
|
+
def === other
|
18
|
+
raise ### should not need this?
|
19
|
+
end
|
20
|
+
|
21
|
+
def find_in rbtree
|
22
|
+
case @cmd
|
23
|
+
when "first"
|
24
|
+
rbtree.first
|
25
|
+
###
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# A tuple store (in-memory) that is optimized for (key_string, object) pairs.
|
30
|
+
# The object may be any serializable object (built up from numbers, booleans,
|
31
|
+
# nil, strings, hashes and arrays).
|
32
|
+
#
|
33
|
+
# Unlike in a key-value store, a given key_string may occur more than once.
|
34
|
+
# It is up to the application to decide whether to enforce key uniqueness or
|
35
|
+
# not (for example, by taking (k,...) before writing (k,v).
|
36
|
+
#
|
37
|
+
# This store should be used only by clients that subscribe to a subspace
|
38
|
+
# that can be represented as pairs. (See memo2.rb.)
|
39
|
+
#
|
40
|
+
# This store also manages meta tuples, which it keeps in an array, just like
|
41
|
+
# the default Tuplespace class does.
|
42
|
+
class SortedSetSpace
|
43
|
+
include Enumerable
|
44
|
+
|
45
|
+
attr_reader :tag, :hash, :metas
|
46
|
+
|
47
|
+
def initialize tag
|
48
|
+
@tag = tag
|
49
|
+
clear
|
50
|
+
end
|
51
|
+
|
52
|
+
def clear
|
53
|
+
@hash = Hash.new {|h,k| h[k] = []}
|
54
|
+
# It's up to the application to enforce that these arrays have size <=1.
|
55
|
+
@metas = []
|
56
|
+
# We are automatically subscribed to tupelo metadata (subspace defs), so
|
57
|
+
# we need to keep them somewhere.
|
58
|
+
end
|
59
|
+
|
60
|
+
def each
|
61
|
+
hash.each do |k, vs|
|
62
|
+
vs.each do |v|
|
63
|
+
yield tag, k, v
|
64
|
+
end
|
65
|
+
end
|
66
|
+
metas.each do |tuple|
|
67
|
+
yield tuple
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def insert tuple
|
72
|
+
if tuple.kind_of? Array
|
73
|
+
# and tuple.size == 3 and tuple[0] == tag and tuple[1].kind_of? String
|
74
|
+
# This is redundant, because of subscribe.
|
75
|
+
t, k, v = tuple
|
76
|
+
hash[k] << v
|
77
|
+
|
78
|
+
else
|
79
|
+
metas << tuple
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def delete_once tuple
|
84
|
+
if tuple.kind_of? Array
|
85
|
+
# and tuple.size == 3 and tuple[0] == tag and tuple[1].kind_of? String
|
86
|
+
# This is redundant, because of subscribe.
|
87
|
+
t, k, v = tuple
|
88
|
+
if hash.key?(k) and hash[k].include? v
|
89
|
+
hash[k].delete v
|
90
|
+
hash.delete k if hash[k].empty?
|
91
|
+
true
|
92
|
+
else
|
93
|
+
false
|
94
|
+
end
|
95
|
+
|
96
|
+
else
|
97
|
+
if i=metas.index(tuple)
|
98
|
+
delete_at i
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def transaction inserts: [], deletes: [], tick: nil
|
104
|
+
deletes.each do |tuple|
|
105
|
+
delete_once tuple or raise "bug"
|
106
|
+
end
|
107
|
+
|
108
|
+
inserts.each do |tuple|
|
109
|
+
insert tuple.freeze ## should be deep_freeze
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
def find_distinct_matches_for templates
|
114
|
+
templates.inject([]) do |tuples, template|
|
115
|
+
tuples << find_match_for(template, distinct_from: tuples)
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
def find_match_for template, distinct_from: []
|
120
|
+
case template
|
121
|
+
when SortedSetTemplate
|
122
|
+
template.find_in rbtree, distinct_from: distinct_from ###
|
123
|
+
else
|
124
|
+
# fall back to linear search
|
125
|
+
find do |tuple|
|
126
|
+
template === tuple and not distinct_from.any? {|t| t.equal? tuple}
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
data/example/tcp.rb
CHANGED
@@ -13,8 +13,7 @@
|
|
13
13
|
#
|
14
14
|
# ../bin/tup tcp.yaml
|
15
15
|
#
|
16
|
-
# Copy tcp.yaml to a remote host
|
17
|
-
# addr field to a hostname (or ip addr) isntead of 0.0.0.0.
|
16
|
+
# Copy tcp.yaml to a remote host.
|
18
17
|
#
|
19
18
|
# Then run a client like this:
|
20
19
|
#
|
@@ -22,21 +21,21 @@
|
|
22
21
|
|
23
22
|
require 'tupelo/app'
|
24
23
|
|
25
|
-
|
24
|
+
sv = "tcp.yaml" # copy this file to remote clients, setting host as needed
|
26
25
|
port = 9901 # Use 0 to let system choose free port
|
27
26
|
|
28
|
-
Tupelo.application
|
29
|
-
seqd_addr:
|
30
|
-
cseqd_addr:
|
31
|
-
arcd_addr:
|
32
|
-
if
|
33
|
-
puts "
|
27
|
+
Tupelo.application services_file: sv,
|
28
|
+
seqd_addr: {proto: :tcp, bind_host: '0.0.0.0', port: port},
|
29
|
+
cseqd_addr: {proto: :tcp, bind_host: '0.0.0.0', port: port + 1},
|
30
|
+
arcd_addr: {proto: :tcp, bind_host: '0.0.0.0', port: port + 2} do
|
31
|
+
if owns_services
|
32
|
+
puts "service started; ^C to stop"
|
34
33
|
puts "run in another terminal: ../bin/tup tcp.yaml"
|
35
34
|
if log.level > Logger::INFO
|
36
35
|
puts "(run with --info or --trace to see events)"
|
37
36
|
end
|
38
37
|
sleep
|
39
38
|
else
|
40
|
-
abort "
|
39
|
+
abort "service seems to be running already; check file #{sv.inspect}"
|
41
40
|
end
|
42
41
|
end
|
data/example/tiny-client.rb
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
require 'tupelo/app'
|
2
2
|
|
3
|
-
|
3
|
+
sv = "tiny-service.yaml"
|
4
4
|
|
5
|
-
Tupelo.application
|
6
|
-
if
|
7
|
-
abort "
|
5
|
+
Tupelo.application services_file: sv do
|
6
|
+
if owns_services
|
7
|
+
abort "service not running"
|
8
8
|
end
|
9
9
|
|
10
10
|
child do
|
data/lib/tupelo/app/builder.rb
CHANGED
@@ -4,16 +4,16 @@ module Tupelo
|
|
4
4
|
class AppBuilder
|
5
5
|
attr_reader :ez
|
6
6
|
|
7
|
-
# Does this app own (as child processes) the seq, cseq, and arc
|
8
|
-
attr_reader :
|
7
|
+
# Does this app own (as child processes) the seq, cseq, and arc services?
|
8
|
+
attr_reader :owns_services
|
9
9
|
|
10
10
|
# Arguments available to application after tupelo has parsed out switches
|
11
11
|
# and args that it recognizes.
|
12
12
|
attr_reader :argv
|
13
13
|
|
14
|
-
def initialize ez,
|
14
|
+
def initialize ez, owns_services: nil, argv: argv
|
15
15
|
@ez = ez
|
16
|
-
@
|
16
|
+
@owns_services = owns_services
|
17
17
|
@argv = argv
|
18
18
|
end
|
19
19
|
|
data/lib/tupelo/app.rb
CHANGED
@@ -38,12 +38,10 @@ module Tupelo
|
|
38
38
|
end
|
39
39
|
|
40
40
|
# same as application, but with tcp sockets the default
|
41
|
-
def self.tcp_application argv: nil,
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
arcd_addr: [:tcp, nil, 0], **opts, &block
|
46
|
-
application argv: argv, servers_file: servers_file, blob_type: blob_type,
|
41
|
+
def self.tcp_application argv: nil, services_file: nil, blob_type: nil,
|
42
|
+
seqd_addr: {}, cseqd_addr: {}, arcd_addr: {}, **opts, &block
|
43
|
+
seqd_addr[:proto] = cseqd_addr[:proto] = arcd_addr[:proto] = :tcp
|
44
|
+
application argv: argv, services_file: services_file, blob_type: blob_type,
|
47
45
|
seqd_addr: seqd_addr, cseqd_addr: cseqd_addr, arcd_addr: arcd_addr, &block
|
48
46
|
end
|
49
47
|
|
@@ -53,8 +51,8 @@ module Tupelo
|
|
53
51
|
#blob_type: 'json' # more portable than yaml, but more restrictive
|
54
52
|
|
55
53
|
def self.application argv: nil,
|
56
|
-
|
57
|
-
seqd_addr:
|
54
|
+
services_file: nil, blob_type: nil,
|
55
|
+
seqd_addr: {}, cseqd_addr: {}, arcd_addr: {}, **opts, &block
|
58
56
|
|
59
57
|
unless argv
|
60
58
|
argv, h = parse_args(ARGV)
|
@@ -68,7 +66,7 @@ module Tupelo
|
|
68
66
|
persist_dir = opts[:persist_dir]
|
69
67
|
|
70
68
|
ez_opts = {
|
71
|
-
|
69
|
+
services_file: services_file || argv.shift,
|
72
70
|
interactive: $stdin.isatty
|
73
71
|
}
|
74
72
|
|
@@ -77,37 +75,37 @@ module Tupelo
|
|
77
75
|
log.level = log_level
|
78
76
|
log.formatter = nil if verbose
|
79
77
|
log.progname = File.basename($0)
|
80
|
-
|
78
|
+
owns_services = false
|
81
79
|
|
82
|
-
ez.
|
83
|
-
|
80
|
+
ez.start_services do
|
81
|
+
owns_services = true
|
84
82
|
|
85
83
|
arc_to_seq_sock, seq_to_arc_sock = UNIXSocket.pair
|
86
84
|
arc_to_cseq_sock, cseq_to_arc_sock = UNIXSocket.pair
|
87
85
|
|
88
|
-
ez.
|
86
|
+
ez.service :seqd, **seqd_addr do |sv|
|
89
87
|
require 'funl/message-sequencer'
|
90
|
-
seq = Funl::MessageSequencer.new
|
88
|
+
seq = Funl::MessageSequencer.new sv, seq_to_arc_sock, log: log,
|
91
89
|
blob_type: blob_type
|
92
90
|
seq.start
|
93
91
|
end
|
94
92
|
|
95
|
-
ez.
|
93
|
+
ez.service :cseqd, **cseqd_addr do |sv|
|
96
94
|
require 'funl/client-sequencer'
|
97
|
-
cseq = Funl::ClientSequencer.new
|
95
|
+
cseq = Funl::ClientSequencer.new sv, cseq_to_arc_sock, log: log
|
98
96
|
cseq.start
|
99
97
|
end
|
100
98
|
|
101
|
-
ez.
|
99
|
+
ez.service :arcd, **arcd_addr do |sv|
|
102
100
|
require 'tupelo/archiver'
|
103
101
|
if persist_dir
|
104
102
|
require 'tupelo/archiver/persistent-tuplespace'
|
105
|
-
arc = Archiver.new
|
103
|
+
arc = Archiver.new sv, seq: arc_to_seq_sock,
|
106
104
|
tuplespace: Archiver::PersistentTuplespace,
|
107
105
|
persist_dir: persist_dir,
|
108
106
|
cseq: arc_to_cseq_sock, log: log
|
109
107
|
else
|
110
|
-
arc = Archiver.new
|
108
|
+
arc = Archiver.new sv, seq: arc_to_seq_sock,
|
111
109
|
tuplespace: Archiver::Tuplespace,
|
112
110
|
cseq: arc_to_cseq_sock, log: log
|
113
111
|
end
|
@@ -115,7 +113,7 @@ module Tupelo
|
|
115
113
|
end
|
116
114
|
end
|
117
115
|
|
118
|
-
app = AppBuilder.new(ez,
|
116
|
+
app = AppBuilder.new(ez, owns_services: owns_services, argv: argv.dup)
|
119
117
|
|
120
118
|
if enable_trace
|
121
119
|
require 'tupelo/app/trace'
|
@@ -13,13 +13,12 @@ class Tupelo::Client
|
|
13
13
|
Transaction
|
14
14
|
end
|
15
15
|
|
16
|
-
# Transactions are atomic
|
17
|
-
#
|
18
|
-
# returns the
|
19
|
-
|
20
|
-
def transaction atomic: true, timeout: nil, &block
|
16
|
+
# Transactions are atomic and isolated. Without a block, returns the
|
17
|
+
# Transaction. In the block form, transaction automatically waits for
|
18
|
+
# successful completion and returns the value of the block.
|
19
|
+
def transaction timeout: nil, &block
|
21
20
|
deadline = timeout && Time.now + timeout
|
22
|
-
t = trans_class.new self,
|
21
|
+
t = trans_class.new self, deadline: deadline
|
23
22
|
return t unless block_given?
|
24
23
|
|
25
24
|
val =
|
@@ -41,17 +40,13 @@ class Tupelo::Client
|
|
41
40
|
t.cancel if t and t.open? and block_given?
|
42
41
|
end
|
43
42
|
|
44
|
-
def batch &bl
|
45
|
-
transaction atomic: false, &bl
|
46
|
-
end
|
47
|
-
|
48
43
|
def abort
|
49
44
|
raise TransactionAbort
|
50
45
|
end
|
51
46
|
|
52
47
|
# returns an object whose #wait method waits for write to be ack-ed
|
53
48
|
def write_nowait *tuples
|
54
|
-
t = transaction
|
49
|
+
t = transaction
|
55
50
|
t.write *tuples
|
56
51
|
t.commit
|
57
52
|
end
|
@@ -63,7 +58,7 @@ class Tupelo::Client
|
|
63
58
|
end
|
64
59
|
|
65
60
|
def pulse_nowait *tuples
|
66
|
-
t = transaction
|
61
|
+
t = transaction
|
67
62
|
t.pulse *tuples
|
68
63
|
t.commit
|
69
64
|
end
|
@@ -94,7 +89,6 @@ class Tupelo::Client
|
|
94
89
|
class Transaction
|
95
90
|
attr_reader :client
|
96
91
|
attr_reader :worker
|
97
|
-
attr_reader :atomic
|
98
92
|
attr_reader :deadline
|
99
93
|
attr_reader :status
|
100
94
|
attr_reader :global_tick
|
@@ -129,11 +123,10 @@ class Tupelo::Client
|
|
129
123
|
}
|
130
124
|
end
|
131
125
|
|
132
|
-
def initialize client,
|
126
|
+
def initialize client, deadline: nil
|
133
127
|
@client = client
|
134
128
|
@worker = client.worker
|
135
129
|
@log = client.log
|
136
|
-
@atomic = atomic
|
137
130
|
@deadline = deadline
|
138
131
|
@global_tick = nil
|
139
132
|
@exception = nil
|
@@ -197,15 +190,13 @@ class Tupelo::Client
|
|
197
190
|
["#{label} #{tuples.map(&:inspect).join(", ")}"] unless tuples.empty?
|
198
191
|
end
|
199
192
|
ops.compact!
|
200
|
-
|
201
|
-
b = atomic ? "atomic" : "batch"
|
202
193
|
ops << "missing: #{missing}" if missing
|
203
194
|
|
204
195
|
## show take/read tuples too?
|
205
196
|
## show current tick, if open or closed
|
206
197
|
## show nowait
|
207
198
|
|
208
|
-
"<#{self.class} #{stat} #{
|
199
|
+
"<#{self.class} #{stat} #{ops.join('; ')}>"
|
209
200
|
end
|
210
201
|
|
211
202
|
# :section: Client methods
|
@@ -240,7 +231,6 @@ class Tupelo::Client
|
|
240
231
|
|
241
232
|
# raises TransactionFailure
|
242
233
|
def take template_spec
|
243
|
-
raise "cannot take in batch" unless atomic
|
244
234
|
raise exception if failed?
|
245
235
|
raise TransactionStateError, "not open: #{inspect}" unless open? or
|
246
236
|
failed?
|
@@ -253,7 +243,6 @@ class Tupelo::Client
|
|
253
243
|
end
|
254
244
|
|
255
245
|
def take_nowait template_spec
|
256
|
-
raise "cannot take in batch" unless atomic
|
257
246
|
raise exception if failed?
|
258
247
|
raise TransactionStateError, "not open: #{inspect}" unless open? or
|
259
248
|
failed?
|
@@ -270,7 +259,6 @@ class Tupelo::Client
|
|
270
259
|
|
271
260
|
# transaction applies only if template has a match
|
272
261
|
def read template_spec
|
273
|
-
raise "cannot read in batch" unless atomic
|
274
262
|
raise exception if failed?
|
275
263
|
raise TransactionStateError, "not open: #{inspect}" unless open? or
|
276
264
|
failed?
|
@@ -283,7 +271,6 @@ class Tupelo::Client
|
|
283
271
|
end
|
284
272
|
|
285
273
|
def read_nowait template_spec
|
286
|
-
raise "cannot read in batch" unless atomic
|
287
274
|
raise exception if failed?
|
288
275
|
raise TransactionStateError, "not open: #{inspect}" unless open? or
|
289
276
|
failed?
|
@@ -486,7 +473,6 @@ class Tupelo::Client
|
|
486
473
|
|
487
474
|
## convert cancelling write/take to pulse
|
488
475
|
## convert cancelling take/write to read
|
489
|
-
## check that remaining take/read tuples do not cross a space boundary
|
490
476
|
|
491
477
|
if take_tuples_for_local.all? and read_tuples_for_local.all?
|
492
478
|
@queue << true
|
data/lib/tupelo/client/worker.rb
CHANGED
@@ -29,16 +29,13 @@ class Tupelo::Client
|
|
29
29
|
GET_TUPLESPACE = "get tuplespace"
|
30
30
|
|
31
31
|
class Operation
|
32
|
-
attr_reader :
|
33
|
-
## "put" or "set" operation to ensure that at least one
|
34
|
-
## copy of a tuple exists?
|
32
|
+
attr_reader :writes, :pulses, :takes, :reads
|
35
33
|
|
36
|
-
def initialize
|
37
|
-
@
|
38
|
-
atomic, writes, pulses, takes, reads
|
34
|
+
def initialize writes, pulses, takes, reads
|
35
|
+
@writes, @pulses, @takes, @reads = writes, pulses, takes, reads
|
39
36
|
end
|
40
37
|
|
41
|
-
NOOP = new([], [], [], []
|
38
|
+
NOOP = new([].freeze, [].freeze, [].freeze, [].freeze).freeze
|
42
39
|
|
43
40
|
def to_s
|
44
41
|
ops = [ ["write", writes], ["pulse", pulses],
|
@@ -47,8 +44,7 @@ class Tupelo::Client
|
|
47
44
|
["#{label} #{tuples.map(&:inspect).join(", ")}"] unless tuples.empty?
|
48
45
|
end
|
49
46
|
ops.compact!
|
50
|
-
|
51
|
-
[atomic ? "atomic" : "batch", ops.join("; ")].join(" ")
|
47
|
+
ops.join("; ")
|
52
48
|
end
|
53
49
|
alias inspect to_s
|
54
50
|
end
|
@@ -331,11 +327,9 @@ class Tupelo::Client
|
|
331
327
|
waiter << [:attempt, msg.global_tick, msg.client_id, op, msg.tags]
|
332
328
|
end
|
333
329
|
|
334
|
-
|
330
|
+
take_tuples = tuplespace.find_distinct_matches_for(op.takes)
|
335
331
|
read_tuples = op.reads.map {|t| tuplespace.find_match_for(t)}
|
336
|
-
|
337
|
-
succeeded = !op.atomic || (granted_tuples.all? && read_tuples.all?)
|
338
|
-
take_tuples = granted_tuples.compact
|
332
|
+
succeeded = take_tuples.all? && read_tuples.all?
|
339
333
|
|
340
334
|
if client.subscribed_all
|
341
335
|
write_tuples = op.writes
|
@@ -395,12 +389,15 @@ class Tupelo::Client
|
|
395
389
|
end
|
396
390
|
end
|
397
391
|
|
398
|
-
log.debug {trans ? "taking #{
|
399
|
-
"client #{msg.client_id} takes #{
|
392
|
+
log.debug {trans ? "taking #{take_tuples}" :
|
393
|
+
"client #{msg.client_id} takes #{take_tuples}"}
|
400
394
|
|
401
395
|
else
|
402
396
|
log.debug {
|
403
|
-
missing =
|
397
|
+
missing = []
|
398
|
+
take_tuples.each_with_index do |tuple, i|
|
399
|
+
missing << op.takes[i] unless tuple
|
400
|
+
end
|
404
401
|
trans ? "failed to take #{missing}" :
|
405
402
|
"client #{msg.client_id} failed to take #{missing}"}
|
406
403
|
end
|
@@ -431,7 +428,7 @@ class Tupelo::Client
|
|
431
428
|
trans_waiters.delete trans
|
432
429
|
|
433
430
|
if succeeded
|
434
|
-
trans.done msg.global_tick,
|
431
|
+
trans.done msg.global_tick, take_tuples # note: tuples not frozen
|
435
432
|
else
|
436
433
|
trans.fail (op.takes - take_tuples) + (op.reads - read_tuples)
|
437
434
|
end
|
@@ -598,12 +595,9 @@ class Tupelo::Client
|
|
598
595
|
end
|
599
596
|
|
600
597
|
begin
|
601
|
-
msg.blob = blobber.dump([
|
602
|
-
transaction.atomic,
|
603
|
-
writes, pulses, takes, reads
|
604
|
-
])
|
598
|
+
msg.blob = blobber.dump([writes, pulses, takes, reads])
|
605
599
|
## optimization: use bitfields to identify which ops are present
|
606
|
-
## (instead of nils),
|
600
|
+
## (instead of nils), in one int
|
607
601
|
rescue => ex
|
608
602
|
raise ex, "cannot serialize #{transaction.inspect}: #{ex}"
|
609
603
|
end
|
data/lib/tupelo/client.rb
CHANGED
data/lib/tupelo/version.rb
CHANGED