openc3 7.0.0 → 7.1.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +105 -13
  3. data/bin/pipinstall +38 -6
  4. data/data/config/command_modifiers.yaml +1 -0
  5. data/data/config/item_modifiers.yaml +2 -1
  6. data/data/config/microservice.yaml +12 -1
  7. data/data/config/parameter_modifiers.yaml +49 -7
  8. data/data/config/table_parameter_modifiers.yaml +3 -1
  9. data/data/config/target.yaml +11 -0
  10. data/data/config/target_config.yaml +6 -2
  11. data/lib/openc3/accessors/template_accessor.rb +9 -0
  12. data/lib/openc3/api/cmd_api.rb +2 -1
  13. data/lib/openc3/api/metrics_api.rb +11 -1
  14. data/lib/openc3/api/tlm_api.rb +21 -6
  15. data/lib/openc3/core_ext/faraday.rb +1 -1
  16. data/lib/openc3/interfaces/interface.rb +1 -6
  17. data/lib/openc3/io/json_api.rb +1 -1
  18. data/lib/openc3/logs/log_writer.rb +3 -1
  19. data/lib/openc3/microservices/decom_common.rb +128 -0
  20. data/lib/openc3/microservices/decom_microservice.rb +27 -96
  21. data/lib/openc3/microservices/interface_decom_common.rb +28 -10
  22. data/lib/openc3/microservices/interface_microservice.rb +16 -9
  23. data/lib/openc3/microservices/log_microservice.rb +1 -1
  24. data/lib/openc3/microservices/microservice.rb +3 -2
  25. data/lib/openc3/microservices/queue_microservice.rb +1 -1
  26. data/lib/openc3/microservices/scope_cleanup_microservice.rb +60 -46
  27. data/lib/openc3/microservices/text_log_microservice.rb +1 -2
  28. data/lib/openc3/models/cvt_model.rb +24 -13
  29. data/lib/openc3/models/db_sharded_model.rb +110 -0
  30. data/lib/openc3/models/interface_model.rb +9 -0
  31. data/lib/openc3/models/interface_status_model.rb +33 -3
  32. data/lib/openc3/models/metric_model.rb +96 -37
  33. data/lib/openc3/models/microservice_model.rb +7 -0
  34. data/lib/openc3/models/microservice_status_model.rb +30 -3
  35. data/lib/openc3/models/plugin_model.rb +9 -1
  36. data/lib/openc3/models/python_package_model.rb +1 -1
  37. data/lib/openc3/models/reaction_model.rb +27 -9
  38. data/lib/openc3/models/reingest_job_model.rb +153 -0
  39. data/lib/openc3/models/scope_model.rb +3 -2
  40. data/lib/openc3/models/script_status_model.rb +4 -20
  41. data/lib/openc3/models/target_model.rb +113 -100
  42. data/lib/openc3/models/trigger_model.rb +24 -7
  43. data/lib/openc3/packets/packet_config.rb +4 -1
  44. data/lib/openc3/script/api_shared.rb +39 -2
  45. data/lib/openc3/script/calendar.rb +32 -10
  46. data/lib/openc3/script/extract.rb +46 -13
  47. data/lib/openc3/script/script.rb +2 -2
  48. data/lib/openc3/script/script_runner.rb +4 -4
  49. data/lib/openc3/script/telemetry.rb +3 -3
  50. data/lib/openc3/script/web_socket_api.rb +29 -22
  51. data/lib/openc3/system/system.rb +20 -3
  52. data/lib/openc3/topics/command_decom_topic.rb +4 -2
  53. data/lib/openc3/topics/command_topic.rb +8 -5
  54. data/lib/openc3/topics/decom_interface_topic.rb +31 -11
  55. data/lib/openc3/topics/interface_topic.rb +88 -27
  56. data/lib/openc3/topics/limits_event_topic.rb +62 -41
  57. data/lib/openc3/topics/router_topic.rb +61 -21
  58. data/lib/openc3/topics/system_events_topic.rb +18 -1
  59. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -1
  60. data/lib/openc3/topics/telemetry_topic.rb +4 -2
  61. data/lib/openc3/topics/topic.rb +77 -5
  62. data/lib/openc3/utilities/aws_bucket.rb +2 -0
  63. data/lib/openc3/utilities/cli_generator.rb +3 -2
  64. data/lib/openc3/utilities/ctrf.rb +231 -0
  65. data/lib/openc3/utilities/metric.rb +15 -1
  66. data/lib/openc3/utilities/questdb_client.rb +177 -40
  67. data/lib/openc3/utilities/reingest_job.rb +377 -0
  68. data/lib/openc3/utilities/ruby_lex_utils.rb +2 -0
  69. data/lib/openc3/utilities/store_autoload.rb +78 -52
  70. data/lib/openc3/utilities/store_queued.rb +20 -12
  71. data/lib/openc3/version.rb +5 -5
  72. data/templates/plugin/plugin.gemspec +13 -1
  73. data/templates/tool_angular/package.json +2 -2
  74. data/templates/tool_react/package.json +1 -1
  75. data/templates/tool_svelte/package.json +1 -1
  76. data/templates/tool_vue/package.json +3 -4
  77. data/templates/tool_vue/src/router.js +2 -2
  78. data/templates/widget/package.json +2 -2
  79. metadata +8 -3
