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
@@ -21,6 +21,12 @@ module OpenC3
21
21
  class InterfaceTopic < Topic
22
22
  COMMAND_ACK_TIMEOUT_S = 30
23
23
 
24
+ # Look up db_shard from Interface
25
+ def self._db_shard_for_interface(interface_name, scope:)
26
+ json = Store.hget("#{scope}__openc3_interfaces", interface_name)
27
+ json ? (JSON.parse(json, allow_nan: true, create_additions: true)['db_shard'] || 0).to_i : 0
28
+ end
29
+
24
30
  # Generate a list of topics for this interface. This includes the interface itself
25
31
  # and all the targets which are assigned to this interface.
26
32
  def self.topics(interface, scope:)
@@ -33,16 +39,39 @@ module OpenC3
33
39
  topics
34
40
  end
35
41
 
36
- def self.receive_commands(interface, scope:)
42
+ def self.receive_commands(interface, scope:, db_shard: 0)
43
+ db_shard = db_shard.to_i
44
+ interface_cmd_topic = "{#{scope}__CMD}INTERFACE__#{interface.name}"
45
+ system_events_topic = "OPENC3__SYSTEM__EVENTS"
46
+
47
+ target_topics = []
48
+ interface.cmd_target_names.each do |target_name|
49
+ target_topics << "{#{scope}__CMD}TARGET__#{target_name}"
50
+ end
51
+
52
+ # Group target command topics by db_shard; include interface cmd and system events on db_shard
53
+ db_shard_groups = Topic.group_topics_by_db_shard(target_topics, target_pattern: 'CMD}TARGET__', scope: scope)
54
+ db_shard_groups[db_shard] ||= []
55
+ db_shard_groups[db_shard] << interface_cmd_topic
56
+ db_shard_groups[db_shard] << system_events_topic
57
+
58
+ all_same_db_shard = Topic.all_same_db_shard?(db_shard_groups)
59
+
37
60
  while true
38
- Topic.read_topics(InterfaceTopic.topics(interface, scope: scope)) do |topic, msg_id, msg_hash, redis|
39
- result = yield topic, msg_id, msg_hash, redis
40
- if result
41
- # Only ack if we intend to - Disabled targets will not ack
42
- ack_topic = topic.split("__")
43
- ack_topic[1] = 'ACK' + ack_topic[1]
44
- ack_topic = ack_topic.join("__")
45
- Topic.write_topic(ack_topic, { 'result' => result, 'id' => msg_id }, '*', 100)
61
+ if all_same_db_shard
62
+ # Fast path: everything on one db_shard, single read
63
+ db_shard = db_shard_groups.keys.first || 0
64
+ Topic.read_topics(db_shard_groups[db_shard], db_shard: db_shard) do |topic, msg_id, msg_hash, redis|
65
+ result = yield topic, msg_id, msg_hash, redis
66
+ Topic.write_ack(topic, result, msg_id, db_shard: db_shard) if result
67
+ end
68
+ else
69
+ timeout_per_db_shard = [1000 / [db_shard_groups.length, 1].max, 100].max
70
+ db_shard_groups.each do |db_shard, topics|
71
+ Topic.read_topics(topics, nil, timeout_per_db_shard, db_shard: db_shard) do |topic, msg_id, msg_hash, redis|
72
+ result = yield topic, msg_id, msg_hash, redis
73
+ Topic.write_ack(topic, result, msg_id, db_shard: db_shard) if result
74
+ end
46
75
  end
47
76
  end
48
77
  end
@@ -50,15 +79,16 @@ module OpenC3
50
79
 
51
80
  def self.write_raw(interface_name, data, timeout: nil, scope:)
52
81
  interface_name = interface_name.upcase
82
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
53
83
 
54
84
  timeout = COMMAND_ACK_TIMEOUT_S unless timeout
55
85
  ack_topic = "{#{scope}__ACKCMD}INTERFACE__#{interface_name}"
