openc3 7.4.1 → 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.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/bin/pipinstall +18 -5
  3. data/bin/pipuninstall +1 -1
  4. data/bin/uvinstall +36 -8
  5. data/data/config/default_plugins.yaml +43 -0
  6. data/data/config/interface_modifiers.yaml +22 -7
  7. data/data/config/item_modifiers.yaml +14 -4
  8. data/data/config/plugins.yaml +1 -1
  9. data/data/config/widgets.yaml +1 -1
  10. data/lib/openc3/api/cmd_api.rb +10 -13
  11. data/lib/openc3/api/interface_api.rb +1 -2
  12. data/lib/openc3/api/limits_api.rb +149 -160
  13. data/lib/openc3/api/metrics_api.rb +26 -1
  14. data/lib/openc3/api/router_api.rb +1 -2
  15. data/lib/openc3/api/tlm_api.rb +225 -71
  16. data/lib/openc3/config/config_parser.rb +3 -1
  17. data/lib/openc3/core_ext/tempfile.rb +2 -0
  18. data/lib/openc3/interfaces/file_interface.rb +6 -1
  19. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  20. data/lib/openc3/interfaces/interface.rb +27 -3
  21. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  22. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  23. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  24. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  25. data/lib/openc3/interfaces.rb +1 -0
  26. data/lib/openc3/logs/log_writer.rb +14 -4
  27. data/lib/openc3/microservices/decom_microservice.rb +30 -5
  28. data/lib/openc3/microservices/interface_decom_common.rb +3 -1
  29. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  30. data/lib/openc3/microservices/log_microservice.rb +1 -3
  31. data/lib/openc3/microservices/microservice.rb +25 -1
  32. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  33. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  34. data/lib/openc3/models/cvt_model.rb +7 -2
  35. data/lib/openc3/models/interface_model.rb +1 -1
  36. data/lib/openc3/models/interface_status_model.rb +6 -0
  37. data/lib/openc3/models/metric_model.rb +52 -4
  38. data/lib/openc3/models/microservice_model.rb +7 -3
  39. data/lib/openc3/models/plugin_model.rb +12 -12
  40. data/lib/openc3/models/python_package_model.rb +3 -13
  41. data/lib/openc3/models/target_model.rb +92 -15
  42. data/lib/openc3/operators/microservice_operator.rb +5 -0
  43. data/lib/openc3/operators/operator.rb +69 -0
  44. data/lib/openc3/packets/packet.rb +10 -4
  45. data/lib/openc3/packets/packet_config.rb +12 -1
  46. data/lib/openc3/packets/packet_item.rb +40 -22
  47. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  48. data/lib/openc3/packets/structure_item.rb +3 -2
  49. data/lib/openc3/script/api_shared.rb +62 -86
  50. data/lib/openc3/script/extract.rb +263 -20
  51. data/lib/openc3/script/plugins.rb +4 -11
  52. data/lib/openc3/script/script.rb +1 -1
  53. data/lib/openc3/script/storage.rb +81 -10
  54. data/lib/openc3/system/system.rb +9 -6
  55. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  56. data/lib/openc3/topics/command_topic.rb +2 -1
  57. data/lib/openc3/topics/limits_event_topic.rb +90 -3
  58. data/lib/openc3/topics/router_topic.rb +7 -2
  59. data/lib/openc3/topics/system_events_topic.rb +9 -6
  60. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  62. data/lib/openc3/topics/topic.rb +162 -3
  63. data/lib/openc3/utilities/bucket.rb +5 -0
  64. data/lib/openc3/utilities/cli_generator.rb +67 -15
  65. data/lib/openc3/utilities/config_overlay.rb +35 -17
  66. data/lib/openc3/utilities/default_plugins.rb +33 -0
  67. data/lib/openc3/utilities/local_mode.rb +82 -46
  68. data/lib/openc3/utilities/metric.rb +16 -0
  69. data/lib/openc3/utilities/process_stats.rb +160 -0
  70. data/lib/openc3/utilities/pypi_url.rb +65 -0
  71. data/lib/openc3/utilities/questdb_client.rb +140 -32
  72. data/lib/openc3/utilities/running_script.rb +22 -18
  73. data/lib/openc3/utilities/secrets.rb +5 -0
  74. data/lib/openc3/utilities/store_autoload.rb +23 -4
  75. data/lib/openc3/utilities/store_queued.rb +37 -9
  76. data/lib/openc3/utilities/target_file.rb +57 -12
  77. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  78. data/lib/openc3/version.rb +5 -5
  79. data/templates/conversion/conversion.py +6 -2
  80. data/templates/conversion/conversion.rb +5 -0
  81. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  82. data/templates/plugin/.github/workflows/release.yml +45 -0
  83. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  84. data/templates/plugin/.gitignore +6 -0
  85. data/templates/plugin/README.md +114 -2
  86. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  87. data/templates/plugin/plugin.gemspec +10 -1
  88. data/templates/plugin/pyproject.toml +119 -0
  89. data/templates/tool_angular/package.json +2 -2
  90. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  91. data/templates/tool_react/package.json +1 -1
  92. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  93. data/templates/tool_svelte/package.json +2 -1
  94. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  95. data/templates/tool_vue/package.json +3 -3
  96. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  97. data/templates/widget/package.json +2 -2
  98. data/templates/widget/pnpm-workspace.yaml +16 -0
  99. metadata +16 -1
