tupelo 0.16 → 0.17
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 +21 -460
- data/bin/tup +11 -2
- data/example/chat/chat-nohistory.rb +1 -3
- data/example/chat/chat.rb +19 -8
- data/example/consistent-hash.rb +73 -0
- data/example/map-reduce/prime-factor-balanced.rb +54 -10
- data/example/multi-tier/kvspace.rb +1 -1
- data/example/multi-tier/memo2.rb +5 -8
- data/example/multi-tier/multi-sinatras.rb +1 -1
- data/example/subspaces/addr-book.rb +18 -27
- data/example/subspaces/pubsub.rb +2 -14
- data/example/subspaces/ramp.rb +17 -24
- data/example/subspaces/shop/shop-v2.rb +5 -8
- data/example/subspaces/simple.rb +1 -9
- data/example/{fish.rb → wip/fish.rb} +4 -0
- data/example/{fish0.rb → wip/fish1.rb} +0 -0
- data/example/wip/fish2.rb +59 -0
- data/lib/tupelo/app/builder.rb +2 -0
- data/lib/tupelo/client/subspace.rb +36 -0
- data/lib/tupelo/client/transaction.rb +5 -2
- data/lib/tupelo/client/tuplespace.rb +2 -2
- data/lib/tupelo/client/worker.rb +13 -12
- data/lib/tupelo/client.rb +1 -32
- data/lib/tupelo/util/bin-circle.rb +123 -0
- data/lib/tupelo/version.rb +1 -1
- metadata +115 -111
- data/example/map-reduce/mr.rb +0 -61
@@ -0,0 +1,73 @@
|
|
1
|
+
# TODO make more interesting by changing the set of workers over time.
|
2
|
+
|
3
|
+
require 'tupelo/app'
|
4
|
+
require 'tupelo/util/bin-circle'
|
5
|
+
|
6
|
+
N_BINS = 5
|
7
|
+
N_REPS = 30 # of each bin, to make distribution more uniform
|
8
|
+
N_ITER = 1000
|
9
|
+
|
10
|
+
Tupelo.application do
|
11
|
+
local do
|
12
|
+
use_subspaces!
|
13
|
+
|
14
|
+
N_BINS.times do |id|
|
15
|
+
define_subspace id, [id, Numeric, Numeric]
|
16
|
+
end
|
17
|
+
|
18
|
+
define_subspace "sum", ["sum", Numeric, Numeric, Numeric]
|
19
|
+
end
|
20
|
+
|
21
|
+
circle = BinCircle.new
|
22
|
+
|
23
|
+
N_BINS.times do |id|
|
24
|
+
circle.add_bin id, reps: N_REPS
|
25
|
+
end
|
26
|
+
|
27
|
+
N_BINS.times do |id|
|
28
|
+
child subscribe: [id], passive: true do
|
29
|
+
# take things belonging to the process's bin
|
30
|
+
count = 0
|
31
|
+
at_exit {log "load: #{count}"}
|
32
|
+
|
33
|
+
loop do
|
34
|
+
_, n1, n2 = take [id, Numeric, Numeric]
|
35
|
+
# could use take {|| ...} to optimistically start long computation
|
36
|
+
write ["sum", n1, n2, n1+n2]
|
37
|
+
count += 1
|
38
|
+
end
|
39
|
+
|
40
|
+
# the following loop is faster, but not horiz. scalable
|
41
|
+
if false
|
42
|
+
read [id, Numeric, Numeric] do |_, n1, n2|
|
43
|
+
write ["sum", n1, n2, n1+n2]
|
44
|
+
count += 1
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
local subscribe: ["sum"] do
|
51
|
+
srand(12345)
|
52
|
+
|
53
|
+
Thread.new do
|
54
|
+
N_ITER.times do |i|
|
55
|
+
ns = [rand(100), rand(100)]
|
56
|
+
bin_id = circle.find_bin(ns)
|
57
|
+
write [bin_id, *ns]
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
N_ITER.times do |i|
|
62
|
+
_, n1, n2, sum = take ["sum", Numeric, Numeric, Numeric]
|
63
|
+
unless n1 + n2 == sum
|
64
|
+
log.error "bad sum"
|
65
|
+
end
|
66
|
+
q,r = (100*(i+1)).divmod N_ITER
|
67
|
+
if r == 0
|
68
|
+
printf "\r%3d%", q
|
69
|
+
end
|
70
|
+
end
|
71
|
+
puts
|
72
|
+
end
|
73
|
+
end
|
@@ -1,6 +1,5 @@
|
|
1
|
-
#
|
2
|
-
#
|
3
|
-
# factored numbers, such as by finding the largest prime factor.
|
1
|
+
# Modified prime-factor.rb attempts to balance load better. Improvement
|
2
|
+
# varies--typically around 50% faster with 12 remote hosts.
|
4
3
|
|
5
4
|
require 'tupelo/app/remote'
|
6
5
|
|
@@ -9,28 +8,73 @@ hosts = hosts.split(",")
|
|
9
8
|
|
10
9
|
Tupelo.tcp_application do
|
11
10
|
hosts.each_with_index do |host, hi|
|
12
|
-
remote host: host, passive: true, eval: %{
|
11
|
+
remote host: host, passive: true, log: true, eval: %{
|
13
12
|
require 'prime' # ruby stdlib for prime factorization
|
14
13
|
class M
|
15
|
-
def initialize nh, hi
|
14
|
+
def initialize nh, hi, excl = []
|
16
15
|
@nh, @hi = nh, hi
|
16
|
+
@excl = excl
|
17
17
|
end
|
18
18
|
def === x
|
19
19
|
Array === x and
|
20
20
|
x[0] == "input" and
|
21
|
-
x[1] % @nh == @hi
|
21
|
+
x[1] % @nh == @hi and
|
22
|
+
not @excl.include? x[1]
|
23
|
+
end
|
24
|
+
def exclude *y
|
25
|
+
self.class.new @nh, @hi, @excl + y
|
22
26
|
end
|
23
27
|
end
|
24
28
|
my_pref = M.new(#{hosts.size}, #{hi})
|
29
|
+
|
25
30
|
loop do
|
26
|
-
|
31
|
+
txn = transaction
|
32
|
+
begin
|
33
|
+
_, input = txn.take_nowait(my_pref)
|
34
|
+
rescue TransactionFailure => ex
|
35
|
+
next
|
36
|
+
end
|
37
|
+
|
38
|
+
if input
|
27
39
|
begin
|
28
|
-
|
29
|
-
|
30
|
-
|
40
|
+
txn.commit
|
41
|
+
output = input.prime_division
|
42
|
+
Thread.new do
|
43
|
+
begin
|
44
|
+
txn.wait
|
45
|
+
rescue TransactionFailure
|
46
|
+
# someone else got it
|
47
|
+
else
|
48
|
+
write ["output", input, output]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
rescue TransactionFailure
|
31
52
|
end
|
53
|
+
my_pref = my_pref.exclude input
|
54
|
+
next
|
55
|
+
end
|
56
|
+
|
57
|
+
begin
|
58
|
+
txn.cancel
|
59
|
+
rescue TransactionFailure
|
60
|
+
end
|
61
|
+
break
|
62
|
+
end
|
63
|
+
|
64
|
+
loop do
|
65
|
+
_, input = take(["input", Integer])
|
32
66
|
write ["output", input, input.prime_division]
|
33
67
|
end
|
68
|
+
|
69
|
+
|
70
|
+
# _, input =
|
71
|
+
# begin
|
72
|
+
# take(my_pref, timeout: 1.0) # fewer fails (5.0 -> none at all)
|
73
|
+
# rescue TimeoutError
|
74
|
+
# take(["input", Integer])
|
75
|
+
# end
|
76
|
+
# write ["output", input, input.prime_division]
|
77
|
+
# end
|
34
78
|
}
|
35
79
|
end
|
36
80
|
|
@@ -4,7 +4,7 @@
|
|
4
4
|
#
|
5
5
|
# Unlike in a key-value store, a given key_string may occur more than once.
|
6
6
|
# It is up to the application to decide whether to enforce key uniqueness or
|
7
|
-
# not (for example, by taking
|
7
|
+
# not (for example, by taking [k,...] before writing [k,v]).
|
8
8
|
#
|
9
9
|
# This store should be used only by clients that subscribe to a subspace
|
10
10
|
# that can be represented as pairs. (See memo2.rb.)
|
data/example/multi-tier/memo2.rb
CHANGED
@@ -16,14 +16,11 @@ fork do
|
|
16
16
|
local do
|
17
17
|
use_subspaces!
|
18
18
|
|
19
|
-
define_subspace(
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
nil # value, can be any object (e.g. JSON object)
|
25
|
-
]
|
26
|
-
)
|
19
|
+
define_subspace("memo", [
|
20
|
+
"memo", # tag is encoded in each tuple, for recognizing
|
21
|
+
String, # key in the cache, must be string
|
22
|
+
nil # value, can be any object (e.g. JSON object)
|
23
|
+
])
|
27
24
|
end
|
28
25
|
|
29
26
|
child tuplespace: [KVSpace, "memo"], subscribe: ["memo"] do |client|
|
@@ -35,43 +35,34 @@ Tupelo.application do
|
|
35
35
|
use_subspaces!
|
36
36
|
|
37
37
|
# Subspace for tuples belonging to the addr book.
|
38
|
-
define_subspace(
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
nil # address; can be any object
|
44
|
-
]
|
45
|
-
)
|
38
|
+
define_subspace(ab_tag, [
|
39
|
+
ab_tag,
|
40
|
+
String, # name
|
41
|
+
nil # address; can be any object
|
42
|
+
])
|
46
43
|
|
47
44
|
# Subspace for commands for fetch, delete, first, last, prev, next.
|
48
45
|
# We can't use #read and #take for fetch and delete because then the
|
49
46
|
# requesting client would have to subscribe to the ab_tag subspace.
|
50
|
-
define_subspace(
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
{type: "list"} # arguments
|
57
|
-
]
|
58
|
-
)
|
47
|
+
define_subspace(cmd_tag, [
|
48
|
+
cmd_tag,
|
49
|
+
nil, # request id, such as [client_id, uniq_id]
|
50
|
+
String, # cmd name
|
51
|
+
Array # arguments
|
52
|
+
])
|
59
53
|
|
60
54
|
# Subspace for responses to commands. A response identifies the command
|
61
55
|
# it is responding to in two ways: by copying it and by an id. The
|
62
56
|
# former is so that another client can "spy" on one client's query
|
63
57
|
# responses, perhaps saving effort. The latter is to distinguish between
|
64
58
|
# iterations of the same command (first, first, ...).
|
65
|
-
define_subspace(
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
nil, # result of query -- type depends on command
|
73
|
-
]
|
74
|
-
)
|
59
|
+
define_subspace(resp_tag, [
|
60
|
+
resp_tag,
|
61
|
+
nil, # in response to this request id
|
62
|
+
String, # cmd name
|
63
|
+
Array, # arguments
|
64
|
+
nil, # result of query -- type depends on command
|
65
|
+
])
|
75
66
|
end
|
76
67
|
|
77
68
|
N_REPLICAS.times do |i|
|
data/example/subspaces/pubsub.rb
CHANGED
@@ -19,22 +19,10 @@ Tupelo.application do
|
|
19
19
|
use_subspaces!
|
20
20
|
|
21
21
|
N_CHAN.times do |i|
|
22
|
-
define_subspace
|
23
|
-
tag: i,
|
24
|
-
template: [
|
25
|
-
{value: i},
|
26
|
-
{type: "string"}
|
27
|
-
]
|
28
|
-
)
|
22
|
+
define_subspace i, [i, String]
|
29
23
|
end
|
30
24
|
|
31
|
-
define_subspace
|
32
|
-
tag: "control",
|
33
|
-
template: [
|
34
|
-
{value: "control"},
|
35
|
-
nil
|
36
|
-
]
|
37
|
-
)
|
25
|
+
define_subspace "control", ["control", nil]
|
38
26
|
end
|
39
27
|
|
40
28
|
N_PUBS.times do |pi|
|
data/example/subspaces/ramp.rb
CHANGED
@@ -45,31 +45,24 @@ Tupelo.application do
|
|
45
45
|
local do
|
46
46
|
use_subspaces!
|
47
47
|
|
48
|
-
|
49
|
-
tag: "x",
|
50
|
-
template: {
|
51
|
-
x: {type: "number"}, # data payload
|
52
|
-
id: {type: "list"}, # [client_id, local_id]
|
53
|
-
final: {type: "boolean"} # false means pending
|
54
|
-
}
|
55
|
-
)
|
48
|
+
bool = PortableObjectTemplate::BOOLEAN
|
56
49
|
|
57
|
-
define_subspace(
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
)
|
50
|
+
define_subspace("x", {
|
51
|
+
x: Numeric, # data payload
|
52
|
+
id: Array, # [client_id, local_id]
|
53
|
+
final: bool # false means pending
|
54
|
+
})
|
55
|
+
|
56
|
+
define_subspace("y", {
|
57
|
+
y: Numeric, # data payload
|
58
|
+
id: Array, # [client_id, local_id]
|
59
|
+
final: bool # false means pending
|
60
|
+
})
|
61
|
+
|
62
|
+
define_subspace("ack", { # could make this per-client
|
63
|
+
ack: String, # state ack-ed: "pending"
|
64
|
+
id: Array # [client_id, local_id]
|
65
|
+
})
|
73
66
|
end
|
74
67
|
|
75
68
|
X_REPLICATIONS.times do |xi|
|
@@ -12,14 +12,11 @@ Tupelo.application do
|
|
12
12
|
local do
|
13
13
|
use_subspaces!
|
14
14
|
|
15
|
-
define_subspace(
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
{type: "number"} # count
|
21
|
-
]
|
22
|
-
)
|
15
|
+
define_subspace("inventory", [
|
16
|
+
"product",
|
17
|
+
nil, # product_id
|
18
|
+
Integer # count
|
19
|
+
])
|
23
20
|
|
24
21
|
PRODUCT_IDS.each do |product_id|
|
25
22
|
count = 10
|
data/example/subspaces/simple.rb
CHANGED
@@ -9,15 +9,7 @@ Tupelo.application do
|
|
9
9
|
log [subscribed_all, subscribed_tags]
|
10
10
|
|
11
11
|
use_subspaces!
|
12
|
-
|
13
|
-
define_subspace(
|
14
|
-
tag: "foo",
|
15
|
-
template: [
|
16
|
-
{type: "number"}
|
17
|
-
]
|
18
|
-
)
|
19
|
-
|
20
|
-
write_wait [0]
|
12
|
+
define_subspace "foo", [Numeric]
|
21
13
|
|
22
14
|
log read_all(Object)
|
23
15
|
end
|
@@ -1,3 +1,7 @@
|
|
1
|
+
# This example doesn't work yet because there is no way to indicate that,
|
2
|
+
# instead of waiting, it would be better to continue searching the tuplespace,
|
3
|
+
# backtracking around the [Integer, String] tuple that did not have a mate.
|
4
|
+
|
1
5
|
require 'tupelo/app'
|
2
6
|
|
3
7
|
Tupelo.application do
|
File without changes
|
@@ -0,0 +1,59 @@
|
|
1
|
+
# This works, but requires a fix-up step to clean up after a race condition
|
2
|
+
# during counter initialization.
|
3
|
+
|
4
|
+
require 'tupelo/app'
|
5
|
+
|
6
|
+
Tupelo.application do
|
7
|
+
2.times do
|
8
|
+
child passive: true do
|
9
|
+
loop do
|
10
|
+
transaction do
|
11
|
+
fish, _ = take([String])
|
12
|
+
n, _ = take_nowait([Integer, fish])
|
13
|
+
if n
|
14
|
+
write [n + 1, fish]
|
15
|
+
else
|
16
|
+
write [1, fish] # another process might also write this, so ...
|
17
|
+
write ["fixup", fish]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
child passive: true do
|
25
|
+
loop do
|
26
|
+
transaction do # fix up the two counter tuples
|
27
|
+
_, fish = take ["fixup", String]
|
28
|
+
n1, _ = take_nowait [Integer, fish]
|
29
|
+
if n1
|
30
|
+
n2, _ = take_nowait [Integer, fish]
|
31
|
+
if n2
|
32
|
+
#log "fixing: #{[n1 + n2, fish]}"
|
33
|
+
write [n1 + n2, fish]
|
34
|
+
else
|
35
|
+
write [n1, fish] # partial rollback
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
local do
|
43
|
+
seed = 3
|
44
|
+
srand seed
|
45
|
+
log "seed = #{seed}"
|
46
|
+
|
47
|
+
fishes = %w{ trout marlin char salmon }
|
48
|
+
|
49
|
+
a = fishes * 10
|
50
|
+
a.shuffle!
|
51
|
+
a.each do |fish|
|
52
|
+
write [fish]
|
53
|
+
end
|
54
|
+
|
55
|
+
fishes.each do |fish|
|
56
|
+
log take [10, fish]
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
data/lib/tupelo/app/builder.rb
CHANGED
@@ -68,6 +68,8 @@ module Tupelo
|
|
68
68
|
# the passive flag for processes that wait for tuples and respond in some
|
69
69
|
# way. Then you do not have to manually interrupt the whole application when
|
70
70
|
# the active processes are done. See examples.
|
71
|
+
#
|
72
|
+
# Returns pid of child process.
|
71
73
|
def child client_class = Client, passive: false, **opts, &block
|
72
74
|
ez.child :seqd, :cseqd, :arcd, passive: passive do |seqd, cseqd, arcd|
|
73
75
|
opts = {seq: seqd, cseq: cseqd, arc: arcd, log: log}.merge(opts)
|
@@ -0,0 +1,36 @@
|
|
1
|
+
class Tupelo::Client
|
2
|
+
module Api
|
3
|
+
def define_subspace tag, template, addr: nil
|
4
|
+
metatuple = {
|
5
|
+
__tupelo__: "subspace",
|
6
|
+
tag: tag,
|
7
|
+
template: PortableObjectTemplate.spec_from(template),
|
8
|
+
addr: addr
|
9
|
+
}
|
10
|
+
write_wait metatuple
|
11
|
+
end
|
12
|
+
|
13
|
+
# call this just once at start of first client (it's optional to
|
14
|
+
# preserve behavior of non-subspace-aware code)
|
15
|
+
def use_subspaces!
|
16
|
+
return if subspace(TUPELO_SUBSPACE_TAG)
|
17
|
+
define_subspace(TUPELO_SUBSPACE_TAG, {
|
18
|
+
__tupelo__: "subspace",
|
19
|
+
tag: nil,
|
20
|
+
template: nil,
|
21
|
+
addr: nil
|
22
|
+
})
|
23
|
+
end
|
24
|
+
|
25
|
+
def subspace tag
|
26
|
+
tag = tag.to_s
|
27
|
+
worker.subspaces.find {|sp| sp.tag == tag} or begin
|
28
|
+
if subscribed_tags.include? tag
|
29
|
+
read __tupelo__: "subspace", tag: tag, addr: nil, template: nil
|
30
|
+
worker.subspaces.find {|sp| sp.tag == tag}
|
31
|
+
end
|
32
|
+
end
|
33
|
+
## this impl will not be safe with dynamic subspaces
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -42,8 +42,8 @@ class Tupelo::Client
|
|
42
42
|
def each(*); end
|
43
43
|
def delete_once(*); end
|
44
44
|
def insert(*); self; end
|
45
|
-
def find_distinct_matches_for(*); raise; end
|
46
|
-
def find_match_for(*); raise; end
|
45
|
+
def find_distinct_matches_for(*); raise; end
|
46
|
+
def find_match_for(*); raise; end
|
47
47
|
def clear; end
|
48
48
|
|
49
49
|
## should store space metadata, so outgoing writes can be tagged
|
data/lib/tupelo/client/worker.rb
CHANGED
@@ -351,12 +351,9 @@ class Tupelo::Client
|
|
351
351
|
end
|
352
352
|
|
353
353
|
take_tuples.each do |tuple|
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
## do some error checking
|
358
|
-
subspaces.delete_if {|sp| sp.tag == tuple["tag"]}
|
359
|
-
end
|
354
|
+
if is_meta_tuple? tuple
|
355
|
+
## do some error checking
|
356
|
+
subspaces.delete_if {|sp| sp.tag == tuple["tag"]}
|
360
357
|
end
|
361
358
|
end
|
362
359
|
end
|
@@ -435,13 +432,17 @@ class Tupelo::Client
|
|
435
432
|
end
|
436
433
|
end
|
437
434
|
|
435
|
+
# Returns true if tuple is subspace metadata.
|
436
|
+
def is_meta_tuple? tuple
|
437
|
+
tuple.kind_of? Hash and tuple.key? "__tupelo__" and
|
438
|
+
tuple["__tupelo__"] == "subspace"
|
439
|
+
end
|
440
|
+
|
438
441
|
def sniff_meta_tuple tuple
|
439
|
-
if
|
440
|
-
|
441
|
-
|
442
|
-
|
443
|
-
subspaces << Subspace.new(tuple, self)
|
444
|
-
end
|
442
|
+
if is_meta_tuple? tuple
|
443
|
+
## do some error checking
|
444
|
+
## what if subspace already exists?
|
445
|
+
subspaces << Subspace.new(tuple, self)
|
445
446
|
end
|
446
447
|
end
|
447
448
|
|
data/lib/tupelo/client.rb
CHANGED
@@ -4,6 +4,7 @@ module Tupelo
|
|
4
4
|
class Client < Funl::Client
|
5
5
|
require 'tupelo/client/worker'
|
6
6
|
require 'tupelo/client/tuplespace'
|
7
|
+
require 'tupelo/client/subspace'
|
7
8
|
|
8
9
|
include Api
|
9
10
|
|
@@ -60,37 +61,5 @@ module Tupelo
|
|
60
61
|
super().unknown *args
|
61
62
|
end
|
62
63
|
end
|
63
|
-
|
64
|
-
## do these belong in API module?
|
65
|
-
def define_subspace metatuple
|
66
|
-
defaults = {__tupelo__: "subspace", addr: nil}
|
67
|
-
write_wait defaults.merge!(metatuple)
|
68
|
-
end
|
69
|
-
|
70
|
-
# call this just once at start of first client (it's optional to
|
71
|
-
# preserve behavior of non-subspace-aware code)
|
72
|
-
def use_subspaces!
|
73
|
-
return if subspace(TUPELO_SUBSPACE_TAG)
|
74
|
-
define_subspace(
|
75
|
-
tag: TUPELO_SUBSPACE_TAG,
|
76
|
-
template: {
|
77
|
-
__tupelo__: {value: "subspace"},
|
78
|
-
tag: nil,
|
79
|
-
addr: nil,
|
80
|
-
template: nil
|
81
|
-
}
|
82
|
-
)
|
83
|
-
end
|
84
|
-
|
85
|
-
def subspace tag
|
86
|
-
tag = tag.to_s
|
87
|
-
worker.subspaces.find {|sp| sp.tag == tag} or begin
|
88
|
-
if subscribed_tags.include? tag
|
89
|
-
read __tupelo__: "subspace", tag: tag, addr: nil, template: nil
|
90
|
-
worker.subspaces.find {|sp| sp.tag == tag}
|
91
|
-
end
|
92
|
-
end
|
93
|
-
## this impl will not be safe with dynamic subspaces
|
94
|
-
end
|
95
64
|
end
|
96
65
|
end
|