56
- Topic.update_topic_offsets([ack_topic])
86
+ Topic.update_topic_offsets([ack_topic], db_shard: db_shard)
57
87
 
58
- cmd_id = Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'raw' => data }, '*', 100)
88
+ cmd_id = Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'raw' => data }, '*', 100, db_shard: db_shard)
59
89
  time = Time.now
60
90
  while (Time.now - time) < timeout
61
- Topic.read_topics([ack_topic]) do |_topic, _msg_id, msg_hash, _redis|
91
+ Topic.read_topics([ack_topic], db_shard: db_shard) do |_topic, _msg_id, msg_hash, _redis|
62
92
  if msg_hash["id"] == cmd_id
63
93
  if msg_hash["result"] == "SUCCESS"
64
94
  return
@@ -72,83 +102,114 @@ module OpenC3
72
102
  end
73
103
 
74
104
  def self.connect_interface(interface_name, *interface_params, scope:)
105
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
75
106
  if interface_params && !interface_params.empty?
76
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'connect' => 'true', 'params' => JSON.generate(interface_params, allow_nan: true) }, '*', 100)
107
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'connect' => 'true', 'params' => JSON.generate(interface_params, allow_nan: true) }, '*', 100, db_shard: db_shard)
77
108
  else
78
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'connect' => 'true' }, '*', 100)
109
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'connect' => 'true' }, '*', 100, db_shard: db_shard)
79
110
  end
80
111
  end
81
112
 
82
113
  def self.disconnect_interface(interface_name, scope:)
83
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'disconnect' => 'true' }, '*', 100)
114
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
115
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'disconnect' => 'true' }, '*', 100, db_shard: db_shard)
84
116
  end
85
117
 
86
118
  def self.start_raw_logging(interface_name, scope:)
87
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'log_stream' => 'true' }, '*', 100)
119
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
120
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'log_stream' => 'true' }, '*', 100, db_shard: db_shard)
88
121
  end
89
122
 
90
123
  def self.stop_raw_logging(interface_name, scope:)
91
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'log_stream' => 'false' }, '*', 100)
124
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
125
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'log_stream' => 'false' }, '*', 100, db_shard: db_shard)
92
126
  end
93
127
 
94
128
  def self.shutdown(interface, scope:)
95
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface.name}", { 'shutdown' => 'true' }, '*', 100)
129
+ db_shard = _db_shard_for_interface(interface.name, scope: scope)
130
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface.name}", { 'shutdown' => 'true' }, '*', 100, db_shard: db_shard)
96
131
  end
97
132
 
98
133
  def self.interface_cmd(interface_name, cmd_name, *cmd_params, scope:)
134
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
99
135
  data = {}
100
136
  data['cmd_name'] = cmd_name
101
137
  data['cmd_params'] = cmd_params
102
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'interface_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100)
138
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'interface_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
103
139
  end
104
140
 
105
141
  def self.protocol_cmd(interface_name, cmd_name, *cmd_params, read_write: :READ_WRITE, index: -1, scope:)
142
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
106
143
  data = {}
107
144
  data['cmd_name'] = cmd_name
108
145
  data['cmd_params'] = cmd_params
109
146
  data['read_write'] = read_write.to_s.upcase
110
147
  data['index'] = index
111
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'protocol_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100)
148
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'protocol_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
112
149
  end
113
150
 
114
- def self.inject_tlm(interface_name, target_name, packet_name, item_hash = nil, type: :CONVERTED, scope:)
151
+ def self.inject_tlm(interface_name, target_name, packet_name, item_hash = nil, type: :CONVERTED, stored: false, timeout: nil, scope:)
152
+ interface_name = interface_name.upcase
153
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
154
+
155
+ timeout = COMMAND_ACK_TIMEOUT_S unless timeout
156
+ ack_topic = "{#{scope}__ACKCMD}INTERFACE__#{interface_name}"
157
+ Topic.update_topic_offsets([ack_topic], db_shard: db_shard)
158
+
115
159
  data = {}