@@ -17,6 +17,8 @@
17
17
 
18
18
  require 'tempfile'
19
19
  require 'net/http'
20
+ require 'base64'
21
+ require 'openc3/utilities/local_mode'
20
22
 
21
23
  ENV['OPENC3_CLOUD'] ||= 'local'
22
24
 
@@ -53,6 +55,15 @@ module OpenC3
53
55
 
54
56
  upload_path = "#{scope}/targets_modified/#{path}"
55
57
 
58
+ if OpenC3::LocalMode.local_only_name?(path)
59
+ # The local mode volume is only mounted in the cluster so from outside
60
+ # write it through the API which never sends it to the bucket
61
+ return _upload_api_file(upload_path, io_or_string, scope: scope) unless $openc3_in_cluster
62
+ puts "Writing local #{upload_path}"
63
+ OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
64
+ return nil
65
+ end
66
+
56
67
  if ENV['OPENC3_LOCAL_MODE'] and $openc3_in_cluster
57
68
  OpenC3::LocalMode.put_target_file(upload_path, io_or_string, scope: scope)
58
69
  io_or_string.rewind if io_or_string.respond_to?(:rewind)
@@ -91,22 +102,22 @@ module OpenC3
91
102
  # @param original [Boolean] Whether to get the original or modified file
92
103
  # @return [File|nil]
93
104
  def get_target_file(path, original: false, scope: $openc3_scope)
105
+ # Local only targets only exist in the local mode volume so original doesn't apply
106
+ if OpenC3::LocalMode.local_only_name?(path)
107
+ # The local mode volume is only mounted in the cluster so from outside
108
+ # read it through the API which serves it from the volume
109
+ return _get_api_file("targets_modified/#{path}", scope: scope) unless $openc3_in_cluster
110
+ return _get_local_file(path, scope: scope)
111
+ end
112
+
94
113
  part = "targets"
95
114
  part += "_modified" unless original
96
115
  # Loop to allow redo when switching from modified to original
97
116
  loop do
98
117
  begin
99
118
  if part == "targets_modified" and ENV['OPENC3_LOCAL_MODE']
100
- local_file = OpenC3::LocalMode.open_local_file(path, scope: scope)
101
- if local_file
102
- puts "Reading local #{scope}/#{part}/#{path}"
103
- file = Tempfile.new('target', binmode: true)
104
- file.filename = path
105
- file.write(local_file.read)
106
- local_file.close
107
- file.rewind
108
- return file
109
- end
119
+ file = _get_local_file(path, scope: scope)
120
+ return file if file
110
121
  end
111
122
 
112
123
  return _get_storage_file("#{part}/#{path}", scope: scope)
@@ -125,7 +136,53 @@ module OpenC3
125
136
 
126
137
  # These are helper methods ... should not be used directly
127
138
 
