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.
- checksums.yaml +4 -4
- data/bin/pipinstall +18 -5
- data/bin/pipuninstall +1 -1
- data/bin/uvinstall +36 -8
- data/data/config/default_plugins.yaml +43 -0
- data/data/config/interface_modifiers.yaml +22 -7
- data/data/config/item_modifiers.yaml +14 -4
- data/data/config/plugins.yaml +1 -1
- data/data/config/widgets.yaml +1 -1
- data/lib/openc3/api/cmd_api.rb +10 -13
- data/lib/openc3/api/interface_api.rb +1 -2
- data/lib/openc3/api/limits_api.rb +149 -160
- data/lib/openc3/api/metrics_api.rb +26 -1
- data/lib/openc3/api/router_api.rb +1 -2
- data/lib/openc3/api/tlm_api.rb +225 -71
- data/lib/openc3/config/config_parser.rb +3 -1
- data/lib/openc3/core_ext/tempfile.rb +2 -0
- data/lib/openc3/interfaces/file_interface.rb +6 -1
- data/lib/openc3/interfaces/http_client_interface.rb +14 -6
- data/lib/openc3/interfaces/interface.rb +27 -3
- data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
- data/lib/openc3/interfaces/stream_interface.rb +22 -3
- data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
- data/lib/openc3/interfaces/udp_interface.rb +16 -2
- data/lib/openc3/interfaces.rb +1 -0
- data/lib/openc3/logs/log_writer.rb +14 -4
- data/lib/openc3/microservices/decom_microservice.rb +30 -5
- data/lib/openc3/microservices/interface_decom_common.rb +3 -1
- data/lib/openc3/microservices/interface_microservice.rb +146 -50
- data/lib/openc3/microservices/log_microservice.rb +1 -3
- data/lib/openc3/microservices/microservice.rb +25 -1
- data/lib/openc3/microservices/plugin_microservice.rb +32 -0
- data/lib/openc3/microservices/text_log_microservice.rb +1 -3
- data/lib/openc3/models/cvt_model.rb +7 -2
- data/lib/openc3/models/interface_model.rb +1 -1
- data/lib/openc3/models/interface_status_model.rb +6 -0
- data/lib/openc3/models/metric_model.rb +52 -4
- data/lib/openc3/models/microservice_model.rb +7 -3
- data/lib/openc3/models/plugin_model.rb +12 -12
- data/lib/openc3/models/python_package_model.rb +3 -13
- data/lib/openc3/models/target_model.rb +92 -15
- data/lib/openc3/operators/microservice_operator.rb +5 -0
- data/lib/openc3/operators/operator.rb +69 -0
- data/lib/openc3/packets/packet.rb +10 -4
- data/lib/openc3/packets/packet_config.rb +12 -1
- data/lib/openc3/packets/packet_item.rb +40 -22
- data/lib/openc3/packets/parsers/state_parser.rb +1 -1
- data/lib/openc3/packets/structure_item.rb +3 -2
- data/lib/openc3/script/api_shared.rb +62 -86
- data/lib/openc3/script/extract.rb +263 -20
- data/lib/openc3/script/plugins.rb +4 -11
- data/lib/openc3/script/script.rb +1 -1
- data/lib/openc3/script/storage.rb +81 -10
- data/lib/openc3/system/system.rb +9 -6
- data/lib/openc3/topics/command_decom_topic.rb +1 -1
- data/lib/openc3/topics/command_topic.rb +2 -1
- data/lib/openc3/topics/limits_event_topic.rb +90 -3
- data/lib/openc3/topics/router_topic.rb +7 -2
- data/lib/openc3/topics/system_events_topic.rb +9 -6
- data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
- data/lib/openc3/topics/telemetry_topic.rb +3 -2
- data/lib/openc3/topics/topic.rb +162 -3
- data/lib/openc3/utilities/bucket.rb +5 -0
- data/lib/openc3/utilities/cli_generator.rb +67 -15
- data/lib/openc3/utilities/config_overlay.rb +35 -17
- data/lib/openc3/utilities/default_plugins.rb +33 -0
- data/lib/openc3/utilities/local_mode.rb +82 -46
- data/lib/openc3/utilities/metric.rb +16 -0
- data/lib/openc3/utilities/process_stats.rb +160 -0
- data/lib/openc3/utilities/pypi_url.rb +65 -0
- data/lib/openc3/utilities/questdb_client.rb +140 -32
- data/lib/openc3/utilities/running_script.rb +22 -18
- data/lib/openc3/utilities/secrets.rb +5 -0
- data/lib/openc3/utilities/store_autoload.rb +23 -4
- data/lib/openc3/utilities/store_queued.rb +37 -9
- data/lib/openc3/utilities/target_file.rb +57 -12
- data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
- data/lib/openc3/version.rb +5 -5
- data/templates/conversion/conversion.py +6 -2
- data/templates/conversion/conversion.rb +5 -0
- data/templates/plugin/.github/workflows/playwright.yml +62 -0
- data/templates/plugin/.github/workflows/release.yml +45 -0
- data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
- data/templates/plugin/.gitignore +6 -0
- data/templates/plugin/README.md +114 -2
- data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
- data/templates/plugin/plugin.gemspec +10 -1
- data/templates/plugin/pyproject.toml +119 -0
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_angular/pnpm-workspace.yaml +16 -0
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_react/pnpm-workspace.yaml +16 -0
- data/templates/tool_svelte/package.json +2 -1
- data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
- data/templates/tool_vue/package.json +3 -3
- data/templates/tool_vue/pnpm-workspace.yaml +16 -0
- data/templates/widget/package.json +2 -2
- data/templates/widget/pnpm-workspace.yaml +16 -0
- 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
|
-
|
|
101
|
-
if
|
|
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)
|
data/lib/openc3/system/system.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
20
|
+
# Collect all unique target and interface db_shards. Interfaces read system
|
|
21
|
+
# events on their own db_shard which may not have any targets on it.
|
|
21
22
|
def self._active_db_shards
|
|
22
23
|
db_shards = Set.new([0])
|
|
23
|
-
# Iterate all scopes to find all target db_shards
|
|
24
|
-
|
|
25
|
-
Store.
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
# Iterate all scopes to find all target and interface db_shards
|
|
25
|
+
['*__openc3_targets', '*__openc3_interfaces'].each do |match|
|
|
26
|
+
Store.scan_each(match: match, type: 'hash') do |key|
|
|
27
|
+
Store.hgetall(key).each do |_name, json|
|
|
28
|
+
parsed = JSON.parse(json, allow_nan: true, create_additions: true)
|
|
29
|
+
db_shards << (parsed['db_shard'] || 0).to_i
|
|
30
|
+
end
|
|
28
31
|
end
|
|
29
32
|
end
|
|
30
33
|
db_shards
|
|
@@ -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
|
data/lib/openc3/topics/topic.rb
CHANGED
|
@@ -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
|
|
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
|