116
160
  data['target_name'] = target_name.to_s.upcase
117
161
  data['packet_name'] = packet_name.to_s.upcase
118
162
  data['item_hash'] = item_hash
119
163
  data['type'] = type
120
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100)
164
+ data['stored'] = stored
165
+ cmd_id = Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'inject_tlm' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
166
+ time = Time.now
167
+ while (Time.now - time) < timeout
168
+ Topic.read_topics([ack_topic], db_shard: db_shard) do |_topic, _msg_id, msg_hash, _redis|
169
+ if msg_hash["id"] == cmd_id
170
+ if msg_hash["result"] == "SUCCESS"
171
+ return
172
+ else
173
+ raise msg_hash["result"]
174
+ end
175
+ end
176
+ end
177
+ end
178
+ raise "Timeout of #{timeout}s waiting for cmd ack"
121
179
  end
122
180
 
123
181
  def self.interface_target_enable(interface_name, target_name, cmd_only: false, tlm_only: false, scope:)
182
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
124
183
  data = {}
125
184
  data['target_name'] = target_name.to_s.upcase
126
185
  data['cmd_only'] = cmd_only
127
186
  data['tlm_only'] = tlm_only
128
187
  data['action'] = 'enable'
129
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100)
188
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
130
189
  end
131
190
 
132
191
  def self.interface_target_disable(interface_name, target_name, cmd_only: false, tlm_only: false, scope:)
192
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
133
193
  data = {}
134
194
  data['target_name'] = target_name.to_s.upcase
135
195
  data['cmd_only'] = cmd_only
136
196
  data['tlm_only'] = tlm_only
137
197
  data['action'] = 'disable'
138
- Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100)
198
+ Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
139
199
  end
140
200
 
141
201
  def self.interface_details(interface_name, timeout: nil, scope:)
142
202
  interface_name = interface_name.upcase
203
+ db_shard = _db_shard_for_interface(interface_name, scope: scope)
143
204
 
144
205
  timeout = COMMAND_ACK_TIMEOUT_S unless timeout
145
206
  ack_topic = "{#{scope}__ACKCMD}INTERFACE__#{interface_name}"
146
- Topic.update_topic_offsets([ack_topic])
207
+ Topic.update_topic_offsets([ack_topic], db_shard: db_shard)
147
208
 
148
- cmd_id = Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'interface_details' => 'true' }, '*', 100)
209
+ cmd_id = Topic.write_topic("{#{scope}__CMD}INTERFACE__#{interface_name}", { 'interface_details' => 'true' }, '*', 100, db_shard: db_shard)
149
210
  time = Time.now
150
211
  while (Time.now - time) < timeout
151
- Topic.read_topics([ack_topic]) do |_topic, _msg_id, msg_hash, _redis|
212
+ Topic.read_topics([ack_topic], db_shard: db_shard) do |_topic, _msg_id, msg_hash, _redis|
152
213
  if msg_hash["id"] == cmd_id
153
214
  return JSON.parse(msg_hash["result"], :allow_nan => true, :create_additions => true)
154
215
  end
@@ -25,6 +25,16 @@ 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
+ # Collect all unique target db_shards from TargetModel
29
+ def self._active_db_shards(scope:)
30
+ db_shards = Set.new([0])
31
+ Store.hgetall("#{scope}__openc3_targets").each do |_name, json|
32
+ parsed = JSON.parse(json, allow_nan: true, create_additions: true)
33
+ db_shards << (parsed['db_shard'] || 0).to_i
34
+ end
35
+ db_shards
36
+ end
37
+
28
38
  def self.write(event, scope:)
29
39
  case event[:type]
30
40
  when :LIMITS_CHANGE