139
+ # Write a file through the API instead of a presigned URL
140
+ def _upload_api_file(path, io_or_string, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
141
+ data = String === io_or_string ? io_or_string : io_or_string.read
142
+ endpoint = "/openc3-api/storage/upload_file/#{path}"
143
+ puts "Writing #{path}"
144
+ response = $api_server.request('put', endpoint, query: { bucket: bucket },
145
+ data: { contents: Base64.strict_encode64(data) }, json: true, scope: scope)
146
+ if response.nil? || response.status != 200
147
+ raise "Failed to write #{path}"
148
+ end
149
+ nil
150
+ end
151
+
152
+ # Read a file through the API instead of a presigned URL
153
+ # Returns nil if the file doesn't exist
154
+ def _get_api_file(path, bucket: 'OPENC3_CONFIG_BUCKET', scope: $openc3_scope)
155
+ endpoint = "/openc3-api/storage/download_file/#{scope}/#{path}"
156
+ response = $api_server.request('get', endpoint, query: { bucket: bucket }, scope: scope)
157
+ return nil if response && response.status == 404
158
+ if response.nil? || response.status != 200
159
+ raise "Failed to read #{scope}/#{path}"
160
+ end
161
+ puts "Reading #{scope}/#{path}"
162
+ json = JSON.parse(response.body, allow_nan: true, create_additions: true)
163
+ file = Tempfile.new('target', binmode: true)
164
+ file.filename = path
165
+ file.write(Base64.decode64(json['contents']))
166
+ file.rewind
167
+ return file
168
+ end
169
+
170
+ def _get_local_file(path, scope: $openc3_scope)
171
+ local_file = OpenC3::LocalMode.open_local_file(path, scope: scope)
172
+ return nil unless local_file
173
+ puts "Reading local #{scope}/targets_modified/#{path}"
174
+ file = Tempfile.new('target', binmode: true)
175
+ file.filename = path
176
+ file.write(local_file.read)
177
+ local_file.close
178
+ file.rewind
179
+ return file
180
+ end
181
+
128
182
  def _get_download_url(path, scope: $openc3_scope)
183
+ if OpenC3::LocalMode.local_only_name?(path)
184
+ raise "#{path} belongs to a local only target (OPENC3_LOCAL_ONLY_TARGETS) and has no bucket download URL"
185
+ end
129
186
  targets = "targets_modified" # First try targets_modified
130
187
  response = $api_server.request('get', "/openc3-api/storage/exists/#{scope}/#{targets}/#{path}", query: { bucket: 'OPENC3_CONFIG_BUCKET' }, scope: scope)
131
188
  if response.status != 200
@@ -167,6 +224,20 @@ module OpenC3
167
224
  return file
168
225
  end
169
226
 
227
+ # Retrieve the file selected in open_bucket_dialog
228
+ # input is the hash returned by the frontend: { 'bucket', 'path', 'filename' }
229
+ def _get_bucket_dialog_file(input, scope: $openc3_scope)
230
+ bucket = input['bucket']
231
+ # Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...)
232
+ # _get_storage_file prepends scope, so strip it from the path to avoid doubling
233
+ path = input['path'].sub(/\A#{Regexp.escape(scope)}\//, '')
234
+ file = _get_storage_file(path, bucket: bucket, scope: scope)
235
+ file.filename = input['filename']
236
+ file.bucket = bucket
237
+ file.bucket_path = input['path']
238
+ file
239
+ end
240
+
170
241
  def _get_uri(url)
171
242
  if $openc3_in_cluster
172
243
  case ENV.fetch('OPENC3_CLOUD', nil)
@@ -78,7 +78,8 @@ module OpenC3
78
78
  end
79
79
 
80
80
  # target_version can also be the actual hash used in the target_archives folder
81
- def self.setup_targets(target_names, base_dir, target_version: 'current', scope:)
81
+ # @param descriptions [Boolean] Whether to keep packet and item descriptions
82
+ def self.setup_targets(target_names, base_dir, target_version: 'current', scope:, descriptions: true)
82
83
  # Nothing to do if there are no targets
83
84
  return if target_names.nil? or target_names.length == 0
84
85
  if @@instance.nil?
@@ -118,7 +119,7 @@ module OpenC3
118
119
  end
119
120
 
120
121
  # Build System from targets
121
- System.instance(target_names, targets_path)
122
+ System.instance(target_names, targets_path, descriptions: descriptions)
122
123
  end
123
124
  end
124
125
 
@@ -137,14 +138,15 @@ module OpenC3
137
138
  #
138
139
  # @param target_names [Array of target_names]
139
140
  # @param target_config_dir Directory where target config folders are
141
+ # @param descriptions [Boolean] Whether to keep packet and item descriptions
140
142
  # @return [System] The System singleton
141
- def self.instance(target_names = nil, target_config_dir = nil)
143
+ def self.instance(target_names = nil, target_config_dir = nil, descriptions: true)
142
144
  return @@instance if @@instance
143
145
  raise "System.instance parameters are required on first call" unless target_names and target_config_dir
144
146
 
145
147
  @@instance_mutex.synchronize do
146
148
  return @@instance if @@instance
147
- @@instance ||= self.new(target_names, target_config_dir)
149
+ @@instance ||= self.new(target_names, target_config_dir, descriptions: descriptions)
148
150
  @@post_instance_callbacks.each do |callback|
149
151
  callback.call
150
152
  end
@@ -171,10 +173,11 @@ module OpenC3
171
173
  #
172
174
  # @param target_names [Array of target names]
173
175
  # @param target_config_dir Directory where target config folders are
174
- def initialize(target_names, target_config_dir)
176
+ # @param descriptions [Boolean] Whether to keep packet and item descriptions
177
+ def initialize(target_names, target_config_dir, descriptions: true)
175
178
  OpenC3.add_to_search_path(target_config_dir, true) if target_config_dir
176
179
  @targets = {}
177
- @packet_config = PacketConfig.new
180
+ @packet_config = PacketConfig.new(descriptions: descriptions)
178
181
  @commands = Commands.new(@packet_config)
179
182
  @telemetry = Telemetry.new(@packet_config)
180
183
  @limits = Limits.new(@packet_config)
@@ -58,7 +58,7 @@ module OpenC3
58
58
  msg_hash['json_data'] = JSON.generate(json_hash.as_json, allow_nan: true)
59
59
  msg_hash['extra'] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
60
60
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
61
- EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
61
+ EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: Topic.stream_safety_minid)
62
62
  end
63
63
 
64
64
  def self.get_cmd_item(target_name, packet_name, param_name, type: :FORMATTED, scope: $openc3_scope)
@@ -35,7 +35,8 @@ module OpenC3
35
35
  buffer: packet.buffer(false) }