@@ -28,42 +28,104 @@ module OpenC3
28
28
  class QuestDBError < StandardError; end
29
29
 
30
30
  # Thread-local PG connection storage using Concurrent::ThreadLocalVar.
31
- # Each thread gets its own connection to avoid thread-safety issues with PG::Connection.
31
+ # Each thread gets its own connections (per db_shard) to avoid thread-safety issues with PG::Connection.
32
32
  # Connections are automatically garbage collected when threads terminate.
33
- @thread_conn = Concurrent::ThreadLocalVar.new(nil)
33
+ # Value is a Hash: { db_shard_number => PG::Connection }
34
+ @thread_conns = Concurrent::ThreadLocalVar.new { Hash.new } # NOSONAR
35
+
36
+ # DB_Shard cache: { "scope__target_name" => [db_shard_number, Time] }
37
+ @db_shard_cache = {}
38
+ @db_shard_cache_mutex = Mutex.new
39
+ DB_SHARD_CACHE_TIMEOUT = 60 # seconds
40
+
41
+ # Resolve the hostname for a given db_shard number.
42
+ # If OPENC3_TSDB_HOSTNAME contains "SHARDNUM", it is replaced with the db_shard number.
43
+ # Otherwise, all db_shards connect to the same host (backward compatible).
44
+ def self.hostname_for_db_shard(db_shard)
45
+ ENV['OPENC3_TSDB_HOSTNAME'].to_s.gsub("SHARDNUM", db_shard.to_s)
46
+ end
47
+
48
+ # Look up the db_shard number for a target from TargetModel with a 1-minute cache.
49
+ # Non-target-specific data (nil target_name) always returns db_shard 0.
50
+ def self.db_shard_for_target(target_name, scope: "DEFAULT")
51
+ return 0 unless target_name
52
+
53
+ cache_key = "#{scope}__#{target_name}"
54
+ now = Time.now
55
+
56
+ @db_shard_cache_mutex.synchronize do
57
+ cached = @db_shard_cache[cache_key]
58
+ if cached
59
+ db_shard, cached_at = cached
60
+ return db_shard if (now - cached_at) < DB_SHARD_CACHE_TIMEOUT
61
+ end
62
+ end
63
+
64
+ # Cache miss or expired — look up from TargetModel
65
+ begin
66
+ model = TargetModel.get(name: target_name, scope: scope)
67
+ db_shard = model ? model['db_shard'].to_i : 0
68
+ rescue
69
+ db_shard = 0
70
+ end
71
+
72
+ @db_shard_cache_mutex.synchronize do
73
+ @db_shard_cache[cache_key] = [db_shard, now]
74
+ end
75
+
76
+ db_shard
77
+ end
34
78
 
35
- # Get or create a thread-local PG connection with type mapping configured.
79
+ # Get or create a thread-local PG connection for the given db_shard with type mapping configured.
36
80
  # Returns the thread-local connection - callers should not close it.
37
- def self.connection
38
- conn = @thread_conn.value
39
- if conn.nil? || conn.finished?
40
- conn = PG::Connection.new(
41
- host: ENV['OPENC3_TSDB_HOSTNAME'],
42
- port: ENV['OPENC3_TSDB_QUERY_PORT'],
43
- user: ENV['OPENC3_TSDB_USERNAME'],
44
- password: ENV['OPENC3_TSDB_PASSWORD'],
45
- dbname: 'qdb'
46
- )
47
- conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)
48
- @thread_conn.value = conn
81
+ def self.connection(db_shard: 0)
82
+ conns = @thread_conns.value
83
+ conn = conns[db_shard]
84
+ if conn and not conn.finished?
85
+ begin
86
+ conn.check_socket
87
+ return conn
88
+ rescue
89
+ # Will need to reconnect
90
+ end
49
91
  end