@@ -81,7 +91,10 @@ module OpenC3
81
91
  raise "Invalid limits event type '#{event[:type]}'"
82
92
  end
83
93
 
84
- Topic.write_topic("#{scope}__openc3_limits_events", {event: JSON.generate(event, allow_nan: true)}, '*', 1000)
94
+ # Write to all active db_shards so each decom microservice can read limits events inline
95
+ _active_db_shards(scope: scope).each do |db_shard|
96
+ Topic.write_topic("#{scope}__openc3_limits_events", {event: JSON.generate(event, allow_nan: true)}, '*', 1000, db_shard: db_shard)
97
+ end
85
98
  end
86
99
 
87
100
  # Remove the JSON encoding to return hashes directly
@@ -189,51 +202,59 @@ module OpenC3
189
202
  end
190
203
  end
191
204
 
192
- # Update the local system based on limits events
193
- def self.sync_system_thread_body(scope:, block_ms: nil)
194
- telemetry = System.telemetry.all
195
- topics = ["#{scope}__openc3_limits_events"]
196
- Topic.read_topics(topics, nil, block_ms) do |_topic, _msg_id, event, _redis|
197
- event = JSON.parse(event['event'], allow_nan: true, create_additions: true)
198
- case event['type']
199
- when 'LIMITS_CHANGE'
200
- # Ignore
201
- when 'LIMITS_SETTINGS'
202
- target_name = event['target_name']
203
- packet_name = event['packet_name']
204
- item_name = event['item_name']
205
- target = telemetry[target_name]
206
- if target
207
- packet = target[packet_name]
208
- if packet
209
- enabled = ConfigParser.handle_true_false_nil(event['enabled'])
210
- persistence = event['persistence']
211
- System.limits.set(target_name, packet_name, item_name,
212
- event['red_low'], event['yellow_low'], event['yellow_high'], event['red_high'],
213
- event['green_low'], event['green_high'], event['limits_set'], persistence, enabled)
214
- end
205
+ # Process a single limits event hash and update the local System accordingly.
206
+ # Called inline by DecomMicroservice when reading from the limits events topic.
207
+ def self.process_event(event, telemetry: nil)
208
+ telemetry ||= System.telemetry.all
209
+ case event['type']
210
+ when 'LIMITS_CHANGE'
211
+ # Ignore
212
+ when 'LIMITS_SETTINGS'
213
+ target_name = event['target_name']
214
+ packet_name = event['packet_name']
215
+ item_name = event['item_name']
216
+ target = telemetry[target_name]
217
+ if target
218
+ packet = target[packet_name]
219
+ if packet
220
+ enabled = ConfigParser.handle_true_false_nil(event['enabled'])
221
+ persistence = event['persistence']
222
+ System.limits.set(target_name, packet_name, item_name,
223
+ event['red_low'], event['yellow_low'], event['yellow_high'], event['red_high'],
224
+ event['green_low'], event['green_high'], event['limits_set'], persistence, enabled)
215
225
  end
226
+ end
216
227
 
217
- when 'LIMITS_ENABLE_STATE'
218
- target_name = event['target_name']
219
- packet_name = event['packet_name']
220
- item_name = event['item_name']
221
- target = telemetry[target_name]
222
- if target
223
- packet = target[packet_name]
224
- if packet
225
- enabled = ConfigParser.handle_true_false_nil(event['enabled'])
226
- if enabled
227
- System.limits.enable(target_name, packet_name, item_name)
228
- else
229
- System.limits.disable(target_name, packet_name, item_name)
230
- end
228
+ when 'LIMITS_ENABLE_STATE'
229
+ target_name = event['target_name']
230
+ packet_name = event['packet_name']
231
+ item_name = event['item_name']
232
+ target = telemetry[target_name]
233
+ if target
234
+ packet = target[packet_name]
235
+ if packet
236
+ enabled = ConfigParser.handle_true_false_nil(event['enabled'])
237
+ if enabled
238
+ System.limits.enable(target_name, packet_name, item_name)
239
+ else
240
+ System.limits.disable(target_name, packet_name, item_name)
231
241
  end