36
36
  msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
37
37
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
38
- EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
38
+ minid = Topic.log_stream_safety_minid(packet.target_name, :CMD, scope: scope)
39
+ EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
39
40
  end
40
41
 
41
42
  # @param command [Hash] Command hash structure read to be written to a topic
@@ -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,13 +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]}"
87
- Topic.write_topic(topic, { 'target_name' => packet.target_name ? packet.target_name : 'UNKNOWN', 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100)
90
+ db_shard = Store.db_shard_for_target(target_names[0], scope: scope)
91
+ # Name the routed target so the interface identifies against it (and sends it as UNKNOWN if that fails)
92
+ Topic.write_topic(topic, { 'target_name' => target_names[0], 'cmd_name' => 'UNKNOWN', 'cmd_buffer' => packet.buffer(false) }, '*', 100, db_shard: db_shard)
88
93
  else
89
94
  raise "No route for command: #{packet.target_name ? packet.target_name : 'UNKNOWN'} #{packet.packet_name ? packet.packet_name : 'UNKNOWN'}"
90
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
@@ -45,7 +45,7 @@ module OpenC3
45
45
  }
46
46
  msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
47
47
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
48
- Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, db_shard: db_shard)
48
+ Topic.write_topic("#{scope}__DECOM__{#{packet.target_name}}__#{packet.packet_name}", msg_hash, id, minid: Topic.stream_safety_minid(id), db_shard: db_shard)
49
49
 
50
50
  unless packet.stored