92
+ conn = PG::Connection.new(
93
+ host: hostname_for_db_shard(db_shard),
94
+ port: ENV['OPENC3_TSDB_QUERY_PORT'],
95
+ user: ENV['OPENC3_TSDB_USERNAME'],
96
+ password: ENV['OPENC3_TSDB_PASSWORD'],
97
+ dbname: 'qdb'
98
+ )
99
+ conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)
100
+ conns[db_shard] = conn
101
+ @thread_conns.value = conns
50
102
  conn
51
103
  end
52
104
 
53
- # Reset the connection for the current thread. Used after errors.
54
- def self.disconnect
55
- conn = @thread_conn.value
56
- if conn && !conn.finished?
57
- conn.finish
105
+ # Reset the connection(s) for the current thread. Used after errors.
106
+ # If db_shard is nil, closes all db_shard connections. Otherwise closes only the specified db_shard.
107
+ def self.disconnect(db_shard: nil)
108
+ conns = @thread_conns.value
109
+ if db_shard.nil?
110
+ conns.each_value do |conn|
111
+ conn.finish if conn && !conn.finished?
112
+ end
113
+ @thread_conns.value = {}
114
+ else
115
+ conn = conns[db_shard]
116
+ if conn && !conn.finished?
117
+ conn.finish
118
+ end
119
+ conns.delete(db_shard)
120
+ @thread_conns.value = conns
58
121
  end
59
- @thread_conn.value = nil
60
122
  end
61
123
 
62
124
  # Health check - attempt to connect and immediately close.
63
125
  # Returns true if successful, raises on failure.