232
242
  end
233
-
234
- when 'LIMITS_SET'
235
- System.limits_set = event['set']
236
243
  end
244
+
245
+ when 'LIMITS_SET'
246
+ System.limits_set = event['set']
247
+ end
248
+ end
249
+
250
+ # Update the local system based on limits events (standalone read loop).
251
+ # Still available for non-decom consumers that need to sync limits.
252
+ def self.sync_system_thread_body(scope:, block_ms: nil)
253
+ telemetry = System.telemetry.all
254
+ topics = ["#{scope}__openc3_limits_events"]
255
+ Topic.read_topics(topics, nil, block_ms) do |_topic, _msg_id, event, _redis|
256
+ event = JSON.parse(event['event'], allow_nan: true, create_additions: true)
257
+ process_event(event, telemetry: telemetry)
237
258
  end
238
259
  end
239
260
  end
@@ -21,6 +21,12 @@ module OpenC3
21
21
  class RouterTopic < Topic
22
22
  COMMAND_ACK_TIMEOUT_S = 30
23
23
 
24
+ # Look up db_shard from RouterModel
25
+ def self._db_shard_for_router(router_name, scope:)
26
+ json = Store.hget("#{scope}__openc3_routers", router_name)
27
+ json ? (JSON.parse(json, allow_nan: true, create_additions: true)['db_shard'] || 0).to_i : 0
28
+ end
29
+
24
30
  # Generate a list of topics for this router. This includes the router itself
25
31
  # and all the targets which are assigned to this router.
26
32
  def self.topics(router, scope:)
@@ -34,15 +40,39 @@ module OpenC3
34
40
  topics
35
41
  end
36
42
 
37
- def self.receive_telemetry(router, scope:)
43
+ def self.receive_telemetry(router, scope:, db_shard: 0)
44
+ db_shard = db_shard.to_i
45
+ router_cmd_topic = "{#{scope}__CMD}ROUTER__#{router.name}"
46
+
47
+ target_topics = []
48
+ router.tlm_target_names.each do |target_name|
49
+ System.telemetry.packets(target_name).each do |_packet_name, packet|
50
+ target_topics << "#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}"
51
+ end
52
+ end
53
+
54
+ # Group telemetry topics by db_shard; include router cmd topic on db_shard
55
+ db_shard_groups = Topic.group_topics_by_db_shard(target_topics, target_pattern: '__TELEMETRY__', scope: scope)
56
+ db_shard_groups[db_shard] ||= []
57
+ db_shard_groups[db_shard] << router_cmd_topic
58
+
59
+ all_same_db_shard = Topic.all_same_db_shard?(db_shard_groups)
60
+
38
61
  while true
39
- Topic.read_topics(RouterTopic.topics(router, scope: scope)) do |topic, msg_id, msg_hash, redis|
40
- result = yield topic, msg_id, msg_hash, redis
41
- if result and /CMD}ROUTER/.match?(topic)
42
- ack_topic = topic.split("__")
43
- ack_topic[1] = 'ACK' + ack_topic[1]
44
- ack_topic = ack_topic.join("__")
45
- Topic.write_topic(ack_topic, { 'result' => result, 'id' => msg_id }, msg_id, 100)
62
+ if all_same_db_shard
63
+ # Fast path: everything on one db_shard, single read
64
+ db_shard = db_shard_groups.keys.first || 0
65
+ Topic.read_topics(db_shard_groups[db_shard], db_shard: db_shard) do |topic, msg_id, msg_hash, redis|
66
+ result = yield topic, msg_id, msg_hash, redis
67
+ Topic.write_ack(topic, result, msg_id, db_shard: db_shard) if result and /CMD}ROUTER/.match?(topic)
68
+ end
69
+ else
70
+ timeout_per_db_shard = [1000 / [db_shard_groups.length, 1].max, 100].max
71
+ db_shard_groups.each do |db_shard, topics|
72
+ Topic.read_topics(topics, nil, timeout_per_db_shard, db_shard: db_shard) do |topic, msg_id, msg_hash, redis|
73
+ result = yield topic, msg_id, msg_hash, redis
74
+ Topic.write_ack(topic, result, msg_id, db_shard: db_shard) if result and /CMD}ROUTER/.match?(topic)
75
+ end
46
76
  end