51
51
  # Also update the current value table with the latest decommutated data
@@ -33,10 +33,11 @@ module OpenC3
33
33
  msg_hash[:extra] = JSON.generate(packet.extra.as_json, allow_nan: true) if packet.extra
34
34
  topic = "#{scope}__TELEMETRY__{#{packet.target_name}}__#{packet.packet_name}"
35
35
  db_shard = Store.db_shard_for_target(packet.target_name, scope: scope)
36
+ minid = Topic.log_stream_safety_minid(packet.target_name, :TLM, scope: scope)
36
37
  if queued
37
- EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash)
38
+ EphemeralStoreQueued.instance(db_shard: db_shard).write_topic(topic, msg_hash, minid: minid)
38
39
  else
39
- Topic.write_topic(topic, msg_hash, db_shard: db_shard)
40
+ Topic.write_topic(topic, msg_hash, minid: minid, db_shard: db_shard)
40
41
  end
41
42
  end
42
43
  end
@@ -16,9 +16,48 @@
16
16
  # if purchased from OpenC3, Inc.
17
17
 
18
18
  require 'openc3/utilities/store'
19
+ require 'openc3/utilities/logger'
19
20
 
20
21
  module OpenC3
21
22
  class Topic
23
+ # Stream trimming contract for the high rate target streams
24
+ # (TELEMETRY__, COMMAND__, DECOM__, DECOMCMD__):
25
+ #
26
+ # The streams are primarily trimmed by their consumers once the data is safely
27
+ # persisted elsewhere:
28
+ # * TELEMETRY__ / COMMAND__ are trimmed by the log microservice (LogWriter) once a
29
+ # log file has been moved to the bucket plus LogWriter::CLEANUP_DELAY.
30
+ # * DECOM__ / DECOMCMD__ are trimmed by the TSDB microservice which keeps
31
+ # TsdbMicroservice::TRIM_KEEP_MS of data.
32
+ #
33
+ # If a trimmer is absent (logging disabled, no TSDB) or falls far behind, the streams
34
+ # would grow without bound. As a safety net every write to these streams also passes
35
+ # an approximate XADD MINID so no entry older than the safety max age is retained.
36
+ # The max age is OPENC3_STREAM_MAX_AGE_SECONDS (default 600s, 0 or empty disables) and is
37
+ # never less than STREAM_MAX_AGE_MIN_SECONDS. For TELEMETRY__ / COMMAND__ the max age is
38
+ # never less than two log cycles plus the cleanup delay, because historical streaming reads
39
+ # data not yet in the bucket from the stream.
40
+ #
41
+ # When the minid is calculated from now (auto generated ids) it uses this host's clock while
42
+ # Redis generates the ids from its own clock. A writer clock ahead of Redis by more than the
43
+ # max age would trim the entire stream (including the entry being added) so hosts must be
44
+ # time synchronized (NTP).
45
+ STREAM_MAX_AGE_DEFAULT_SECONDS = 600
46
+ # Twice TsdbMicroservice::TRIM_KEEP_MS. Also protects against small clock offsets.
47
+ STREAM_MAX_AGE_MIN_SECONDS = 120
48
+ # LogWriter::CLEANUP_DELAY is defined from this
49
+ LOG_CLEANUP_DELAY_SECONDS = 60
50
+ # TargetModel default log cycle time
51
+ DEFAULT_LOG_CYCLE_TIME_SECONDS = 600
52
+ # How long to wait before retrying a failed log cycle time lookup
53
+ LOG_CYCLE_LOOKUP_RETRY_SECONDS = 60
54
+ # These are calculated on the write path of every packet so they are cached per process.
55
+ # The environment doesn't change while a microservice runs and the log cycle time only
56
+ # changes when the plugin (and therefore the microservices) are reinstalled.
57
+ @@stream_max_age_ms = nil
58
+ @@log_stream_max_age_ms = {}
59
+ @@log_cycle_lookup_retry_at = {}
60
+
22
61
  # Delegate all unknown class methods to EphemeralStore db_shard 0 (system-level topics)
23
62
  def self.method_missing(message, *args, **kwargs, &block)
