asterism 0.8.0 → 0.10.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 +71 -0
- data/README.md +193 -21
- data/lib/asterism/cli/complete.rb +283 -0
- data/lib/asterism/cli/hz.rb +77 -0
- data/lib/asterism/cli/mcp.rb +56 -1
- data/lib/asterism/cli/nouns.rb +493 -0
- data/lib/asterism/cli/survey.rb +6 -4
- data/lib/asterism/cli/text.rb +256 -0
- data/lib/asterism/cli/values.rb +216 -0
- data/lib/asterism/cli.rb +389 -98
- data/lib/asterism/version.rb +1 -1
- metadata +7 -2
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# How the command line's text looks (not --json, which stays as it is):
|
|
2
|
+
# colours and the terminal's width when the output is a terminal, plain
|
|
3
|
+
# text 80 columns wide (or COLUMNS) otherwise; NO_COLOR turns the colours
|
|
4
|
+
# off. Statuses are laid out as indented key=value lines, the methods and
|
|
5
|
+
# the descriptions are wrapped to the width, and watch tells what changed
|
|
6
|
+
# in a status. The standard library only (io/console for the width).
|
|
7
|
+
require "json"
|
|
8
|
+
|
|
9
|
+
module Asterism
|
|
10
|
+
class CLI
|
|
11
|
+
# Colours and width for one output.
|
|
12
|
+
class Style
|
|
13
|
+
CODES = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, blue: 34, magenta: 35, cyan: 36 }.freeze
|
|
14
|
+
# What each kind of thing is painted with.
|
|
15
|
+
ROLES = {
|
|
16
|
+
node: %i[bold blue], app: [:cyan], object: [:green], method: [:bold], note: [:dim], time: [:dim],
|
|
17
|
+
join: [:green], leave: [:red], status: [:yellow], profile: [:magenta]
|
|
18
|
+
}.freeze
|
|
19
|
+
MIN_WIDTH = 20
|
|
20
|
+
|
|
21
|
+
attr_reader :width
|
|
22
|
+
|
|
23
|
+
def initialize(io, env = ENV)
|
|
24
|
+
tty = io.respond_to?(:tty?) && io.tty?
|
|
25
|
+
@color = tty && env["NO_COLOR"].to_s.empty?
|
|
26
|
+
w = (tty && terminal_width(io)) || env["COLUMNS"].to_i
|
|
27
|
+
@width = w.positive? ? [w, MIN_WIDTH].max : 80
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def color?
|
|
31
|
+
@color
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# text painted as role (a ROLES key) or with the CODES given.
|
|
35
|
+
def paint(text, *styles)
|
|
36
|
+
text = text.to_s
|
|
37
|
+
return text unless @color
|
|
38
|
+
codes = styles.flat_map { ROLES.fetch(_1, [_1]) }.map { CODES.fetch(_1) }
|
|
39
|
+
codes.empty? ? text : "\e[#{codes.join(';')}m#{text}\e[0m"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def terminal_width(io)
|
|
45
|
+
require "io/console"
|
|
46
|
+
w = io.winsize[1]
|
|
47
|
+
w.positive? ? w : nil
|
|
48
|
+
rescue StandardError, LoadError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Laying text out: display widths (East Asian wide characters take two
|
|
54
|
+
# columns), word wrapping, statuses and their differences.
|
|
55
|
+
module Text
|
|
56
|
+
module_function
|
|
57
|
+
|
|
58
|
+
ANSI = /\e\[[0-9;]*m/
|
|
59
|
+
# Code points that take two columns on a terminal (East Asian Wide and
|
|
60
|
+
# Fullwidth, roughly).
|
|
61
|
+
WIDE = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-가-힣豈-︰-﹏-⦆¢-₩\u{1F300}-\u{1F64F}\u{1F900}-\u{1F9FF}\u{20000}-\u{3FFFD}]/
|
|
62
|
+
# A string value longer than this (columns) is cut, and so is an
|
|
63
|
+
# array written on one line; --json has them whole.
|
|
64
|
+
LONG = 60
|
|
65
|
+
ARROW = "→"
|
|
66
|
+
ELLIPSIS = "…"
|
|
67
|
+
|
|
68
|
+
# The columns text takes on a terminal (colour codes take none).
|
|
69
|
+
def width(text)
|
|
70
|
+
s = text.to_s.gsub(ANSI, "")
|
|
71
|
+
s = s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless s.encoding == Encoding::UTF_8
|
|
72
|
+
s.scrub.each_char.sum { _1.match?(WIDE) ? 2 : 1 }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The tokens joined with sep in lines at most cols columns wide: the
|
|
76
|
+
# first starts with first, the others with rest (spaces as wide as
|
|
77
|
+
# first by default). A plain token wider than a line is broken between
|
|
78
|
+
# characters (break_long: false keeps it whole, on a line of its own);
|
|
79
|
+
# a painted one (a method's signature) is kept whole. Returns the
|
|
80
|
+
# lines (at least one).
|
|
81
|
+
def wrap(tokens, cols, first: "", rest: nil, sep: " ", break_long: true)
|
|
82
|
+
rest ||= " " * width(first)
|
|
83
|
+
lines = []
|
|
84
|
+
line = first
|
|
85
|
+
lw = width(first)
|
|
86
|
+
empty = true
|
|
87
|
+
tokens.each do |tok|
|
|
88
|
+
tok = tok.to_s
|
|
89
|
+
tw = width(tok)
|
|
90
|
+
if !empty && lw + sep.size + tw > cols
|
|
91
|
+
lines << line
|
|
92
|
+
line = rest
|
|
93
|
+
lw = width(rest)
|
|
94
|
+
empty = true
|
|
95
|
+
end
|
|
96
|
+
while break_long && lw + tw > cols && !tok.match?(ANSI) && tok.size > 1
|
|
97
|
+
head, tail = split_width(tok, [cols - lw, 1].max)
|
|
98
|
+
break if tail.empty?
|
|
99
|
+
lines << (empty ? line : line + sep) + head
|
|
100
|
+
line = rest
|
|
101
|
+
lw = width(rest)
|
|
102
|
+
empty = true
|
|
103
|
+
tok = tail
|
|
104
|
+
tw = width(tok)
|
|
105
|
+
end
|
|
106
|
+
line += (empty ? "" : sep) + tok
|
|
107
|
+
lw += (empty ? 0 : sep.size) + tw
|
|
108
|
+
empty = false
|
|
109
|
+
end
|
|
110
|
+
lines << line
|
|
111
|
+
lines
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# text's words wrapped (wrap), each paragraph (blank lines between)
|
|
115
|
+
# starting a new line with rest.
|
|
116
|
+
def wrap_text(text, cols, first: "", rest: nil)
|
|
117
|
+
rest ||= " " * width(first)
|
|
118
|
+
paras = text.to_s.strip.split(/\n\s*\n/).map(&:split).reject(&:empty?)
|
|
119
|
+
return [first.rstrip] if paras.empty?
|
|
120
|
+
paras.each_with_index.flat_map { |words, i| wrap(words, cols, first: i.zero? ? first : rest, rest: rest) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# [the start of text no wider than cols (one character at least), the rest]
|
|
124
|
+
def split_width(text, cols)
|
|
125
|
+
w = 0
|
|
126
|
+
n = 0
|
|
127
|
+
text.each_char do |c|
|
|
128
|
+
cw = width(c)
|
|
129
|
+
break if n.positive? && w + cw > cols
|
|
130
|
+
w += cw
|
|
131
|
+
n += 1
|
|
132
|
+
end
|
|
133
|
+
[text[0, n], text[n..]]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------ statuses
|
|
137
|
+
|
|
138
|
+
# A status (a Hash) as lines indented by indent spaces: the plain
|
|
139
|
+
# values of a level as key=value on one line (wrapped), a Hash of
|
|
140
|
+
# plain values as "key: k=v k=v", deeper ones as "key:" and their
|
|
141
|
+
# lines two spaces further in, an array with a Hash in it as "key:"
|
|
142
|
+
# and its elements as "- " lines. A key=value is never broken: one
|
|
143
|
+
# wider than the line has a line of its own (the terminal folds it).
|
|
144
|
+
def status_lines(h, cols, indent = 0)
|
|
145
|
+
pad = " " * indent
|
|
146
|
+
lines = []
|
|
147
|
+
flat = []
|
|
148
|
+
flush = lambda do
|
|
149
|
+
lines.concat(wrap(flat, cols, first: pad, break_long: false)) unless flat.empty?
|
|
150
|
+
flat = []
|
|
151
|
+
end
|
|
152
|
+
h.each do |k, v|
|
|
153
|
+
if plain?(v)
|
|
154
|
+
flat << pair(k, v)
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
flush.call
|
|
158
|
+
if v.is_a?(Hash) && v.empty?
|
|
159
|
+
lines << "#{pad}#{k}: {}"
|
|
160
|
+
elsif v.is_a?(Hash) && v.values.all? { plain?(_1) }
|
|
161
|
+
# The lines after the first under the first value (two spaces in
|
|
162
|
+
# when the key is long).
|
|
163
|
+
hang = width(k) + 2 > cols / 3 ? 2 : width(k) + 2
|
|
164
|
+
lines.concat(wrap(v.map { |kk, vv| pair(kk, vv) }, cols, first: "#{pad}#{k}: ", rest: pad + (" " * hang),
|
|
165
|
+
break_long: false))
|
|
166
|
+
elsif v.is_a?(Hash)
|
|
167
|
+
lines << "#{pad}#{k}:"
|
|
168
|
+
lines.concat(status_lines(v, cols, indent + 2))
|
|
169
|
+
else
|
|
170
|
+
lines << "#{pad}#{k}:"
|
|
171
|
+
v.each { |e| lines.concat(element_lines(e, cols, indent + 2)) }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
flush.call
|
|
175
|
+
lines.empty? ? ["#{pad}{}"] : lines
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# One element of an array that holds a Hash: "- " and the element.
|
|
179
|
+
def element_lines(e, cols, indent)
|
|
180
|
+
pad = " " * indent
|
|
181
|
+
if e.is_a?(Hash) && !e.empty? && e.values.all? { plain?(_1) }
|
|
182
|
+
wrap(e.map { |k, v| pair(k, v) }, cols, first: "#{pad}- ", rest: "#{pad} ", break_long: false)
|
|
183
|
+
elsif e.is_a?(Hash) && !e.empty?
|
|
184
|
+
sub = status_lines(e, cols, indent + 2)
|
|
185
|
+
sub[0] = "#{pad}- #{sub[0].lstrip}"
|
|
186
|
+
sub
|
|
187
|
+
else
|
|
188
|
+
["#{pad}- #{inline(e)}"]
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# No Hash anywhere in v (it goes on one line).
|
|
193
|
+
def plain?(v)
|
|
194
|
+
case v
|
|
195
|
+
when Hash then false
|
|
196
|
+
when Array then v.all? { plain?(_1) }
|
|
197
|
+
else true
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def pair(k, v)
|
|
202
|
+
"#{k}=#{inline(v)}"
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# A value on one line: a word as it is, other text quoted (JSON), a
|
|
206
|
+
# long one cut; [a, b] (a long array cut, with the count left out);
|
|
207
|
+
# {k=v k=v}; nil as nil.
|
|
208
|
+
def inline(v)
|
|
209
|
+
case v
|
|
210
|
+
when Hash then "{#{v.map { |k, x| pair(k, x) }.join(' ')}}"
|
|
211
|
+
when Array then inline_array(v)
|
|
212
|
+
when String then inline_string(v)
|
|
213
|
+
when nil then "nil"
|
|
214
|
+
else v.to_s
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def inline_string(s)
|
|
219
|
+
s = s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace) unless s.encoding == Encoding::UTF_8
|
|
220
|
+
s = "#{split_width(s, LONG - 1)[0]}#{ELLIPSIS}" if width(s) > LONG
|
|
221
|
+
s.match?(/\A[^\s="'{}\[\],]+\z/) ? s : JSON.generate(s)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def inline_array(a)
|
|
225
|
+
parts = a.map { inline(_1) }
|
|
226
|
+
text = "[#{parts.join(', ')}]"
|
|
227
|
+
return text if width(text) <= LONG || parts.size < 2
|
|
228
|
+
kept = []
|
|
229
|
+
parts.each do |p|
|
|
230
|
+
break if width("[#{(kept + [p]).join(', ')}, #{ELLIPSIS}+#{parts.size}]") > LONG && kept.any?
|
|
231
|
+
kept << p
|
|
232
|
+
end
|
|
233
|
+
"[#{kept.join(', ')}, #{ELLIPSIS}+#{parts.size - kept.size}]"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# What changed from the status old to new, one String a change:
|
|
237
|
+
# "a.b 0→1" (a value changed), "+a.b=1" (a key came; a Hash on one
|
|
238
|
+
# line), "-a.b" (a key went). Hashes in both are compared key by key.
|
|
239
|
+
def status_diff(old, new, prefix = nil)
|
|
240
|
+
out = []
|
|
241
|
+
at = ->(k) { prefix ? "#{prefix}.#{k}" : k.to_s }
|
|
242
|
+
new.each do |k, v|
|
|
243
|
+
if !old.key?(k)
|
|
244
|
+
out << "+#{pair(at.(k), v)}"
|
|
245
|
+
elsif old[k].is_a?(Hash) && v.is_a?(Hash)
|
|
246
|
+
out.concat(status_diff(old[k], v, at.(k)))
|
|
247
|
+
elsif old[k] != v
|
|
248
|
+
out << "#{at.(k)} #{inline(old[k])}#{ARROW}#{inline(v)}"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
old.each_key { |k| out << "-#{at.(k)}" unless new.key?(k) }
|
|
252
|
+
out
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# The values the applications put on their own keys
|
|
2
|
+
# (asterism/<node>/<app>/<object>/<more>, docs/protocol.md 2.2: a game
|
|
3
|
+
# table's state, a log), for `asterism object get / echo` and the MCP
|
|
4
|
+
# bridge's asterism_values. A put is not kept anywhere and a get has no
|
|
5
|
+
# answer for it (an application's queryable answers only the key shapes of
|
|
6
|
+
# 4.3), so the latest value is only what a subscriber received and kept:
|
|
7
|
+
# Values::Store keeps it, a key at a time, within limits.
|
|
8
|
+
require "json"
|
|
9
|
+
|
|
10
|
+
module Asterism
|
|
11
|
+
class CLI
|
|
12
|
+
module Values
|
|
13
|
+
PREFIX = "asterism/"
|
|
14
|
+
# Characters a part of a key cannot have (2.1); * and ** are whole
|
|
15
|
+
# parts.
|
|
16
|
+
BAD = /[*$?#]/
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The pattern (node/app/object/more, * and ** as whole parts) checked;
|
|
21
|
+
# ArgumentError otherwise.
|
|
22
|
+
def check_pattern!(pattern)
|
|
23
|
+
parts = pattern.to_s.split("/", -1)
|
|
24
|
+
bad = parts.empty? || parts.any? do |p|
|
|
25
|
+
p.empty? || p.start_with?("@") || (p.match?(BAD) && !%w[* **].include?(p))
|
|
26
|
+
end
|
|
27
|
+
raise ArgumentError, "#{pattern.inspect}: a key's parts are names, * (one part) or ** (any parts)" if bad
|
|
28
|
+
pattern
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The Zenoh key expression of a pattern.
|
|
32
|
+
def key_expr(pattern)
|
|
33
|
+
"#{PREFIX}#{pattern}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def match?(pattern, key)
|
|
37
|
+
::Asterism.glob?(pattern, key)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# [value, format] of a payload: MessagePack (Asterism's own, 3.1);
|
|
41
|
+
# else JSON text; else UTF-8 text; else "(n bytes)". The value is
|
|
42
|
+
# what JSON can hold (bytes inside as "(n bytes)").
|
|
43
|
+
def decode(payload)
|
|
44
|
+
bytes = payload.to_s.b
|
|
45
|
+
return [nil, "empty"] if bytes.empty?
|
|
46
|
+
begin
|
|
47
|
+
return [json_safe(::MessagePack.unpack(bytes)), "msgpack"]
|
|
48
|
+
rescue StandardError
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
52
|
+
return ["(#{bytes.bytesize} bytes)", "bytes"] unless text.valid_encoding?
|
|
53
|
+
begin
|
|
54
|
+
[json_safe(JSON.parse(text)), "json"]
|
|
55
|
+
rescue JSON::ParserError
|
|
56
|
+
[text, "text"]
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def json_safe(v)
|
|
61
|
+
case v
|
|
62
|
+
when Hash then v.to_h { |k, x| [k.is_a?(String) ? k : k.to_s, json_safe(x)] }
|
|
63
|
+
when Array then v.map { json_safe(_1) }
|
|
64
|
+
when String then v.encoding == Encoding::BINARY && !v.ascii_only? ? "(#{v.bytesize} bytes)" : v
|
|
65
|
+
when Float then v.finite? ? v : v.to_s
|
|
66
|
+
when Integer, true, false, nil then v
|
|
67
|
+
when Symbol then v.to_s
|
|
68
|
+
else v.inspect
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def time(t)
|
|
73
|
+
t.getutc.strftime("%Y-%m-%dT%H:%M:%S.%LZ")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# One put as the command line and the MCP tool show it: {"key"
|
|
77
|
+
# (without asterism/), "t", "bytes", "format", "value"}.
|
|
78
|
+
def entry(key, payload, t = Time.now)
|
|
79
|
+
value, format = decode(payload)
|
|
80
|
+
{ "key" => key.delete_prefix(PREFIX), "t" => time(t), "bytes" => payload.to_s.bytesize,
|
|
81
|
+
"format" => format, "value" => value }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The latest put of each key, as received since it was made: at most
|
|
85
|
+
# max_keys keys and max_total payload bytes (the key received
|
|
86
|
+
# longest ago is forgotten first); a payload over max_bytes is kept
|
|
87
|
+
# as its size only. Thread safe; wait blocks until a key matches.
|
|
88
|
+
class Store
|
|
89
|
+
MAX_KEYS = 1000
|
|
90
|
+
MAX_BYTES = 256 * 1024
|
|
91
|
+
MAX_TOTAL = 16 * 1024 * 1024
|
|
92
|
+
# The JSON text of a value longer than this is cut in an answer.
|
|
93
|
+
VALUE_CUT = 8192
|
|
94
|
+
# The most keys an answer lists (the rest counted as "more").
|
|
95
|
+
MAX_ANSWER = 100
|
|
96
|
+
# The longest wait an answer may ask for (seconds).
|
|
97
|
+
MAX_WAIT = 30
|
|
98
|
+
|
|
99
|
+
attr_reader :since, :forgotten
|
|
100
|
+
|
|
101
|
+
# alive: given "<node>/<app>", true when the application is on the
|
|
102
|
+
# network now. clock: the monotonic seconds (for the tests).
|
|
103
|
+
def initialize(max_keys: MAX_KEYS, max_bytes: MAX_BYTES, max_total: MAX_TOTAL, alive: nil,
|
|
104
|
+
clock: nil, now: nil)
|
|
105
|
+
@max_keys = max_keys
|
|
106
|
+
@max_bytes = max_bytes
|
|
107
|
+
@max_total = max_total
|
|
108
|
+
@alive = alive || ->(_app) { true }
|
|
109
|
+
@clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
110
|
+
@now = now || -> { Time.now }
|
|
111
|
+
@since = @now.call
|
|
112
|
+
@keys = {} # key => record, the oldest received first
|
|
113
|
+
@total = 0
|
|
114
|
+
@forgotten = 0
|
|
115
|
+
@lock = Mutex.new
|
|
116
|
+
@cond = ConditionVariable.new
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# A put received (key with or without asterism/).
|
|
120
|
+
def put(key, payload)
|
|
121
|
+
key = key.delete_prefix(PREFIX)
|
|
122
|
+
payload = payload.to_s
|
|
123
|
+
size = payload.bytesize
|
|
124
|
+
rec = if size > @max_bytes
|
|
125
|
+
{ "value" => "(#{size} bytes: too large to keep)", "format" => "dropped", "kept" => false }
|
|
126
|
+
else
|
|
127
|
+
v, f = Values.decode(payload)
|
|
128
|
+
{ "value" => v, "format" => f }
|
|
129
|
+
end
|
|
130
|
+
rec.merge!("key" => key, "bytes" => size, "t" => @now.call, "mono" => @clock.call, "left" => false)
|
|
131
|
+
@lock.synchronize do
|
|
132
|
+
old = @keys.delete(key)
|
|
133
|
+
@total -= old["kept"] == false ? 0 : old["bytes"] if old
|
|
134
|
+
@keys[key] = rec
|
|
135
|
+
@total += size unless rec["kept"] == false
|
|
136
|
+
while @keys.size > @max_keys || (@total > @max_total && @keys.size > 1)
|
|
137
|
+
_, gone = @keys.shift
|
|
138
|
+
@total -= gone["bytes"] unless gone["kept"] == false
|
|
139
|
+
@forgotten += 1
|
|
140
|
+
end
|
|
141
|
+
@cond.broadcast
|
|
142
|
+
end
|
|
143
|
+
self
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The application "<node>/<app>" left the network: its values are
|
|
147
|
+
# from before (kept, marked).
|
|
148
|
+
def left(app)
|
|
149
|
+
prefix = "#{app}/"
|
|
150
|
+
@lock.synchronize { @keys.each_value { |r| r["left"] = true if r["key"].start_with?(prefix) } }
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def size
|
|
155
|
+
@lock.synchronize { @keys.size }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def keys
|
|
159
|
+
@lock.synchronize { @keys.keys }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The records whose keys match (sorted by key); with wait (seconds),
|
|
163
|
+
# until one matches or the time is up.
|
|
164
|
+
def match(pattern, wait: 0)
|
|
165
|
+
deadline = @clock.call + wait.to_f
|
|
166
|
+
@lock.synchronize do
|
|
167
|
+
loop do
|
|
168
|
+
got = @keys.values.select { Values.match?(pattern, _1["key"]) }
|
|
169
|
+
return got.sort_by { _1["key"] } unless got.empty?
|
|
170
|
+
left = deadline - @clock.call
|
|
171
|
+
return [] if left <= 0
|
|
172
|
+
@cond.wait(@lock, left)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# What asterism_values answers: {"since", "values" => [{"key", "t",
|
|
178
|
+
# "age_s", "app", "bytes", "format", "value"(, "truncated")}]}, with
|
|
179
|
+
# "more" when more keys matched than an answer lists, "forgotten"
|
|
180
|
+
# when the limits made it forget keys, and "note" when nothing came.
|
|
181
|
+
def answer(pattern = "**", wait: 0)
|
|
182
|
+
Values.check_pattern!(pattern)
|
|
183
|
+
wait = wait.to_f.clamp(0, MAX_WAIT)
|
|
184
|
+
recs = match(pattern, wait: wait)
|
|
185
|
+
now = @clock.call
|
|
186
|
+
alive = {}
|
|
187
|
+
out = { "since" => Values.time(@since) }
|
|
188
|
+
out["values"] = recs.first(MAX_ANSWER).map do |r|
|
|
189
|
+
app = r["key"].split("/")[0, 2].join("/")
|
|
190
|
+
here = alive.key?(app) ? alive[app] : (alive[app] = @alive.call(app) ? true : false)
|
|
191
|
+
h = { "key" => r["key"], "t" => Values.time(r["t"]), "age_s" => (now - r["mono"]).round(1),
|
|
192
|
+
"app" => here ? (r["left"] ? "restarted" : "alive") : "gone", "bytes" => r["bytes"],
|
|
193
|
+
"format" => r["format"] }
|
|
194
|
+
text = JSON.generate(r["value"])
|
|
195
|
+
if text.bytesize > VALUE_CUT
|
|
196
|
+
h["value"] = "#{text.byteslice(0, VALUE_CUT).scrub('')}…"
|
|
197
|
+
h["truncated"] = true
|
|
198
|
+
else
|
|
199
|
+
h["value"] = r["value"]
|
|
200
|
+
end
|
|
201
|
+
h["truncated"] = true if r["kept"] == false
|
|
202
|
+
h
|
|
203
|
+
end
|
|
204
|
+
out["more"] = recs.size - MAX_ANSWER if recs.size > MAX_ANSWER
|
|
205
|
+
out["forgotten"] = @forgotten if @forgotten.positive?
|
|
206
|
+
if recs.empty?
|
|
207
|
+
out["note"] = "nothing came for #{pattern}#{wait.positive? ? " in #{format('%g', wait)} s" : ''}: " \
|
|
208
|
+
"it has not been put since this server started (puts come only when a value " \
|
|
209
|
+
"changes), or the router does not let this server read it"
|
|
210
|
+
end
|
|
211
|
+
out
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|