47
77
  end
48
78
  end
@@ -61,74 +91,84 @@ module OpenC3
61
91
  end
62
92
 
63
93
  def self.connect_router(router_name, *router_params, scope:)
94
+ db_shard = _db_shard_for_router(router_name, scope: scope)
64
95
  if router_params && !router_params.empty?
65
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'connect' => 'true', 'params' => JSON.generate(router_params, allow_nan: true) }, '*', 100)
96
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'connect' => 'true', 'params' => JSON.generate(router_params, allow_nan: true) }, '*', 100, db_shard: db_shard)
66
97
  else
67
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'connect' => 'true' }, '*', 100)
98
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'connect' => 'true' }, '*', 100, db_shard: db_shard)
68
99
  end
69
100
  end
70
101
 
71
102
  def self.disconnect_router(router_name, scope:)
72
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'disconnect' => 'true' }, '*', 100)
103
+ db_shard = _db_shard_for_router(router_name, scope: scope)
104
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'disconnect' => 'true' }, '*', 100, db_shard: db_shard)
73
105
  end
74
106
 
75
107
  def self.start_raw_logging(router_name, scope:)
76
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'log_stream' => 'true' }, '*', 100)
108
+ db_shard = _db_shard_for_router(router_name, scope: scope)
109
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'log_stream' => 'true' }, '*', 100, db_shard: db_shard)
77
110
  end
78
111
 
79
112
  def self.stop_raw_logging(router_name, scope:)
80
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'log_stream' => 'false' }, '*', 100)
113
+ db_shard = _db_shard_for_router(router_name, scope: scope)
114
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'log_stream' => 'false' }, '*', 100, db_shard: db_shard)
81
115
  end
82
116
 
83
117
  def self.shutdown(router, scope:)
84
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router.name}", { 'shutdown' => 'true' }, '*', 100)
118
+ db_shard = _db_shard_for_router(router.name, scope: scope)
119
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router.name}", { 'shutdown' => 'true' }, '*', 100, db_shard: db_shard)
85
120
  end
86
121
 
87
122
  def self.router_cmd(router_name, cmd_name, *cmd_params, scope:)
123
+ db_shard = _db_shard_for_router(router_name, scope: scope)
88
124
  data = {}
89
125
  data['cmd_name'] = cmd_name
90
126
  data['cmd_params'] = cmd_params
91
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'router_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100)
127
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'router_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
92
128
  end
93
129
 
94
130
  def self.protocol_cmd(router_name, cmd_name, *cmd_params, read_write: :READ_WRITE, index: -1, scope:)
131
+ db_shard = _db_shard_for_router(router_name, scope: scope)
95
132
  data = {}
96
133
  data['cmd_name'] = cmd_name
97
134
  data['cmd_params'] = cmd_params
98
135
  data['read_write'] = read_write.to_s.upcase
99
136
  data['index'] = index
100
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'protocol_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100)
137
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'protocol_cmd' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
101
138
  end
102
139
 
103
140
  def self.router_target_enable(router_name, target_name, cmd_only: false, tlm_only: false, scope:)
141
+ db_shard = _db_shard_for_router(router_name, scope: scope)
104
142
  data = {}
105
143
  data['target_name'] = target_name.to_s.upcase