24
63
  EphemeralStore.public_send(message, *args, **kwargs, &block)
@@ -37,8 +76,121 @@ module OpenC3
37
76
  # DB_Shard-aware topic methods for target-specific streams.
38
77
  # These explicitly route to the correct EphemeralStore db_shard.
39
78
 
40
- def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', db_shard: 0)
41
- EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate)
79
+ def self.write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', minid: nil, db_shard: 0)
80
+ EphemeralStore.instance(db_shard: db_shard).write_topic(topic, msg_hash, id, maxlen, approximate, minid: minid)
81
+ end
82
+
83
+ # @return [Float] Configured safety max age in seconds (<= 0 means disabled)
84
+ def self.stream_max_age_seconds
85
+ value = ENV.fetch('OPENC3_STREAM_MAX_AGE_SECONDS', nil)
86
+ return STREAM_MAX_AGE_DEFAULT_SECONDS if value.nil?
87
+ return 0.0 if value.strip.empty?
88
+ # Strictly parse and reject nan / inf, which would raise when converted to
89
+ # milliseconds on every write. A typo keeps the safety cap at the default
90
+ # rather than disabling it.
91
+ seconds = Float(value, exception: false)
92
+ return seconds if seconds&.finite?
93
+ Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of '#{value}' is invalid, using #{STREAM_MAX_AGE_DEFAULT_SECONDS}")
94
+ return STREAM_MAX_AGE_DEFAULT_SECONDS
95
+ end
96
+
97
+ # @return [Integer] Cached safety max age in milliseconds (0 means disabled)
98
+ def self.stream_max_age_ms
99
+ @@stream_max_age_ms ||= begin
100
+ seconds = stream_max_age_seconds
101
+ if seconds <= 0
102
+ 0
103
+ elsif seconds < STREAM_MAX_AGE_MIN_SECONDS
104
+ Logger.warn("OPENC3_STREAM_MAX_AGE_SECONDS of #{seconds} is below the minimum, using #{STREAM_MAX_AGE_MIN_SECONDS}")
105
+ STREAM_MAX_AGE_MIN_SECONDS * 1000
106
+ else
107
+ (seconds * 1000).to_i
108
+ end
109
+ end
110
+ end
111
+
112
+ # Calculates the MINID to pass to XADD to cap the age of entries in a target stream.
113
+ # The cap is anchored to the id being written (or now for auto generated ids) so an
114
+ # explicitly id'd entry (e.g. decom reusing the raw packet id) is never trimmed by its own add.
115
+ #
116
+ # @param id [String] The id being written ('*' or nil for auto generation)
117
+ # @param max_age_ms [Integer] Max age to retain (<= 0 means disabled)
118
+ # @return [String|nil] The minid or nil if the safety cap is disabled
119
+ def self.stream_safety_minid(id = '*', max_age_ms: stream_max_age_ms)
120
+ return nil if max_age_ms <= 0
121
+ if id.nil? or id == '*'
122
+ base_ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond)
123
+ else
124
+ base_ms = id.to_s.to_i # Stops at the '-' sequence separator
125
+ end
126
+ minid_ms = base_ms - max_age_ms
127
+ return nil if minid_ms <= 0
128
+ return minid_ms.to_s
129
+ end
130
+
131
+ # Calculates the MINID for a TELEMETRY__ / COMMAND__ stream write which also never
132
+ # trims younger than log_stream_min_age_seconds. The resulting max age is cached per stream.
133
+ #
134
+ # @param target_name [String] Target name
135
+ # @param cmd_or_tlm [Symbol] :CMD or :TLM
136
+ # @return [String|nil] The minid or nil if the safety cap is disabled
137
+ def self.log_stream_safety_minid(target_name, cmd_or_tlm, scope:)
138
+ target_cache = ((@@log_stream_max_age_ms[scope] ||= {})[target_name] ||= {})
139
+ max_age_ms = target_cache[cmd_or_tlm]
140
+ unless max_age_ms
141
+ max_age_ms = log_stream_max_age_ms(target_name, cmd_or_tlm, scope: scope)
142
+ return nil unless max_age_ms # Log cycle time unknown so don't cap yet
143
+ target_cache[cmd_or_tlm] = max_age_ms
144
+ end
145
+ return stream_safety_minid(max_age_ms: max_age_ms)
146
+ end
147
+
148
+ # @param target_name [String] Target name
149
+ # @param cmd_or_tlm [Symbol] :CMD or :TLM
150
+ # @return [Integer|nil] Max age in milliseconds for a TELEMETRY__ / COMMAND__ stream (0 means
151
+ # disabled) or nil if the log cycle time couldn't be determined. Guessing the log cycle time
152
+ # could trim data historical streaming still needs, so the stream isn't capped until a
153
+ # lookup (retried every LOG_CYCLE_LOOKUP_RETRY_SECONDS) succeeds.
154
+ def self.log_stream_max_age_ms(target_name, cmd_or_tlm, scope:)
155
+ max_age_ms = stream_max_age_ms
156
+ return max_age_ms if max_age_ms <= 0
157
+ key = [scope, target_name, cmd_or_tlm]
158
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ retry_at = @@log_cycle_lookup_retry_at[key]
160
+ return nil if retry_at and now < retry_at
161
+ begin
162
+ min_age_ms = log_stream_min_age_seconds(target_name, cmd_or_tlm, scope: scope) * 1000
163
+ rescue => e
164
+ Logger.warn("Unable to determine log cycle time for #{target_name} #{cmd_or_tlm}, stream age not capped: #{e.message}")
165
+ @@log_cycle_lookup_retry_at[key] = now + LOG_CYCLE_LOOKUP_RETRY_SECONDS
166
+ return nil
167
+ end
168
+ @@log_cycle_lookup_retry_at.delete(key)
169
+ return [max_age_ms, min_age_ms].max
170
+ end
171
+
172
+ # Minimum age to retain in a TELEMETRY__ / COMMAND__ stream so the log microservice
173
+ # and historical streaming always have the data not yet moved to the bucket.
174
+ #
175
+ # @param target_name [String] Target name
176
+ # @param cmd_or_tlm [Symbol] :CMD or :TLM
177
+ # @return [Integer] Minimum age in seconds
178
+ # @raise If the TargetModel lookup fails
179
+ def self.log_stream_min_age_seconds(target_name, cmd_or_tlm, scope:)
180
+ require 'openc3/models/target_model'
181
+ cycle_time = DEFAULT_LOG_CYCLE_TIME_SECONDS
182
+ model = TargetModel.get(name: target_name, scope: scope)
183
+ if model
184
+ value = model[cmd_or_tlm == :CMD ? 'cmd_log_cycle_time' : 'tlm_log_cycle_time'].to_i
185
+ cycle_time = value if value > 0
186
+ end
187
+ return 2 * (cycle_time + LOG_CLEANUP_DELAY_SECONDS)
188
+ end
189
+
190
+ def self.clear_stream_max_age_cache
191
+ @@stream_max_age_ms = nil
192
+ @@log_stream_max_age_ms = {}
193
+ @@log_cycle_lookup_retry_at = {}
42
194
  end
43
195
 
44
196
  def self.read_topics(topics, offsets = nil, timeout_ms = 1000, count = nil, db_shard: 0, &block)
@@ -69,6 +221,13 @@ module OpenC3
69
221
  EphemeralStore.instance(db_shard: db_shard).del(topic)
70
222
  end
71
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
+
72
231
  # Group topics by db_shard. Each topic's target name is extracted and looked up.
73
232
  # Topics matching target_pattern are db_sharded; others go to db_shard 0.
74
233
  # @param topics [Array<String>] List of topic strings
@@ -79,7 +238,7 @@ module OpenC3
79
238
  groups = {}
80
239
  topics.each do |topic|
81
240
  if topic.include?(target_pattern)
82
- target_name = topic.match(/__\{?([^}_]+)\}?__/)[1] rescue nil
241
+ target_name = target_name_from_topic(topic)
83
242
  # Handle CMD}TARGET__ pattern where target is after TARGET__
84
243
  target_name = topic.split('TARGET__')[1] if target_pattern.include?('TARGET__') && target_name.nil?
85
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