64
- def self.check_connection
126
+ def self.check_connection(db_shard: 0)
65
127
  conn = PG::Connection.new(
66
- host: ENV['OPENC3_TSDB_HOSTNAME'],
128
+ host: hostname_for_db_shard(db_shard),
67
129
  port: ENV['OPENC3_TSDB_QUERY_PORT'],
68
130
  user: ENV['OPENC3_TSDB_USERNAME'],
69
131
  password: ENV['OPENC3_TSDB_PASSWORD'],
@@ -127,14 +189,15 @@ module OpenC3
127
189
  # - Arrays are JSON-encoded: "[1, 2, 3]" or '["a", "b"]'
128
190
  # - Objects/Hashes are JSON-encoded: '{"key": "value"}'
129
191
  # - Binary data (BLOCK) is base64-encoded
130
- # - Large integers (64-bit) are stored as DECIMAL
192
+ # - Large integers (64-bit) are stored as VARCHAR strings
131
193
  #
132
194
  # @param value [Object] The value to decode
133
195
  # @param data_type [String] COSMOS data type (INT, UINT, FLOAT, STRING, BLOCK, DERIVED, etc.)
134
196
  # @param array_size [Integer, nil] If not nil, indicates this is an array item
135
197
  # @return [Object] The decoded value
136
198
  def self.decode_value(value, data_type: nil, array_size: nil)
137
- # Handle BigDecimal values from QuestDB DECIMAL columns (used for 64-bit integers)
199
+ # Handle BigDecimal values from legacy QuestDB DECIMAL columns
200
+ # (pre-existing tables may still use DECIMAL; new tables use VARCHAR)
138
201
  if value.is_a?(BigDecimal)
139
202
  return value.to_i if data_type == 'INT' || data_type == 'UINT'
140
203
  return value
@@ -167,7 +230,7 @@ module OpenC3
167
230
  end
168
231
  end
169
232
 
170
- # Integer values stored as strings (fallback path, normally DECIMAL)
233
+ # Integer values stored as VARCHAR strings (≥64-bit integers)
171
234
  if data_type == 'INT' || data_type == 'UINT'
172
235
  begin
173
236
  return Integer(value)
@@ -290,14 +353,14 @@ module OpenC3
290
353
  # @param label [String, nil] Optional label for log messages
291
354
  # @return [PG::Result, nil] Query result
292
355
  # @raise [RuntimeError] After exhausting retries
293
- def self.query_with_retry(query, params: [], max_retries: 5, label: nil)
356
+ def self.query_with_retry(query, params: [], max_retries: 5, label: nil, db_shard: 0)
294
357
  retry_count = 0
295
358
  begin
296
- conn = connection
359
+ conn = connection(db_shard: db_shard)
297
360
  if params.empty?
298
- conn.exec(query)
361
+ return conn.exec(query)
299
362
  else
300
- conn.exec_params(query, params)
363
+ return conn.exec_params(query, params)
301
364
  end
302
365
  rescue IOError, PG::Error => e
303
366
  retry_count += 1
@@ -306,7 +369,7 @@ module OpenC3
306
369
  end
307
370
  Logger.warn("TSDB#{label ? " #{label}" : ""}: Retrying due to error: #{e.message}")
308
371
  Logger.warn("TSDB#{label ? " #{label}" : ""}: Last query: #{query}")
309
- disconnect
372
+ disconnect(db_shard: db_shard)
310
373
  sleep 0.1
311
374
  retry
312
375
  end
@@ -542,11 +605,11 @@ module OpenC3
542
605
  # @param start_time [Integer] Nanosecond start time
543
606
  # @param end_time [Integer, nil] Nanosecond end time
544
607
  # @return [Boolean]
545
- def self.table_has_data?(table_name, start_time, end_time)
546
- query = "SELECT 1 FROM #{table_name}"
608
+ def self.table_has_data?(table_name, start_time, end_time, db_shard: 0)
609
+ query = "SELECT 1 FROM \"#{table_name}\""
547
610
  query += time_where_clause(start_time, end_time)
548
611
  query += " LIMIT 1"
549
- result = query_with_retry(query, max_retries: 1, label: "table_has_data")
612
+ result = query_with_retry(query, max_retries: 1, label: "table_has_data", db_shard: db_shard)
550
613
  result && result.ntuples > 0
551
614
  rescue RuntimeError
552
615
  false
@@ -559,13 +622,13 @@ module OpenC3
559
622
  # @param page_size [Integer] Number of rows per page
560
623
  # @param label [String] Label for log messages
561
624
  # @yield [PG::Result] Each page of results
562
- def self.paginate_query(query, page_size, label:)
625
+ def self.paginate_query(query, page_size, label:, db_shard: 0)
563
626
  min = 0
564
627
  max = page_size
565
628
  loop do
566
629
  query_offset = "#{query} LIMIT #{min}, #{max}"
567
630
  Logger.debug("QuestDB #{label}: #{query_offset}")
568
- result = query_with_retry(query_offset, label: label)
631
+ result = query_with_retry(query_offset, label: label, db_shard: db_shard)
569
632
  min += page_size
570
633
  max += page_size
571
634
  if result.nil? or result.ntuples == 0
@@ -589,7 +652,7 @@ module OpenC3
589
652
  names << TIMESTAMP_SELECT
590
653
  names << "RECEIVED_TIMESECONDS" if include_received_ts
591
654
  names << "COSMOS_EXTRA"
592
- query = "SELECT #{names.join(', ')} FROM #{table_name}"
655
+ query = "SELECT #{names.join(', ')} FROM \"#{table_name}\""
593
656
  query += time_where_clause(start_time, end_time)
594
657
  query
595
658
  end
@@ -807,6 +870,8 @@ module OpenC3
807
870
 
808
871
  # Query historical telemetry data from QuestDB for a list of items.
809
872
  # Builds the SQL query, executes it, and decodes all results.
873
+ # Supports cross-db_shard queries by grouping items by db_shard, executing
874
+ # separate queries per db_shard, and merging results positionally.
810
875
  #
811
876
  # @param items [Array] Array of [target_name, packet_name, item_name, value_type, limits]
812
877
  # item_name may be nil to indicate a placeholder (non-existent item)
@@ -816,6 +881,78 @@ module OpenC3
816
881
  # @return [Array, Hash] Array of [value, limits_state] pairs per row, or {} if no results.
817
882
  # Single-row results return a flat array; multi-row results return array of arrays.
818
883
  def self.tsdb_lookup(items, start_time:, end_time: nil, scope: "DEFAULT")
884
+ # Group items by db_shard number while preserving their original positions
885
+ db_shard_groups = {} # db_shard => { positions: [], items: [] }
886
+ items.each_with_index do |item, pos|
887
+ target_name = item[0]
888
+ db_shard = db_shard_for_target(target_name, scope: scope)
889
+ db_shard_groups[db_shard] ||= { positions: [], items: [] }
890
+ db_shard_groups[db_shard][:positions] << pos
891
+ db_shard_groups[db_shard][:items] << item
892
+ end
893
+
894
+ # Single-db_shard fast path (most common case)
895
+ if db_shard_groups.length == 1
896
+ db_shard, group = db_shard_groups.first
897
+ return tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
898
+ end
899
+
900
+ # Cross-db_shard: execute per-db_shard queries and merge results
901
+ db_shard_results = {} # db_shard => data
902
+ db_shard_groups.each do |db_shard, group|
903
+ result = tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
904
+ db_shard_results[db_shard] = result
905
+ end
906
+
907
+ # If all db_shards returned empty, return empty
908
+ return {} if db_shard_results.values.all? { |r| r == {} }
909
+
910
+ # Merge results positionally back into the original item order.
911
+ # For single-row results (no end_time), merge flat arrays.
912
+ # For multi-row results, each db_shard may have different row counts;
913
+ # use the maximum row count and fill missing positions with [nil, nil].
914
+ if !end_time
915
+ # Single-row mode: each db_shard returns a flat array of [value, limits] pairs.
916
+ # Merge them into the original item order.
917
+ merged = Array.new(items.length) { [nil, nil] }
918
+ db_shard_groups.each do |db_shard, group|
919
+ result = db_shard_results[db_shard]
920
+ next if result == {} || !result.is_a?(Array)
921
+ group[:positions].each_with_index do |orig_pos, db_shard_idx|
922
+ merged[orig_pos] = result[db_shard_idx] if result[db_shard_idx]
923
+ end
924
+ end
925
+ merged
926
+ else
927
+ # Multi-row mode: find max row count across db_shards
928
+ max_rows = 0
929
+ db_shard_groups.each do |db_shard, _group|
930
+ result = db_shard_results[db_shard]
931
+ next if result == {}
932
+ count = result.is_a?(Array) ? result.length : 0
933
+ max_rows = count if count > max_rows
934
+ end
935
+ return {} if max_rows == 0
936
+
937
+ merged = Array.new(max_rows) { Array.new(items.length) { [nil, nil] } }
938
+ db_shard_groups.each do |db_shard, group|
939
+ result = db_shard_results[db_shard]
940
+ next if result == {}
941
+ rows = result.is_a?(Array) ? result : []
942
+ rows.each_with_index do |row, row_num|
943
+ next unless row.is_a?(Array)
944
+ group[:positions].each_with_index do |orig_pos, db_shard_idx|
945
+ merged[row_num][orig_pos] = row[db_shard_idx] if row[db_shard_idx]
946
+ end
947
+ end
948
+ end
949
+ merged
950
+ end
951
+ end
952
+
953
+ # Execute a tsdb_lookup query against a single db_shard.
954
+ # This contains the original ASOF JOIN logic for items all on the same QuestDB instance.
955
+ def self.tsdb_lookup_single_db_shard(items, start_time:, end_time: nil, scope: "DEFAULT", db_shard: 0)
819
956
  tables = {}
820
957
  names = []
821
958
  nil_count = 0
@@ -887,9 +1024,9 @@ module OpenC3
887
1024
  query = "SELECT #{names.join(", ")} FROM "
888
1025
  tables.each_with_index do |(table_name, _), index|
889
1026
  if index == 0
890
- query += "#{table_name} as T#{index} "
1027
+ query += "\"#{table_name}\" as T#{index} "
891
1028
  else
892
- query += "ASOF JOIN #{table_name} as T#{index} "
1029
+ query += "ASOF JOIN \"#{table_name}\" as T#{index} "
893
1030
  end
894
1031
  end
895
1032
  query_params = []
@@ -902,7 +1039,7 @@ module OpenC3
902
1039
  query_params << end_time
903
1040
  end
904
1041
 
905
- result = query_with_retry(query, params: query_params, label: "tsdb_lookup")
1042
+ result = query_with_retry(query, params: query_params, label: "tsdb_lookup", db_shard: db_shard)
906
1043
  if result.nil? or result.ntuples == 0
907
1044
  return {}
908
1045
  end