106
144
  data['cmd_only'] = cmd_only
107
145
  data['tlm_only'] = tlm_only
108
146
  data['action'] = 'enable'
109
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100)
147
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
110
148
  end
111
149
 
112
150
  def self.router_target_disable(router_name, target_name, cmd_only: false, tlm_only: false, scope:)
151
+ db_shard = _db_shard_for_router(router_name, scope: scope)
113
152
  data = {}
114
153
  data['target_name'] = target_name.to_s.upcase
115
154
  data['cmd_only'] = cmd_only
116
155
  data['tlm_only'] = tlm_only
117
156
  data['action'] = 'disable'
118
- Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100)
157
+ Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'target_control' => JSON.generate(data, allow_nan: true) }, '*', 100, db_shard: db_shard)
119
158
  end
120
159
 
121
160
  def self.router_details(router_name, timeout: nil, scope:)
122
161
  router_name = router_name.upcase
162
+ db_shard = _db_shard_for_router(router_name, scope: scope)
123
163
 
124
164
  timeout = COMMAND_ACK_TIMEOUT_S unless timeout
125
165
  ack_topic = "{#{scope}__ACKCMD}ROUTER__#{router_name}"
126
- Topic.update_topic_offsets([ack_topic])
166
+ Topic.update_topic_offsets([ack_topic], db_shard: db_shard)
127
167
 
128
- cmd_id = Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'router_details' => 'true' }, '*', 100)
168
+ cmd_id = Topic.write_topic("{#{scope}__CMD}ROUTER__#{router_name}", { 'router_details' => 'true' }, '*', 100, db_shard: db_shard)
129
169
  time = Time.now
130
170
  while (Time.now - time) < timeout
131
- Topic.read_topics([ack_topic]) do |_topic, _msg_id, msg_hash, _redis|
171
+ Topic.read_topics([ack_topic], db_shard: db_shard) do |_topic, _msg_id, msg_hash, _redis|
132
172
  if msg_hash["id"] == cmd_id
133
173
  return JSON.parse(msg_hash["result"], :allow_nan => true, :create_additions => true)
134
174
  end
@@ -17,13 +17,30 @@ 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
21
+ def self._active_db_shards
22
+ 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
28
+ end
29
+ end
30
+ db_shards
31
+ end
32
+
20
33
  def self.update_topic_offsets()
21
34
  Topic.update_topic_offsets([PRIMARY_KEY])
22
35
  end
23
36
 
24
37
  def self.write(type, event)
25
38
  event['type'] = type
26
- Topic.write_topic(PRIMARY_KEY, {event: JSON.generate(event, allow_nan: true)}, '*', 1000)
39
+ msg = {event: JSON.generate(event, allow_nan: true)}
40
+ # Write to all active db_shards so every interface microservice can read system events inline
41
+ _active_db_shards.each do |db_shard|
42
+ Topic.write_topic(PRIMARY_KEY, msg, '*', 1000, db_shard: db_shard)
43
+ end
27
44
  end
28
45
 
29
46
  def self.read()
@@ -43,7 +43,8 @@ module OpenC3
43
43
  :received_count => packet.received_count,
44
44
  :json_data => json_data,
45
45
  }
46
- Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id)
46
+ db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
47
+ Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, db_shard: db_shard)
47
48
 
48
49
  unless packet.stored
49
50
  # Also update the current value table with the latest decommutated data
@@ -31,10 +31,12 @@ module OpenC3
31
31
  :buffer => packet.buffer(false)
32
32
  }
33
33
  msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
34
+ topic = "#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}"
35
+ db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
34
36
  if queued
35
- EphemeralStoreQueued.write_topic("#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}", msg_hash)
37
+ EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
36
38
  else
37
- Topic.write_topic("#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}", msg_hash)
39
+ Topic.write_topic(topic, msg_hash, db_shard: db_shard)
38
40
  end
39
41
  end
40
42
  end