openc3 7.5.0 → 7.5.1
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/lib/openc3/api/limits_api.rb +149 -160
- data/lib/openc3/api/tlm_api.rb +219 -61
- data/lib/openc3/logs/log_writer.rb +9 -8
- data/lib/openc3/microservices/decom_microservice.rb +23 -1
- data/lib/openc3/microservices/interface_decom_common.rb +3 -1
- data/lib/openc3/models/cvt_model.rb +7 -2
- data/lib/openc3/models/target_model.rb +9 -6
- data/lib/openc3/packets/packet_item.rb +13 -8
- data/lib/openc3/script/api_shared.rb +10 -8
- data/lib/openc3/script/extract.rb +52 -5
- data/lib/openc3/script/script.rb +1 -1
- data/lib/openc3/topics/limits_event_topic.rb +90 -3
- data/lib/openc3/topics/router_topic.rb +6 -2
- data/lib/openc3/topics/system_events_topic.rb +9 -6
- data/lib/openc3/topics/topic.rb +8 -1
- data/lib/openc3/utilities/bucket.rb +5 -0
- data/lib/openc3/utilities/questdb_client.rb +126 -32
- data/lib/openc3/utilities/secrets.rb +5 -0
- data/lib/openc3/utilities/store_autoload.rb +3 -1
- data/lib/openc3/version.rb +5 -5
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_svelte/package.json +1 -1
- data/templates/tool_vue/package.json +3 -3
- data/templates/widget/package.json +2 -2
- metadata +1 -1
|
@@ -195,7 +195,8 @@ module OpenC3
|
|
|
195
195
|
end
|
|
196
196
|
|
|
197
197
|
# Splits `check()` comparison expressions, e.g. "== 'foo bar'" becomes ["==", "foo bar"]
|
|
198
|
-
|
|
198
|
+
# The optional block resolves an unquoted word, see extract_operand
|
|
199
|
+
def extract_operator_and_operand_from_comparison(comparison, &resolve_name)
|
|
199
200
|
operator, operand = comparison.split(nil, 2) # Ruby: second split arg is max number of resultant elements
|
|
200
201
|
|
|
201
202
|
if operand.nil?
|
|
@@ -207,7 +208,7 @@ module OpenC3
|
|
|
207
208
|
raise "ERROR: Invalid operator: '#{operator}'" unless COMPARISON_OPERATORS.include?(operator)
|
|
208
209
|
|
|
209
210
|
# split leaves any trailing whitespace on the operand which the anchored matches reject
|
|
210
|
-
operand = extract_operand(operand.strip)
|
|
211
|
+
operand = extract_operand(operand.strip, &resolve_name)
|
|
211
212
|
# 'in' is containment against a list of values in both Ruby and Python.
|
|
212
213
|
# Enforced here so check(), wait() and wait_check() all reject the same thing.
|
|
213
214
|
if operator == "in" and !operand.is_a?(Array)
|
|
@@ -219,7 +220,9 @@ module OpenC3
|
|
|
219
220
|
|
|
220
221
|
# Converts the operand of a `check()` comparison expression into a Ruby value.
|
|
221
222
|
# Note this deliberately does not eval the operand so only literal values are supported.
|
|
222
|
-
|
|
223
|
+
# The optional block is called with an unquoted word, including one inside a list, and
|
|
224
|
+
# returns the value to use or nil if the word is not recognized, e.g. a state name.
|
|
225
|
+
def extract_operand(operand, &resolve_name)
|
|
223
226
|
# A quoted operand must be a single complete string literal. Anything trailing the
|
|
224
227
|
# closing quote, e.g. "== 'a' garbage 'b'", is a syntax error rather than a string.
|
|
225
228
|
if (match = operand.match(/\A'((?:[^'\\]|\\.)*)'\z/m))
|
|
@@ -248,7 +251,7 @@ module OpenC3
|
|
|
248
251
|
# Arrays are parsed recursively so their elements follow exactly the same rules as a
|
|
249
252
|
# bare operand. JSON alone would reject ['ON', 'OFF'], [0xA, 0xB] and [true, nil].
|
|
250
253
|
if operand.start_with?('[') and operand.end_with?(']')
|
|
251
|
-
return split_operand_list(operand[1..-2]).map { |element| extract_operand(element) }
|
|
254
|
+
return split_operand_list(operand[1..-2]).map { |element| extract_operand(element, &resolve_name) }
|
|
252
255
|
end
|
|
253
256
|
# JSON handles decimal numbers and hashes
|
|
254
257
|
begin
|
|
@@ -272,7 +275,10 @@ module OpenC3
|
|
|
272
275
|
|
|
273
276
|
# A bare word is almost always a string the user forgot to quote
|
|
274
277
|
if operand.match?(/\A[A-Za-z_][A-Za-z0-9_]*\z/)
|
|
275
|
-
|
|
278
|
+
value = resolve_name&.call(operand)
|
|
279
|
+
return value unless value.nil?
|
|
280
|
+
|
|
281
|
+
raise NameError, "Unknown state name #{operand}. Did you mean '#{operand}' as a string?"
|
|
276
282
|
end
|
|
277
283
|
raise "ERROR: Unable to parse operand: #{operand}"
|
|
278
284
|
end
|
|
@@ -369,6 +375,47 @@ module OpenC3
|
|
|
369
375
|
return elements
|
|
370
376
|
end
|
|
371
377
|
|
|
378
|
+
# Splits a comparison like extract_operator_and_operand_from_comparison but matches state names.
|
|
379
|
+
# State names are stored upper case so a string operand which matches a state name ignoring case
|
|
380
|
+
# is compared against that state name, e.g. "== 'Connected'" matches the CONNECTED state.
|
|
381
|
+
# An unquoted state name, e.g. "== Connected" or "in [On, Off]", is also accepted. The block returns
|
|
382
|
+
# the item hash. It is only called once, when the operand could be a state name, so numeric
|
|
383
|
+
# comparisons don't make an additional API call. RAW values are never state names so it is never called.
|
|
384
|
+
def extract_comparison(comparison, value_type, &get_item)
|
|
385
|
+
item_states = nil
|
|
386
|
+
looked_up = false
|
|
387
|
+
states = lambda do
|
|
388
|
+
unless looked_up
|
|
389
|
+
looked_up = true
|
|
390
|
+
item_states = get_item.call['states'] unless value_type.to_s.upcase == 'RAW'
|
|
391
|
+
end
|
|
392
|
+
item_states
|
|
393
|
+
end
|
|
394
|
+
operator, operand = extract_operator_and_operand_from_comparison(comparison) do |name|
|
|
395
|
+
name.upcase if states.call&.key?(name.upcase)
|
|
396
|
+
end
|
|
397
|
+
return [operator, operand] unless operand_contains_string?(operand)
|
|
398
|
+
|
|
399
|
+
return [operator, states.call ? upcase_state_names(operand, states.call) : operand]
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Returns whether the operand is or contains a string, i.e. could be a state name
|
|
403
|
+
def operand_contains_string?(operand)
|
|
404
|
+
return true if operand.is_a?(String)
|
|
405
|
+
return operand.any? { |element| operand_contains_string?(element) } if operand.is_a?(Array)
|
|
406
|
+
return false
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# State names are stored upper case so any string in the operand which matches
|
|
410
|
+
# a state name ignoring case is replaced with that state name
|
|
411
|
+
def upcase_state_names(operand, states)
|
|
412
|
+
if operand.is_a?(String)
|
|
413
|
+
return states.key?(operand.upcase) ? operand.upcase : operand
|
|
414
|
+
end
|
|
415
|
+
return operand.map { |element| upcase_state_names(element, states) } if operand.is_a?(Array)
|
|
416
|
+
return operand
|
|
417
|
+
end
|
|
418
|
+
|
|
372
419
|
# Compares a telemetry value against an operand using the given operator.
|
|
373
420
|
# Returns false rather than raising if the two values can not be compared.
|
|
374
421
|
def compare_values(value, operator, operand)
|
data/lib/openc3/script/script.rb
CHANGED
|
@@ -313,7 +313,7 @@ module OpenC3
|
|
|
313
313
|
return disconnect if disconnect
|
|
314
314
|
# The only commands allowed through in disconnect mode are read-only
|
|
315
315
|
# Thus we allow the get, list, tlm and limits_enabled and subscribe methods
|
|
316
|
-
if method_name =~ /\w*_get$|^get_\w*|\w*_list$|^list_\w*|^tlm|^limits_enabled
|
|
316
|
+
if method_name =~ /\w*_get$|^get_\w*|\w*_list$|^list_\w*|^tlm|^limits_enabled\??$|^subscribe$/
|
|
317
317
|
return @json_drb.method_missing(method_name, *method_params, **kw_params)
|
|
318
318
|
else
|
|
319
319
|
return nil
|
|
@@ -25,6 +25,20 @@ module OpenC3
|
|
|
25
25
|
# While this isn't a clean separation of topics (streams) and models (key-value)
|
|
26
26
|
# it helps maintain consistency as the topic and model are linked.
|
|
27
27
|
class LimitsEventTopic < Topic
|
|
28
|
+
# Returns the items in a limits group grouped by target and packet, or nil if the
|
|
29
|
+
# group is undefined. Reads the store directly to avoid a circular reference to TargetModel.
|
|
30
|
+
# @return [Hash{String => Hash{String => Array<String>}}, nil]
|
|
31
|
+
def self.limits_group_items(group_name, scope:)
|
|
32
|
+
json = Store.hget("#{scope}__limits_groups", group_name)
|
|
33
|
+
return nil unless json
|
|
34
|
+
|
|
35
|
+
items = {}
|
|
36
|
+
JSON.parse(json, allow_nan: true, create_additions: true).each do |target_name, packet_name, item_name|
|
|
37
|
+
((items[target_name] ||= {})[packet_name] ||= []) << item_name
|
|
38
|
+
end
|
|
39
|
+
items
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
# Collect all unique target db_shards from TargetModel
|
|
29
43
|
def self._active_db_shards(scope:)
|
|
30
44
|
db_shards = Set.new([0])
|
|
@@ -85,6 +99,30 @@ module OpenC3
|
|
|
85
99
|
limits_settings['enabled'] = event[:enabled]
|
|
86
100
|
Store.hset("#{scope}__current_limits_settings", field, JSON.generate(limits_settings, allow_nan: true))
|
|
87
101
|
|
|
102
|
+
when :LIMITS_GROUP_ENABLE_STATE
|
|
103
|
+
# Groups can contain many thousands of items so read and write the settings in bulk
|
|
104
|
+
fields = []
|
|
105
|
+
(limits_group_items(event[:group_name], scope: scope) || {}).each do |target_name, packets|
|
|
106
|
+
packets.each do |packet_name, item_names|
|
|
107
|
+
item_names.each { |item_name| fields << "#{target_name}__#{packet_name}__#{item_name}" }
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
unless fields.empty?
|
|
111
|
+
all_settings = Store.hmget("#{scope}__current_limits_settings", *fields)
|
|
112
|
+
updates = {}
|
|
113
|
+
fields.each_with_index do |field, index|
|
|
114
|
+
limits_settings = all_settings[index]
|
|
115
|
+
if limits_settings
|
|
116
|
+
limits_settings = JSON.parse(limits_settings, allow_nan: true, create_additions: true)
|
|
117
|
+
else
|
|
118
|
+
limits_settings = {}
|
|
119
|
+
end
|
|
120
|
+
limits_settings['enabled'] = event[:enabled]
|
|
121
|
+
updates[field] = JSON.generate(limits_settings, allow_nan: true)
|
|
122
|
+
end
|
|
123
|
+
Store.hmset("#{scope}__current_limits_settings", *updates.flatten)
|
|
124
|
+
end
|
|
125
|
+
|
|
88
126
|
when :LIMITS_STATE_COLOR
|
|
89
127
|
# Persist the state color so it survives a decom microservice restart (applied by sync_system)
|
|
90
128
|
field = "#{event[:target_name]}__#{event[:packet_name]}__#{event[:item_name]}"
|
|
@@ -111,8 +149,9 @@ module OpenC3
|
|
|
111
149
|
end
|
|
112
150
|
|
|
113
151
|
# Write to all active db_shards so each decom microservice can read limits events inline
|
|
152
|
+
msg_hash = { event: JSON.generate(event, allow_nan: true) }
|
|
114
153
|
_active_db_shards(scope: scope).each do |db_shard|
|
|
115
|
-
Topic.write_topic("#{scope}__openc3_limits_events",
|
|
154
|
+
Topic.write_topic("#{scope}__openc3_limits_events", msg_hash, '*', 1000, db_shard: db_shard)
|
|
116
155
|
end
|
|
117
156
|
end
|
|
118
157
|
|
|
@@ -214,6 +253,26 @@ module OpenC3
|
|
|
214
253
|
Store.hdel("#{scope}__limits_sets", set_name)
|
|
215
254
|
end
|
|
216
255
|
|
|
256
|
+
# Returns true if limits events written after previous_id were trimmed from the
|
|
257
|
+
# stream before they were read. The stream keeps ~1000 entries so a flood of
|
|
258
|
+
# LIMITS_CHANGE events can trim events a slow reader hasn't processed yet.
|
|
259
|
+
# Stream IDs always increase, so if the oldest remaining entry is newer than the
|
|
260
|
+
# last entry read, the entries in between are gone. This can also report a gap
|
|
261
|
+
# when only previous_id itself was trimmed, which just causes an extra resync.
|
|
262
|
+
def self.events_trimmed?(previous_id, scope:, db_shard: 0)
|
|
263
|
+
# 0-0 means the stream was empty when the reader started
|
|
264
|
+
return false if previous_id.nil? or previous_id == '0-0'
|
|
265
|
+
oldest_id = Topic.get_oldest_message("#{scope}__openc3_limits_events", db_shard: db_shard)&.first
|
|
266
|
+
return false unless oldest_id
|
|
267
|
+
(oldest_id.split('-').map(&:to_i) <=> previous_id.split('-').map(&:to_i)) > 0
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Reload all limits state from the store, used to recover from missed limits events
|
|
271
|
+
def self.resync_system(scope:)
|
|
272
|
+
System.limits_set = current_set(scope: scope)
|
|
273
|
+
sync_system(scope: scope)
|
|
274
|
+
end
|
|
275
|
+
|
|
217
276
|
# Update the local System based on overall state
|
|
218
277
|
def self.sync_system(scope:)
|
|
219
278
|
all_limits_settings = Store.hgetall("#{scope}__current_limits_settings")
|
|
@@ -251,7 +310,7 @@ module OpenC3
|
|
|
251
310
|
|
|
252
311
|
# Process a single limits event hash and update the local System accordingly.
|
|
253
312
|
# Called inline by DecomMicroservice when reading from the limits events topic.
|
|
254
|
-
def self.process_event(event, telemetry: nil)
|
|
313
|
+
def self.process_event(event, scope:, telemetry: nil)
|
|
255
314
|
telemetry ||= System.telemetry.all
|
|
256
315
|
case event['type']
|
|
257
316
|
when 'LIMITS_CHANGE'
|
|
@@ -289,6 +348,34 @@ module OpenC3
|
|
|
289
348
|
end
|
|
290
349
|
end
|
|
291
350
|
|
|
351
|
+
when 'LIMITS_GROUP_ENABLE_STATE'
|
|
352
|
+
enabled = ConfigParser.handle_true_false_nil(event['enabled'])
|
|
353
|
+
(limits_group_items(event['group_name'], scope: scope) || {}).each do |target_name, packets|
|
|
354
|
+
target = telemetry[target_name]
|
|
355
|
+
next unless target
|
|
356
|
+
|
|
357
|
+
packets.each do |packet_name, item_names|
|
|
358
|
+
packet = target[packet_name]
|
|
359
|
+
next unless packet
|
|
360
|
+
|
|
361
|
+
item_names.each do |item_name|
|
|
362
|
+
next unless packet.items[item_name]
|
|
363
|
+
|
|
364
|
+
# The stream offset has already advanced past this event so isolate
|
|
365
|
+
# failures to a single item rather than skipping the rest of the group
|
|
366
|
+
begin
|
|
367
|
+
if enabled
|
|
368
|
+
System.limits.enable(target_name, packet_name, item_name)
|
|
369
|
+
else
|
|
370
|
+
System.limits.disable(target_name, packet_name, item_name)
|
|
371
|
+
end
|
|
372
|
+
rescue => e
|
|
373
|
+
Logger.error("Error #{enabled ? 'enabling' : 'disabling'} limits for #{target_name} #{packet_name} #{item_name}: #{e.formatted}")
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
292
379
|
when 'LIMITS_STATE_COLOR'
|
|
293
380
|
target_name = event['target_name']
|
|
294
381
|
packet_name = event['packet_name']
|
|
@@ -313,7 +400,7 @@ module OpenC3
|
|
|
313
400
|
topics = ["#{scope}__openc3_limits_events"]
|
|
314
401
|
Topic.read_topics(topics, nil, block_ms) do |_topic, _msg_id, event, _redis|
|
|
315
402
|
event = JSON.parse(event['event'], allow_nan: true, create_additions: true)
|
|
316
|
-
process_event(event, telemetry: telemetry)
|
|
403
|
+
process_event(event, scope: scope, telemetry: telemetry)
|
|
317
404
|
end
|
|
318
405
|
end
|
|
319
406
|
end
|
|
@@ -78,14 +78,18 @@ module OpenC3
|
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
+
# Write to the target's db_shard where its interface reads commands
|
|
81
82
|
def self.route_command(packet, target_names, scope:)
|
|
83
|
+
# Write to the target's db_shard where its interface reads commands
|
|
82
84
|
if packet.identified?
|
|
83
85
|
topic = "{#{scope}__CMD}TARGET__#{packet.target_name}"
|
|
84
|
-
|
|
86
|
+
db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
|
|
87
|
+
Topic.write_topic(topic, { 'target_name' => packet.target_name, 'cmd_name' => packet.packet_name, 'cmd_buffer' => packet.buffer(false) }, '*', 100, db_shard: db_shard)
|
|
85
88
|
elsif target_names.length == 1
|
|
86
89
|
topic = "{#{scope}__CMD}TARGET__#{target_names[0]}"
|
|
90
|
+
db_shard = Store.db_shard_for_target(target_names[0], scope: scope)
|
|
87
91
|
# Name the routed target so the interface identifies against it (and sends it as UNKNOWN if that fails)
|
|
88
|
-
Topic.write_topic(topic, { 'target_name' => target_names[0], 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100)
|
|
92
|
+
Topic.write_topic(topic, { 'target_name' => target_names[0], 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100, db_shard: db_shard)
|
|
89
93
|
else
|
|
90
94
|
raise "No route for command: #{packet.target_name ? packet.target_name : 'UNKNOWN'} #{packet.packet_name ? packet.packet_name : 'UNKNOWN'}"
|
|
91
95
|
end
|
|
@@ -17,14 +17,17 @@ module OpenC3
|
|
|
17
17
|
class SystemEventsTopic < Topic
|
|
18
18
|
PRIMARY_KEY = "OPENC3__SYSTEM__EVENTS".freeze
|
|
19
19
|
|
|
20
|
-
# Collect all unique target db_shards
|
|
20
|
+
# Collect all unique target and interface db_shards. Interfaces read system
|
|
21
|
+
# events on their own db_shard which may not have any targets on it.
|
|
21
22
|
def self._active_db_shards
|
|
22
23
|
db_shards = Set.new([0])
|
|
23
|
-
# Iterate all scopes to find all target db_shards
|
|
24
|
-
|
|
25
|
-
Store.
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
# Iterate all scopes to find all target and interface db_shards
|
|
25
|
+
['*__openc3_targets', '*__openc3_interfaces'].each do |match|
|
|
26
|
+
Store.scan_each(match: match, type: 'hash') do |key|
|
|
27
|
+
Store.hgetall(key).each do |_name, json|
|
|
28
|
+
parsed = JSON.parse(json, allow_nan: true, create_additions: true)
|
|
29
|
+
db_shards << (parsed['db_shard'] || 0).to_i
|
|
30
|
+
end
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
db_shards
|
data/lib/openc3/topics/topic.rb
CHANGED
|
@@ -221,6 +221,13 @@ module OpenC3
|
|
|
221
221
|
EphemeralStore.instance(db_shard: db_shard).del(topic)
|
|
222
222
|
end
|
|
223
223
|
|
|
224
|
+
# Extract the target name from the {TARGET} hash tag in a topic like
|
|
225
|
+
# DEFAULT__TELEMETRY__{INST}__HEALTH_STATUS. Returns nil if there is none.
|
|
226
|
+
def self.target_name_from_topic(topic)
|
|
227
|
+
match = topic.match(/__\{([^}]+)\}__/)
|
|
228
|
+
match ? match[1] : nil
|
|
229
|
+
end
|
|
230
|
+
|
|
224
231
|
# Group topics by db_shard. Each topic's target name is extracted and looked up.
|
|
225
232
|
# Topics matching target_pattern are db_sharded; others go to db_shard 0.
|
|
226
233
|
# @param topics [Array<String>] List of topic strings
|
|
@@ -231,7 +238,7 @@ module OpenC3
|
|
|
231
238
|
groups = {}
|
|
232
239
|
topics.each do |topic|
|
|
233
240
|
if topic.include?(target_pattern)
|
|
234
|
-
target_name = topic
|
|
241
|
+
target_name = target_name_from_topic(topic)
|
|
235
242
|
# Handle CMD}TARGET__ pattern where target is after TARGET__
|
|
236
243
|
target_name = topic.split('TARGET__')[1] if target_pattern.include?('TARGET__') && target_name.nil?
|
|
237
244
|
db_shard = (Store.db_shard_for_target(target_name, scope: scope) || 0).to_i
|
|
@@ -77,8 +77,9 @@ module OpenC3
|
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
# Get or create a thread-local PG connection for the given db_shard with type mapping configured.
|
|
80
|
+
# The port defaults to OPENC3_TSDB_QUERY_PORT.
|
|
80
81
|
# Returns the thread-local connection - callers should not close it.
|
|
81
|
-
def self.connection(db_shard: 0)
|
|
82
|
+
def self.connection(db_shard: 0, port: nil)
|
|
82
83
|
conns = @thread_conns.value
|
|
83
84
|
conn = conns[db_shard]
|
|
84
85
|
if conn and not conn.finished?
|
|
@@ -91,7 +92,7 @@ module OpenC3
|
|
|
91
92
|
end
|
|
92
93
|
conn = PG::Connection.new(
|
|
93
94
|
host: hostname_for_db_shard(db_shard),
|
|
94
|
-
port: ENV
|
|
95
|
+
port: port || ENV.fetch('OPENC3_TSDB_QUERY_PORT', nil),
|
|
95
96
|
user: ENV['OPENC3_TSDB_USERNAME'],
|
|
96
97
|
password: ENV['OPENC3_TSDB_PASSWORD'],
|
|
97
98
|
dbname: 'qdb'
|
|
@@ -122,11 +123,12 @@ module OpenC3
|
|
|
122
123
|
end
|
|
123
124
|
|
|
124
125
|
# Health check - attempt to connect and immediately close.
|
|
126
|
+
# The port defaults to OPENC3_TSDB_QUERY_PORT.
|
|
125
127
|
# Returns true if successful, raises on failure.
|
|
126
|
-
def self.check_connection(db_shard: 0)
|
|
128
|
+
def self.check_connection(db_shard: 0, port: nil)
|
|
127
129
|
conn = PG::Connection.new(
|
|
128
130
|
host: hostname_for_db_shard(db_shard),
|
|
129
|
-
port: ENV
|
|
131
|
+
port: port || ENV.fetch('OPENC3_TSDB_QUERY_PORT', nil),
|
|
130
132
|
user: ENV['OPENC3_TSDB_USERNAME'],
|
|
131
133
|
password: ENV['OPENC3_TSDB_PASSWORD'],
|
|
132
134
|
dbname: 'qdb'
|
|
@@ -628,10 +630,30 @@ module OpenC3
|
|
|
628
630
|
query += " LIMIT 1"
|
|
629
631
|
result = query_with_retry(query, max_retries: 1, label: "table_has_data", db_shard: db_shard)
|
|
630
632
|
result && result.ntuples > 0
|
|
631
|
-
rescue RuntimeError
|
|
633
|
+
rescue QuestDBError, RuntimeError
|
|
634
|
+
# A table which doesn't exist fails the query
|
|
632
635
|
false
|
|
633
636
|
end
|
|
634
637
|
|
|
638
|
+
# Return the telemetry packets which have a table. The TSDB microservice only
|
|
639
|
+
# creates tables for its initial packets so a packet added later has no table
|
|
640
|
+
# until it's received. If the table names can't be fetched assume they all exist.
|
|
641
|
+
#
|
|
642
|
+
# @param packets [Array<Array(String, String)>] [target_name, packet_name] pairs
|
|
643
|
+
# @return [Set<Array(String, String)>] The pairs which have a table
|
|
644
|
+
def self.tlm_packets_with_tables(packets, scope: "DEFAULT")
|
|
645
|
+
existing = Set.new
|
|
646
|
+
packets.group_by { |target_name, _| db_shard_for_target(target_name, scope: scope) }.each do |db_shard, shard_packets|
|
|
647
|
+
names = table_names(db_shard: db_shard)
|
|
648
|
+
shard_packets.each do |target_name, packet_name|
|
|
649
|
+
if names.nil? or names.include?(sanitize_table_name(target_name, packet_name, scope: scope).upcase)
|
|
650
|
+
existing << [target_name, packet_name]
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
existing
|
|
655
|
+
end
|
|
656
|
+
|
|
635
657
|
# Return the names of all tables in a single query. Used to avoid querying
|
|
636
658
|
# (and failing on) tables that were never created, e.g. commands that have
|
|
637
659
|
# never been sent, which is the common case for targets with thousands of packets.
|
|
@@ -932,19 +954,23 @@ module OpenC3
|
|
|
932
954
|
# Query historical telemetry data from QuestDB for a list of items.
|
|
933
955
|
# Builds the SQL query, executes it, and decodes all results.
|
|
934
956
|
# Supports cross-db_shard queries by grouping items by db_shard, executing
|
|
935
|
-
# separate queries per db_shard, and
|
|
957
|
+
# separate queries per db_shard, and aligning the results by packet time.
|
|
936
958
|
#
|
|
937
959
|
# @param items [Array] Array of [target_name, packet_name, item_name, value_type, limits]
|
|
938
960
|
# item_name may be nil to indicate a placeholder (non-existent item)
|
|
939
961
|
# @param start_time [String, Numeric] Start timestamp for the query
|
|
940
962
|
# @param end_time [String, Numeric, nil] End timestamp, or nil for "latest single row"
|
|
941
963
|
# @param scope [String] Scope name
|
|
964
|
+
# @param flatten [Boolean] Whether a single row result is returned as a flat array
|
|
942
965
|
# @return [Array, Hash] Array of [value, limits_state] pairs per row, or {} if no results.
|
|
943
|
-
# Single-row results return a flat array; multi-row results return array of arrays.
|
|
944
|
-
def self.tsdb_lookup(items, start_time:, end_time: nil, scope: "DEFAULT")
|
|
966
|
+
# Single-row results return a flat array (unless flatten is false); multi-row results return array of arrays.
|
|
967
|
+
def self.tsdb_lookup(items, start_time:, end_time: nil, scope: "DEFAULT", flatten: true)
|
|
945
968
|
# Every item is a placeholder for an item which doesn't exist, so there's
|
|
946
969
|
# nothing to query. Return a single row of nil values, one per item.
|
|
947
|
-
|
|
970
|
+
if items.all? { |item| item[2].nil? }
|
|
971
|
+
row = Array.new(items.length) { [nil, nil] }
|
|
972
|
+
return flatten ? row : [row]
|
|
973
|
+
end
|
|
948
974
|
|
|
949
975
|
# Group items by db_shard number while preserving their original positions
|
|
950
976
|
db_shard_groups = {} # db_shard => { positions: [], items: [] }
|
|
@@ -959,39 +985,95 @@ module OpenC3
|
|
|
959
985
|
# Single-db_shard fast path (most common case)
|
|
960
986
|
if db_shard_groups.length == 1
|
|
961
987
|
db_shard, group = db_shard_groups.first
|
|
962
|
-
return tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
|
|
988
|
+
return tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard, flatten: flatten)
|
|
963
989
|
end
|
|
964
990
|
|
|
965
|
-
# Cross-db_shard:
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
result = tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard, flatten: false)
|
|
969
|
-
db_shard_rows[db_shard] = result.is_a?(Array) ? result : []
|
|
970
|
-
end
|
|
991
|
+
# Cross-db_shard: a single query can't ASOF JOIN tables on different QuestDBs
|
|
992
|
+
tsdb_lookup_db_shards(items, db_shard_groups, start_time: start_time, end_time: end_time, scope: scope, flatten: flatten)
|
|
993
|
+
end
|
|
971
994
|
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
995
|
+
# Cross-db_shard lookup which matches the rows of a single db_shard ASOF JOIN.
|
|
996
|
+
# The packet of the first item drives the rows (like T0 in a single db_shard query) so
|
|
997
|
+
# its db_shard is queried as usual. Every packet on another db_shard is queried on its
|
|
998
|
+
# own and aligned to each driving row's packet time, using its last row at or before
|
|
999
|
+
# that time (like ASOF JOIN). Each packet is aligned independently and includes its
|
|
1000
|
+
# last row before start_time, so its value carries forward like it would in ASOF JOIN.
|
|
1001
|
+
def self.tsdb_lookup_db_shards(items, db_shard_groups, start_time:, end_time:, scope:, flatten: true)
|
|
1002
|
+
driver = db_shard_for_target(items.find { |item| item[2] }[0], scope: scope)
|
|
1003
|
+
driver_rows = tsdb_lookup_single_db_shard(db_shard_groups[driver][:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: driver, flatten: false, with_time: true)
|
|
1004
|
+
return {} if !driver_rows.is_a?(Array) or driver_rows.empty?
|
|
1005
|
+
|
|
1006
|
+
# The packet time in nanoseconds is the last value of each row
|
|
1007
|
+
times = driver_rows.map { |row| row[-1][0] }
|
|
1008
|
+
|
|
1009
|
+
merged = driver_rows.map do |driver_row|
|
|
1010
|
+
row = Array.new(items.length) { [nil, nil] }
|
|
1011
|
+
db_shard_groups[driver][:positions].each_with_index do |orig_pos, db_shard_idx|
|
|
1012
|
+
row[orig_pos] = driver_row[db_shard_idx] if driver_row[db_shard_idx]
|
|
1013
|
+
end
|
|
1014
|
+
row
|
|
1015
|
+
end
|
|
977
1016
|
|
|
978
|
-
merged = Array.new(max_rows) { Array.new(items.length) { [nil, nil] } }
|
|
979
1017
|
db_shard_groups.each do |db_shard, group|
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1018
|
+
next if db_shard == driver
|
|
1019
|
+
|
|
1020
|
+
# Group the items by packet, leaving placeholders as nils
|
|
1021
|
+
packets = {} # [target_name, packet_name] => { positions: [], items: [] }
|
|
1022
|
+
group[:positions].zip(group[:items]).each do |orig_pos, item|
|
|
1023
|
+
next unless item[2]
|
|
1024
|
+
|
|
1025
|
+
packet = (packets[[item[0], item[1]]] ||= { positions: [], items: [] })
|
|
1026
|
+
packet[:positions] << orig_pos
|
|
1027
|
+
packet[:items] << item
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
packets.each_value do |packet|
|
|
1031
|
+
rows = tsdb_lookup_packet_rows(packet[:items], times, start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
|
|
1032
|
+
index = -1 # Index of the last packet row at or before the driving row
|
|
1033
|
+
times.each_with_index do |driver_time, row_num|
|
|
1034
|
+
index += 1 while index + 1 < rows.length and rows[index + 1][-1][0] <= driver_time
|
|
1035
|
+
next if index < 0
|
|
1036
|
+
|
|
1037
|
+
packet[:positions].each_with_index do |orig_pos, packet_idx|
|
|
1038
|
+
merged[row_num][orig_pos] = rows[index][packet_idx] if rows[index][packet_idx]
|
|
1039
|
+
end
|
|
983
1040
|
end
|
|
984
1041
|
end
|
|
985
1042
|
end
|
|
1043
|
+
|
|
986
1044
|
# Match the single db_shard behavior of returning a flat array for a single row
|
|
987
|
-
return merged[0] if
|
|
1045
|
+
return merged[0] if flatten and merged.length == 1
|
|
1046
|
+
|
|
988
1047
|
merged
|
|
989
1048
|
end
|
|
990
1049
|
|
|
1050
|
+
# Query the rows of a single packet needed to align it to the driving row times.
|
|
1051
|
+
# Each row ends with its packet time in nanoseconds.
|
|
1052
|
+
def self.tsdb_lookup_packet_rows(items, times, start_time:, end_time:, scope:, db_shard:)
|
|
1053
|
+
if end_time
|
|
1054
|
+
# The last row before start_time followed by the rows in the range
|
|
1055
|
+
queries = [{ start_time: start_time }, { start_time: start_time, end_time: end_time }]
|
|
1056
|
+
else
|
|
1057
|
+
# The single driving row is before start_time so get the last row at or before it
|
|
1058
|
+
queries = [{ start_time: nsec_to_iso(times[0]), inclusive: true }]
|
|
1059
|
+
end
|
|
1060
|
+
queries.flat_map do |query|
|
|
1061
|
+
result = tsdb_lookup_single_db_shard(items, scope: scope, db_shard: db_shard, flatten: false, with_time: true, **query)
|
|
1062
|
+
result.is_a?(Array) ? result : []
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
# Convert a nanosecond integer timestamp to an ISO 8601 string with nanosecond precision
|
|
1067
|
+
def self.nsec_to_iso(nsec)
|
|
1068
|
+
Time.at(nsec / 1_000_000_000, nsec % 1_000_000_000, :nsec).utc.strftime('%Y-%m-%dT%H:%M:%S.%9NZ')
|
|
1069
|
+
end
|
|
1070
|
+
|
|
991
1071
|
# Execute a tsdb_lookup query against a single db_shard.
|
|
992
1072
|
# This contains the original ASOF JOIN logic for items all on the same QuestDB instance.
|
|
993
1073
|
# When flatten is true a single row result is returned as a flat array rather than an array of rows.
|
|
994
|
-
|
|
1074
|
+
# When inclusive is true a query without an end_time returns the last row at or before start_time.
|
|
1075
|
+
# When with_time is true each row ends with its packet time in nanoseconds.
|
|
1076
|
+
def self.tsdb_lookup_single_db_shard(items, start_time:, end_time: nil, scope: "DEFAULT", db_shard: 0, flatten: true, inclusive: false, with_time: false)
|
|
995
1077
|
tables = {}
|
|
996
1078
|
names = []
|
|
997
1079
|
nil_count = 0
|
|
@@ -1000,6 +1082,14 @@ module OpenC3
|
|
|
1000
1082
|
calculated_items = {}
|
|
1001
1083
|
needed_timestamps = {}
|
|
1002
1084
|
current_position = 0
|
|
1085
|
+
# Columns with the same name in different tables come back renamed by QuestDB
|
|
1086
|
+
# (e.g. VALUE and VALUE_2) so alias every column and map it back when decoding
|
|
1087
|
+
aliases = {}
|
|
1088
|
+
add_column = lambda do |col_name|
|
|
1089
|
+
alias_name = "__col#{aliases.length}"
|
|
1090
|
+
aliases[alias_name] = col_name
|
|
1091
|
+
names << "\"#{col_name}\" as #{alias_name}"
|
|
1092
|
+
end
|
|
1003
1093
|
|
|
1004
1094
|
items.each do |item|
|
|
1005
1095
|
target_name, packet_name, orig_item_name, value_type, limits = item
|
|
@@ -1014,7 +1104,7 @@ module OpenC3
|
|
|
1014
1104
|
index = tables.find_index {|k,_v| k == table_name }
|
|
1015
1105
|
|
|
1016
1106
|
if STORED_TIMESTAMP_ITEMS.include?(orig_item_name)
|
|
1017
|
-
|
|
1107
|
+
add_column.call("T#{index}.#{orig_item_name}")
|
|
1018
1108
|
current_position += 1
|
|
1019
1109
|
next
|
|
1020
1110
|
end
|
|
@@ -1044,11 +1134,11 @@ module OpenC3
|
|
|
1044
1134
|
|
|
1045
1135
|
suffix = column_suffix_for_value_type(value_type)
|
|
1046
1136
|
col_name = "T#{index}.#{safe_item_name}#{suffix}"
|
|
1047
|
-
|
|
1137
|
+
add_column.call(col_name)
|
|
1048
1138
|
item_types[col_name] = resolve_item_type(item_def, value_type)
|
|
1049
1139
|
current_position += 1
|
|
1050
1140
|
if limits
|
|
1051
|
-
|
|
1141
|
+
add_column.call("T#{index}.#{safe_item_name}__L")
|
|
1052
1142
|
end
|
|
1053
1143
|
end
|
|
1054
1144
|
|
|
@@ -1063,6 +1153,7 @@ module OpenC3
|
|
|
1063
1153
|
names << "T#{table_index}.#{ts_col} as T#{table_index}___ts_#{ts_col}"
|
|
1064
1154
|
end
|
|
1065
1155
|
end
|
|
1156
|
+
names << "cast(T0.PACKET_TIMESECONDS as long) as __time" if with_time
|
|
1066
1157
|
|
|
1067
1158
|
# Build the SQL query
|
|
1068
1159
|
query = "SELECT #{names.join(", ")} FROM "
|
|
@@ -1075,7 +1166,7 @@ module OpenC3
|
|
|
1075
1166
|
end
|
|
1076
1167
|
query_params = []
|
|
1077
1168
|
if start_time && !end_time
|
|
1078
|
-
query += "WHERE T0.PACKET_TIMESECONDS < $1 LIMIT -1"
|
|
1169
|
+
query += "WHERE T0.PACKET_TIMESECONDS #{inclusive ? '<=' : '<'} $1 LIMIT -1"
|
|
1079
1170
|
query_params << start_time
|
|
1080
1171
|
elsif start_time && end_time
|
|
1081
1172
|
query += "WHERE T0.PACKET_TIMESECONDS >= $1 AND T0.PACKET_TIMESECONDS < $2"
|
|
@@ -1094,13 +1185,16 @@ module OpenC3
|
|
|
1094
1185
|
row_index = 0
|
|
1095
1186
|
row_timestamps = {}
|
|
1096
1187
|
tuples.each do |tuple|
|
|
1097
|
-
col_name = tuple[0]
|
|
1188
|
+
col_name = aliases.fetch(tuple[0], tuple[0])
|
|
1098
1189
|
col_value = tuple[1]
|
|
1099
1190
|
if col_name.include?("__L")
|
|
1100
1191
|
data[row_num][row_index - 1][1] = col_value
|
|
1101
1192
|
elsif col_name =~ /^__nil/
|
|
1102
1193
|
data[row_num][row_index] = [nil, nil]
|
|
1103
1194
|
row_index += 1
|
|
1195
|
+
elsif col_name == '__time'
|
|
1196
|
+
data[row_num][row_index] = [Integer(col_value), nil]
|
|
1197
|
+
row_index += 1
|
|
1104
1198
|
elsif col_name =~ /^T(\d+)___ts_(.+)$/
|
|
1105
1199
|
table_idx = $1.to_i
|
|
1106
1200
|
ts_source = $2
|
|
@@ -71,6 +71,11 @@ module OpenC3
|
|
|
71
71
|
klass.new
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# snake_case alias to match the Python Secrets.get_client
|
|
75
|
+
def self.get_client # rubocop:disable Naming/AccessorMethodName
|
|
76
|
+
getClient()
|
|
77
|
+
end
|
|
78
|
+
|
|
74
79
|
def keys(secret_store: nil, scope:)
|
|
75
80
|
raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
|
|
76
81
|
end
|
|
@@ -67,6 +67,7 @@ module OpenC3
|
|
|
67
67
|
|
|
68
68
|
attr_reader :redis_url
|
|
69
69
|
attr_reader :redis_pool
|
|
70
|
+
attr_reader :db_shard
|
|
70
71
|
|
|
71
72
|
# Look up the db_shard number for a target with a 1-minute cache.
|
|
72
73
|
# Reads directly from Redis db_shard 0 to avoid circular deps with TargetModel.
|
|
@@ -124,6 +125,7 @@ module OpenC3
|
|
|
124
125
|
end
|
|
125
126
|
|
|
126
127
|
def initialize(pool_size = 10, db_shard: 0)
|
|
128
|
+
@db_shard = db_shard
|
|
127
129
|
@redis_username = ENV.fetch('OPENC3_REDIS_USERNAME', nil)
|
|
128
130
|
@redis_key = ENV.fetch('OPENC3_REDIS_PASSWORD', nil)
|
|
129
131
|
hostname = ENV['OPENC3_REDIS_HOSTNAME'].to_s.gsub("SHARDNUM", db_shard.to_s)
|
|
@@ -314,7 +316,7 @@ module OpenC3
|
|
|
314
316
|
|
|
315
317
|
class EphemeralStore < Store
|
|
316
318
|
def initialize(pool_size = 10, db_shard: 0)
|
|
317
|
-
super(pool_size)
|
|
319
|
+
super(pool_size, db_shard: db_shard)
|
|
318
320
|
hostname = ENV['OPENC3_REDIS_EPHEMERAL_HOSTNAME'].to_s.gsub("SHARDNUM", db_shard.to_s)
|
|
319
321
|
@redis_url = "redis://#{hostname}:#{ENV.fetch('OPENC3_REDIS_EPHEMERAL_PORT', 6380)}"
|
|
320
322
|
@redis_pool = StoreConnectionPool.new(size: pool_size) { build_redis() }
|