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.
@@ -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
- def extract_operator_and_operand_from_comparison(comparison)
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
- def extract_operand(operand)
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
- raise NameError, "Uninitialized constant #{operand}. Did you mean '#{operand}' as a string?"
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)
@@ -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$|^subscribe$/
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", {event: JSON.generate(event, allow_nan: true)}, '*', 1000, db_shard: db_shard)
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
- Topic.write_topic(topic, { 'target_name' => packet.target_name, 'cmd_name' => packet.packet_name, 'cmd_buffer' => packet.buffer(false) }, '*', 100)
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 from TargetModel
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
- Store.scan_each(match: '*__openc3_targets', type: 'hash') do |key|
25
- Store.hgetall(key).each do |_name, json|
26
- parsed = JSON.parse(json, allow_nan: true, create_additions: true)
27
- db_shards << (parsed['db_shard'] || 0).to_i
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
@@ -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.match(/__\{?([^}_]+)\}?__/)[1] rescue nil
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
@@ -28,6 +28,11 @@ module OpenC3
28
28
  klass.new
29
29
  end
30
30
 
31
+ # snake_case alias to match the Python Bucket.get_client
32
+ def self.get_client # rubocop:disable Naming/AccessorMethodName
33
+ getClient()
34
+ end
35
+
31
36
  def initialize
32
37
  # Setup the client instance
33
38
  end
@@ -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['OPENC3_TSDB_QUERY_PORT'],
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['OPENC3_TSDB_QUERY_PORT'],
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 merging results positionally.
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
- return Array.new(items.length) { [nil, nil] } if items.all? { |item| item[2].nil? }
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: execute per-db_shard queries (as arrays of rows) and merge results
966
- db_shard_rows = {} # db_shard => rows
967
- db_shard_groups.each do |db_shard, group|
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
- # Merge results positionally back into the original item order. Each db_shard
973
- # may have different row counts so use the maximum row count and fill missing
974
- # positions with [nil, nil]. If all db_shards returned empty, return empty.
975
- max_rows = db_shard_rows.values.map(&:length).max
976
- return {} if max_rows == 0
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
- db_shard_rows[db_shard].each_with_index do |row, row_num|
981
- group[:positions].each_with_index do |orig_pos, db_shard_idx|
982
- merged[row_num][orig_pos] = row[db_shard_idx] if row[db_shard_idx]
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 max_rows == 1
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
- def self.tsdb_lookup_single_db_shard(items, start_time:, end_time: nil, scope: "DEFAULT", db_shard: 0, flatten: true)
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
- names << "\"T#{index}.#{orig_item_name}\""
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
- names << "\"#{col_name}\""
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
- names << "\"T#{index}.#{safe_item_name}__L\""
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() }