openc3 7.4.0 → 7.5.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.
- checksums.yaml +4 -4
- data/bin/openc3cli +4 -3
- 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 +1 -1
- 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 +21 -21
- 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/protocols/protocol.rb +8 -0
- 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/io/json_api_object.rb +24 -3
- data/lib/openc3/io/json_drb_object.rb +3 -2
- data/lib/openc3/logs/log_writer.rb +13 -4
- data/lib/openc3/microservices/decom_microservice.rb +7 -4
- 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/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/reaction_model.rb +6 -3
- data/lib/openc3/models/setting_model.rb +5 -3
- data/lib/openc3/models/target_model.rb +83 -9
- 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 +48 -2
- data/lib/openc3/packets/packet_item.rb +27 -14
- data/lib/openc3/packets/parsers/state_parser.rb +1 -1
- data/lib/openc3/packets/parsers/xtce_parser.rb +8 -1
- data/lib/openc3/packets/structure_item.rb +3 -2
- data/lib/openc3/script/api_shared.rb +58 -84
- data/lib/openc3/script/extract.rb +215 -19
- data/lib/openc3/script/plugins.rb +4 -11
- data/lib/openc3/script/storage.rb +81 -10
- data/lib/openc3/script/tables.rb +5 -1
- 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/router_topic.rb +2 -1
- 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 +154 -2
- 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 +43 -46
- data/lib/openc3/utilities/running_script.rb +22 -18
- data/lib/openc3/utilities/store_autoload.rb +20 -3
- 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 +36 -1
|
@@ -632,6 +632,20 @@ module OpenC3
|
|
|
632
632
|
false
|
|
633
633
|
end
|
|
634
634
|
|
|
635
|
+
# Return the names of all tables in a single query. Used to avoid querying
|
|
636
|
+
# (and failing on) tables that were never created, e.g. commands that have
|
|
637
|
+
# never been sent, which is the common case for targets with thousands of packets.
|
|
638
|
+
# QuestDB table names are case insensitive so the names are upcased.
|
|
639
|
+
#
|
|
640
|
+
# @return [Set<String>, nil] Upcased table names, or nil if they cannot be queried
|
|
641
|
+
def self.table_names(db_shard: 0)
|
|
642
|
+
result = query_with_retry("SELECT table_name FROM tables()", max_retries: 1, label: "table names", db_shard: db_shard)
|
|
643
|
+
return nil unless result
|
|
644
|
+
result.values.each_with_object(Set.new) { |row, set| set << row[0].to_s.upcase }
|
|
645
|
+
rescue StandardError
|
|
646
|
+
nil
|
|
647
|
+
end
|
|
648
|
+
|
|
635
649
|
# QuestDB column types that can be aggregated with min/max/avg/stddev.
|
|
636
650
|
# Used by reduced queries to decide whether a converted (__C) column is
|
|
637
651
|
# numeric or must fall back to the raw column (e.g. states stored as VARCHAR).
|
|
@@ -928,6 +942,10 @@ module OpenC3
|
|
|
928
942
|
# @return [Array, Hash] Array of [value, limits_state] pairs per row, or {} if no results.
|
|
929
943
|
# Single-row results return a flat array; multi-row results return array of arrays.
|
|
930
944
|
def self.tsdb_lookup(items, start_time:, end_time: nil, scope: "DEFAULT")
|
|
945
|
+
# Every item is a placeholder for an item which doesn't exist, so there's
|
|
946
|
+
# nothing to query. Return a single row of nil values, one per item.
|
|
947
|
+
return Array.new(items.length) { [nil, nil] } if items.all? { |item| item[2].nil? }
|
|
948
|
+
|
|
931
949
|
# Group items by db_shard number while preserving their original positions
|
|
932
950
|
db_shard_groups = {} # db_shard => { positions: [], items: [] }
|
|
933
951
|
items.each_with_index do |item, pos|
|
|
@@ -944,62 +962,36 @@ module OpenC3
|
|
|
944
962
|
return tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
|
|
945
963
|
end
|
|
946
964
|
|
|
947
|
-
# Cross-db_shard: execute per-db_shard queries and merge results
|
|
948
|
-
|
|
965
|
+
# Cross-db_shard: execute per-db_shard queries (as arrays of rows) and merge results
|
|
966
|
+
db_shard_rows = {} # db_shard => rows
|
|
949
967
|
db_shard_groups.each do |db_shard, group|
|
|
950
|
-
result = tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
|
|
951
|
-
|
|
968
|
+
result = tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard, flatten: false)
|
|
969
|
+
db_shard_rows[db_shard] = result.is_a?(Array) ? result : []
|
|
952
970
|
end
|
|
953
971
|
|
|
954
|
-
#
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
# Merge them into the original item order.
|
|
964
|
-
merged = Array.new(items.length) { [nil, nil] }
|
|
965
|
-
db_shard_groups.each do |db_shard, group|
|
|
966
|
-
result = db_shard_results[db_shard]
|
|
967
|
-
next if result == {} || !result.is_a?(Array)
|
|
972
|
+
# Merge results positionally back into the original item order. Each db_shard
|
|
973
|
+
# may have different row counts so use the maximum row count and fill missing
|
|
974
|
+
# positions with [nil, nil]. If all db_shards returned empty, return empty.
|
|
975
|
+
max_rows = db_shard_rows.values.map(&:length).max
|
|
976
|
+
return {} if max_rows == 0
|
|
977
|
+
|
|
978
|
+
merged = Array.new(max_rows) { Array.new(items.length) { [nil, nil] } }
|
|
979
|
+
db_shard_groups.each do |db_shard, group|
|
|
980
|
+
db_shard_rows[db_shard].each_with_index do |row, row_num|
|
|
968
981
|
group[:positions].each_with_index do |orig_pos, db_shard_idx|
|
|
969
|
-
merged[orig_pos] =
|
|
982
|
+
merged[row_num][orig_pos] = row[db_shard_idx] if row[db_shard_idx]
|
|
970
983
|
end
|
|
971
984
|
end
|
|
972
|
-
merged
|
|
973
|
-
else
|
|
974
|
-
# Multi-row mode: find max row count across db_shards
|
|
975
|
-
max_rows = 0
|
|
976
|
-
db_shard_groups.each do |db_shard, _group|
|
|
977
|
-
result = db_shard_results[db_shard]
|
|
978
|
-
next if result == {}
|
|
979
|
-
count = result.is_a?(Array) ? result.length : 0
|
|
980
|
-
max_rows = count if count > max_rows
|
|
981
|
-
end
|
|
982
|
-
return {} if max_rows == 0
|
|
983
|
-
|
|
984
|
-
merged = Array.new(max_rows) { Array.new(items.length) { [nil, nil] } }
|
|
985
|
-
db_shard_groups.each do |db_shard, group|
|
|
986
|
-
result = db_shard_results[db_shard]
|
|
987
|
-
next if result == {}
|
|
988
|
-
rows = result.is_a?(Array) ? result : []
|
|
989
|
-
rows.each_with_index do |row, row_num|
|
|
990
|
-
next unless row.is_a?(Array)
|
|
991
|
-
group[:positions].each_with_index do |orig_pos, db_shard_idx|
|
|
992
|
-
merged[row_num][orig_pos] = row[db_shard_idx] if row[db_shard_idx]
|
|
993
|
-
end
|
|
994
|
-
end
|
|
995
|
-
end
|
|
996
|
-
merged
|
|
997
985
|
end
|
|
986
|
+
# Match the single db_shard behavior of returning a flat array for a single row
|
|
987
|
+
return merged[0] if max_rows == 1
|
|
988
|
+
merged
|
|
998
989
|
end
|
|
999
990
|
|
|
1000
991
|
# Execute a tsdb_lookup query against a single db_shard.
|
|
1001
992
|
# This contains the original ASOF JOIN logic for items all on the same QuestDB instance.
|
|
1002
|
-
|
|
993
|
+
# When flatten is true a single row result is returned as a flat array rather than an array of rows.
|
|
994
|
+
def self.tsdb_lookup_single_db_shard(items, start_time:, end_time: nil, scope: "DEFAULT", db_shard: 0, flatten: true)
|
|
1003
995
|
tables = {}
|
|
1004
996
|
names = []
|
|
1005
997
|
nil_count = 0
|
|
@@ -1060,6 +1052,11 @@ module OpenC3
|
|
|
1060
1052
|
end
|
|
1061
1053
|
end
|
|
1062
1054
|
|
|
1055
|
+
# Every item in this db_shard is a placeholder so there's no table to query.
|
|
1056
|
+
# Return no results and let tsdb_lookup fill these positions with [nil, nil]
|
|
1057
|
+
# when it merges the db_shards (an all placeholder lookup returns before this)
|
|
1058
|
+
return {} if tables.empty?
|
|
1059
|
+
|
|
1063
1060
|
# Add needed timestamp columns to the SELECT for calculated items
|
|
1064
1061
|
needed_timestamps.each do |table_index, ts_columns|
|
|
1065
1062
|
ts_columns.each do |ts_col|
|
|
@@ -1147,7 +1144,7 @@ module OpenC3
|
|
|
1147
1144
|
data[row_num].insert(position, [calculated_value, nil])
|
|
1148
1145
|
end
|
|
1149
1146
|
end
|
|
1150
|
-
if result.ntuples == 1
|
|
1147
|
+
if flatten and result.ntuples == 1
|
|
1151
1148
|
data = data[0]
|
|
1152
1149
|
end
|
|
1153
1150
|
data
|
|
@@ -112,16 +112,7 @@ module OpenC3
|
|
|
112
112
|
RunningScript.instance.perform_pause
|
|
113
113
|
else
|
|
114
114
|
if method.to_s == 'open_bucket_dialog'
|
|
115
|
-
|
|
116
|
-
path = input['path']
|
|
117
|
-
filename = input['filename']
|
|
118
|
-
# Path from bucket dialog already includes scope prefix (e.g. DEFAULT/targets/...)
|
|
119
|
-
# _get_storage_file prepends scope, so strip it from the path to avoid doubling
|
|
120
|
-
scope = RunningScript.instance.scope
|
|
121
|
-
path = path.sub(/\A#{Regexp.escape(scope)}\//, '')
|
|
122
|
-
file = _get_storage_file(path, bucket: bucket, scope: scope)
|
|
123
|
-
file.filename = filename
|
|
124
|
-
return file
|
|
115
|
+
return _get_bucket_dialog_file(input, scope: RunningScript.instance.scope)
|
|
125
116
|
elsif (method.to_s.include?('open_file'))
|
|
126
117
|
files = input.map do |filename|
|
|
127
118
|
file = _get_storage_file("tmp/#{filename}", scope: RunningScript.instance.scope)
|
|
@@ -275,10 +266,19 @@ module OpenC3
|
|
|
275
266
|
|
|
276
267
|
def goto(line_no_or_procedure_name, line_no = nil)
|
|
277
268
|
if line_no.nil?
|
|
278
|
-
|
|
269
|
+
procedure_name = RunningScript.instance.current_filename
|
|
270
|
+
line_no = line_no_or_procedure_name
|
|
279
271
|
else
|
|
280
|
-
|
|
272
|
+
procedure_name = line_no_or_procedure_name
|
|
281
273
|
end
|
|
274
|
+
# goto typed at the debug prompt is evaluated on the thread that processes
|
|
275
|
+
# go/step/stop messages. Running the script there deadlocks at the next pause,
|
|
276
|
+
# so hand it to the run thread the same way the editor's goto does.
|
|
277
|
+
unless RunningScript.run_thread?
|
|
278
|
+
RunningScript.instance.execute_while_paused(procedure_name, line_no, nil, true)
|
|
279
|
+
return
|
|
280
|
+
end
|
|
281
|
+
start(procedure_name, line_no: line_no, bind_variables: true, complete: true)
|
|
282
282
|
end
|
|
283
283
|
|
|
284
284
|
# Require an additional ruby file
|
|
@@ -797,6 +797,10 @@ class RunningScript
|
|
|
797
797
|
@@instance = value
|
|
798
798
|
end
|
|
799
799
|
|
|
800
|
+
def self.run_thread?
|
|
801
|
+
Thread.current == @@run_thread
|
|
802
|
+
end
|
|
803
|
+
|
|
800
804
|
def self.line_delay
|
|
801
805
|
@@line_delay
|
|
802
806
|
end
|
|
@@ -1103,9 +1107,10 @@ class RunningScript
|
|
|
1103
1107
|
trace
|
|
1104
1108
|
end
|
|
1105
1109
|
|
|
1106
|
-
|
|
1110
|
+
# bind_variables of nil binds only when filename is the currently running file
|
|
1111
|
+
def execute_while_paused(filename, line_no = 1, end_line_no = nil, bind_variables = nil)
|
|
1107
1112
|
if @script_status.state == 'paused' or @script_status.state == 'error' or @script_status.state == 'breakpoint'
|
|
1108
|
-
@execute_while_paused_info = { filename: filename, line_no: line_no, end_line_no: end_line_no }
|
|
1113
|
+
@execute_while_paused_info = { filename: filename, line_no: line_no, end_line_no: end_line_no, bind_variables: bind_variables }
|
|
1109
1114
|
else
|
|
1110
1115
|
scriptrunner_puts("Cannot execute selection or goto unless script is paused, breakpoint, or in error state")
|
|
1111
1116
|
end
|
|
@@ -1217,10 +1222,9 @@ class RunningScript
|
|
|
1217
1222
|
|
|
1218
1223
|
def check_execute_while_paused
|
|
1219
1224
|
if @execute_while_paused_info
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
bind_variables = false
|
|
1225
|
+
bind_variables = @execute_while_paused_info[:bind_variables]
|
|
1226
|
+
if bind_variables.nil?
|
|
1227
|
+
bind_variables = (@script_status.current_filename == @execute_while_paused_info[:filename])
|
|
1224
1228
|
end
|
|
1225
1229
|
if @execute_while_paused_info[:end_line_no]
|
|
1226
1230
|
# Execute Selection While Paused
|
|
@@ -54,6 +54,14 @@ module OpenC3
|
|
|
54
54
|
@@db_shard_cache_mutex = Mutex.new
|
|
55
55
|
DB_SHARD_CACHE_TIMEOUT = 60 # seconds
|
|
56
56
|
|
|
57
|
+
# Maximum number of entries read_topics returns PER STREAM when the caller
|
|
58
|
+
# does not pass a count. XREAD without COUNT returns everything from the
|
|
59
|
+
# offset to the tail of the stream, which for a consumer that has fallen
|
|
60
|
+
# behind on a high-rate stream can be an enormous reply materialized in
|
|
61
|
+
# memory all at once. Callers loop on their offsets so a capped read simply
|
|
62
|
+
# continues on the next call.
|
|
63
|
+
READ_TOPICS_DEFAULT_COUNT = 1000
|
|
64
|
+
|
|
57
65
|
# Mutex used to ensure that only one instance is created
|
|
58
66
|
@@instance_mutex = Mutex.new
|
|
59
67
|
|
|
@@ -225,8 +233,10 @@ module OpenC3
|
|
|
225
233
|
return offsets
|
|
226
234
|
end
|
|
227
235
|
|
|
236
|
+
# @param count [Integer] Maximum entries to return per stream. nil uses READ_TOPICS_DEFAULT_COUNT.
|
|
228
237
|
def read_topics(topics, offsets = nil, timeout_ms = 1000, count = nil)
|
|
229
238
|
return {} if topics.empty?
|
|
239
|
+
count ||= READ_TOPICS_DEFAULT_COUNT
|
|
230
240
|
Thread.current[:topic_offsets] ||= {}
|
|
231
241
|
topic_offsets = Thread.current[:topic_offsets]
|
|
232
242
|
begin
|
|
@@ -264,13 +274,20 @@ module OpenC3
|
|
|
264
274
|
# @option opts [String] :id the entry id, default value is `*`, it means auto generation,
|
|
265
275
|
# if `nil` id is passed it will be changed to `*`
|
|
266
276
|
# @option opts [Integer] :maxlen max length of entries, default value is `nil`, it means will grow forever
|
|
267
|
-
# @option opts [String] :approximate whether to add `~` modifier of maxlen or not, default value is 'true'
|
|
277
|
+
# @option opts [String] :approximate whether to add `~` modifier of maxlen or minid or not, default value is 'true'
|
|
278
|
+
# @option opts [String] :minid trim entries with ids lower than this as part of the add (XADD MINID).
|
|
279
|
+
# Cannot be combined with maxlen. See Topic.stream_safety_minid for the safety cap applied to target streams.
|
|
268
280
|
#
|
|
269
281
|
# @return [String] the entry id
|
|
270
|
-
def write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true')
|
|
282
|
+
def write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', minid: nil)
|
|
271
283
|
id = '*' if id.nil?
|
|
284
|
+
raise ArgumentError, "write_topic can't supply both maxlen and minid" if maxlen and minid
|
|
272
285
|
@redis_pool.with do |redis|
|
|
273
|
-
|
|
286
|
+
if minid
|
|
287
|
+
return redis.xadd(topic, msg_hash, id: id, minid: minid, approximate: approximate)
|
|
288
|
+
else
|
|
289
|
+
return redis.xadd(topic, msg_hash, id: id, maxlen: maxlen, approximate: approximate)
|
|
290
|
+
end
|
|
274
291
|
end
|
|
275
292
|
end
|
|
276
293
|
|
|
@@ -24,20 +24,32 @@ module OpenC3
|
|
|
24
24
|
# Mutex used to ensure that only one instance is created
|
|
25
25
|
@@instance_mutex = Mutex.new
|
|
26
26
|
|
|
27
|
+
DEFAULT_UPDATE_INTERVAL = 1 # seconds
|
|
28
|
+
|
|
27
29
|
# Get the singleton instance for the given db_shard
|
|
28
|
-
#
|
|
29
|
-
def self.instance(update_interval =
|
|
30
|
+
# Uses the interval from set_update_interval_all_shards if not given
|
|
31
|
+
def self.instance(update_interval = nil, db_shard: 0)
|
|
30
32
|
@instances ||= []
|
|
31
33
|
the_instance = @instances[db_shard]
|
|
32
34
|
return the_instance if the_instance
|
|
33
35
|
|
|
34
36
|
@@instance_mutex.synchronize do
|
|
35
37
|
@instances ||= []
|
|
36
|
-
@instances[db_shard] ||= self.new(update_interval, db_shard: db_shard)
|
|
38
|
+
@instances[db_shard] ||= self.new(update_interval || @update_interval || DEFAULT_UPDATE_INTERVAL, db_shard: db_shard)
|
|
37
39
|
return @instances[db_shard]
|
|
38
40
|
end
|
|
39
41
|
end
|
|
40
42
|
|
|
43
|
+
# Set the update interval on every db_shard instance, including ones created later
|
|
44
|
+
def self.set_update_interval_all_shards(interval)
|
|
45
|
+
return unless interval > 0.0
|
|
46
|
+
|
|
47
|
+
@@instance_mutex.synchronize do
|
|
48
|
+
@update_interval = interval
|
|
49
|
+
(@instances || []).compact.each { |the_instance| the_instance.set_update_interval(interval) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
41
53
|
# Delegate all unknown class methods to delegate to the instance (db_shard 0)
|
|
42
54
|
def self.method_missing(message, *args, **kwargs, &)
|
|
43
55
|
self.instance.public_send(message, *args, **kwargs, &)
|
|
@@ -49,6 +61,8 @@ module OpenC3
|
|
|
49
61
|
@store = store_instance()
|
|
50
62
|
# Queue to hold the store requests
|
|
51
63
|
@store_queue = Queue.new
|
|
64
|
+
# Orders queueing against the final drain in shutdown
|
|
65
|
+
@shutdown_mutex = Mutex.new
|
|
52
66
|
# Sleeper used to delay update thread
|
|
53
67
|
@update_sleeper = Sleeper.new
|
|
54
68
|
|
|
@@ -102,11 +116,18 @@ module OpenC3
|
|
|
102
116
|
@update_sleeper.cancel if @update_sleeper
|
|
103
117
|
OpenC3.kill_thread(self, @update_thread) if @update_thread
|
|
104
118
|
@update_thread = nil
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
119
|
+
# Nothing drains the queue once the thread is gone, so from here on write
|
|
120
|
+
# straight through rather than silently dropping whatever gets queued.
|
|
121
|
+
# Drain first and flip the flag last, under the mutex, so a direct write
|
|
122
|
+
# can't be overwritten by an older queued one and nothing is queued after
|
|
123
|
+
# the drain.
|
|
124
|
+
@shutdown_mutex.synchronize do
|
|
125
|
+
begin
|
|
126
|
+
process_queue()
|
|
127
|
+
rescue
|
|
128
|
+
# Best effort - Redis may already be unavailable during shutdown
|
|
129
|
+
end
|
|
130
|
+
@shutdown = true
|
|
110
131
|
end
|
|
111
132
|
end
|
|
112
133
|
|
|
@@ -114,7 +135,14 @@ module OpenC3
|
|
|
114
135
|
|
|
115
136
|
# Record the message for pipelining by the thread
|
|
116
137
|
def method_missing(message, *args, **kwargs, &block)
|
|
117
|
-
@
|
|
138
|
+
# @shutdown never goes back to false, so once set the lock can be skipped
|
|
139
|
+
unless @shutdown
|
|
140
|
+
queued = @shutdown_mutex.synchronize do
|
|
141
|
+
@store_queue.push(MessageStruct.new(message, args, kwargs, block)) unless @shutdown
|
|
142
|
+
end
|
|
143
|
+
return queued if queued
|
|
144
|
+
end
|
|
145
|
+
@store.public_send(message, *args, **kwargs, &block)
|
|
118
146
|
end
|
|
119
147
|
|
|
120
148
|
# Returns the store we're working with
|
|
@@ -31,6 +31,15 @@ module OpenC3
|
|
|
31
31
|
name.sub(/\*$/, '')
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Reject names that could resolve outside the scope's targets_modified
|
|
35
|
+
# directory. The bucket normalizes '..' segments in keys just like the
|
|
36
|
+
# filesystem does, so this must be checked before any bucket call.
|
|
37
|
+
def self.validate_name(name)
|
|
38
|
+
unless OpenC3::LocalMode.safe_key?(name)
|
|
39
|
+
raise ArgumentError, "Invalid target file name: #{name.inspect}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
def self.all(scope, path_matchers, target: nil)
|
|
35
44
|
target = target.upcase if target
|
|
36
45
|
|
|
@@ -46,14 +55,26 @@ module OpenC3
|
|
|
46
55
|
prefix = "#{scope}/targets/"
|
|
47
56
|
modified_prefix = "#{scope}/targets_modified/"
|
|
48
57
|
end
|
|
49
|
-
|
|
50
|
-
|
|
58
|
+
if target and OpenC3::LocalMode.local_only_target?(target)
|
|
59
|
+
# Local only targets are never listed from the bucket
|
|
60
|
+
result = Set.new
|
|
61
|
+
modified = Set.new
|
|
62
|
+
temp = Set.new
|
|
63
|
+
else
|
|
64
|
+
result, _ = remote_target_files(bucket_client: bucket, prefix: prefix, include_temp: false, path_matchers: path_matchers)
|
|
65
|
+
modified, temp = remote_target_files(bucket_client: bucket, prefix: modified_prefix, include_temp: include_temp, path_matchers: path_matchers)
|
|
66
|
+
end
|
|
51
67
|
|
|
52
68
|
# Add in local targets_modified if present
|
|
53
|
-
if ENV['OPENC3_LOCAL_MODE']
|
|
69
|
+
if ENV['OPENC3_LOCAL_MODE'] or !OpenC3::LocalMode.local_only_targets.empty?
|
|
54
70
|
local_modified = OpenC3::LocalMode.local_target_files(scope: scope, target: target, path_matchers: path_matchers, include_temp: include_temp)
|
|
55
71
|
local_modified.each do |filename|
|
|
56
|
-
if
|
|
72
|
+
if OpenC3::LocalMode.local_only_name?(filename)
|
|
73
|
+
# The local file is the only copy so it isn't marked as modified
|
|
74
|
+
result << filename
|
|
75
|
+
elsif !ENV['OPENC3_LOCAL_MODE']
|
|
76
|
+
next
|
|
77
|
+
elsif include_temp and filename.include?(TEMP_FOLDER)
|
|
57
78
|
temp << filename
|
|
58
79
|
else
|
|
59
80
|
modified << filename unless modified.include?(filename)
|
|
@@ -100,16 +121,14 @@ module OpenC3
|
|
|
100
121
|
|
|
101
122
|
def self.body(scope, name)
|
|
102
123
|
name = strip_modified(name) # Remove '*' that indicates modified
|
|
124
|
+
# Local only targets are read from the local mode volume and never the bucket
|
|
125
|
+
if OpenC3::LocalMode.local_only_name?(name)
|
|
126
|
+
return local_body(scope, name)
|
|
127
|
+
end
|
|
103
128
|
# First try opening a potentially modified version by looking for the modified target
|
|
104
129
|
if ENV['OPENC3_LOCAL_MODE']
|
|
105
|
-
|
|
106
|
-
if
|
|
107
|
-
if File.extname(name) == ".bin"
|
|
108
|
-
return local_file.read
|
|
109
|
-
else
|
|
110
|
-
return local_file.read.force_encoding('UTF-8')
|
|
111
|
-
end
|
|
112
|
-
end
|
|
130
|
+
text = local_body(scope, name)
|
|
131
|
+
return text if text
|
|
113
132
|
end
|
|
114
133
|
|
|
115
134
|
bucket = Bucket.getClient()
|
|
@@ -136,6 +155,11 @@ module OpenC3
|
|
|
136
155
|
# file listing, not a name any caller means to write. Remove it to avoid
|
|
137
156
|
# creating a file with a '*' in the name.
|
|
138
157
|
name = strip_modified(name)
|
|
158
|
+
validate_name(name)
|
|
159
|
+
if OpenC3::LocalMode.local_only_name?(name)
|
|
160
|
+
OpenC3::LocalMode.put_target_file("#{scope}/targets_modified/#{name}", text, scope: scope)
|
|
161
|
+
return true
|
|
162
|
+
end
|
|
139
163
|
if ENV['OPENC3_LOCAL_MODE']
|
|
140
164
|
OpenC3::LocalMode.put_target_file("#{scope}/targets_modified/#{name}", text, scope: scope)
|
|
141
165
|
end
|
|
@@ -163,6 +187,11 @@ module OpenC3
|
|
|
163
187
|
# Match body/create so deleting a name taken straight from a listing
|
|
164
188
|
# removes the file the user actually picked
|
|
165
189
|
name = strip_modified(name)
|
|
190
|
+
validate_name(name)
|
|
191
|
+
if OpenC3::LocalMode.local_only_name?(name)
|
|
192
|
+
OpenC3::LocalMode.delete_local("#{scope}/targets_modified/#{name}")
|
|
193
|
+
return true
|
|
194
|
+
end
|
|
166
195
|
if ENV['OPENC3_LOCAL_MODE']
|
|
167
196
|
OpenC3::LocalMode.delete_local("#{scope}/targets_modified/#{name}")
|
|
168
197
|
end
|
|
@@ -177,6 +206,20 @@ module OpenC3
|
|
|
177
206
|
|
|
178
207
|
# protected
|
|
179
208
|
|
|
209
|
+
def self.local_body(scope, name)
|
|
210
|
+
local_file = OpenC3::LocalMode.open_local_file(name, scope: scope)
|
|
211
|
+
return nil unless local_file
|
|
212
|
+
begin
|
|
213
|
+
if File.extname(name) == ".bin"
|
|
214
|
+
local_file.read
|
|
215
|
+
else
|
|
216
|
+
local_file.read.force_encoding('UTF-8')
|
|
217
|
+
end
|
|
218
|
+
ensure
|
|
219
|
+
local_file.close
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
180
223
|
def self.remote_target_files(bucket_client:, prefix:, include_temp: false, path_matchers: nil)
|
|
181
224
|
result = Set.new
|
|
182
225
|
temp = Set.new
|
|
@@ -191,6 +234,8 @@ module OpenC3
|
|
|
191
234
|
temp << split_key[2..-1].join('/') if include_temp
|
|
192
235
|
next
|
|
193
236
|
end
|
|
237
|
+
# Local only targets are never read from the bucket
|
|
238
|
+
next if OpenC3::LocalMode.local_only_target?(split_key[2])
|
|
194
239
|
|
|
195
240
|
if path_matchers
|
|
196
241
|
found = false
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# encoding: ascii-8bit
|
|
2
|
+
|
|
3
|
+
# Copyright 2026 OpenC3, Inc.
|
|
4
|
+
# All Rights Reserved.
|
|
5
|
+
#
|
|
6
|
+
# This program is distributed in the hope that it will be useful,
|
|
7
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
8
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
|
9
|
+
# See LICENSE.md for more details.
|
|
10
|
+
#
|
|
11
|
+
# This file may also be used under the terms of a commercial license
|
|
12
|
+
# if purchased from OpenC3, Inc.
|
|
13
|
+
|
|
14
|
+
require 'openc3/utilities/logger'
|
|
15
|
+
|
|
16
|
+
module OpenC3
|
|
17
|
+
# Tracks how far a microservice is behind the Redis streams it consumes.
|
|
18
|
+
#
|
|
19
|
+
# Every processed message updates the lag gauge metric with the maximum lag
|
|
20
|
+
# seen over the last metric update interval. When the lag crosses the warning
|
|
21
|
+
# or critical threshold a user notification is published, and a recovery
|
|
22
|
+
# notification is published once the microservice has caught back up (with
|
|
23
|
+
# hysteresis so a lag hovering around a threshold doesn't flap).
|
|
24
|
+
#
|
|
25
|
+
# Streams are trimmed by time once their data is logged, so a consumer can
|
|
26
|
+
# only skip trimmed data after lagging far past the critical threshold.
|
|
27
|
+
#
|
|
28
|
+
# Recovery assumes each monitor is fed by a single reader that reads every
|
|
29
|
+
# stream with pending entries on each XREAD (COUNT limits how many entries
|
|
30
|
+
# but not which streams). A backlogged topic then reports its high lag every
|
|
31
|
+
# read cycle, so fresh messages on other topics can't clear the alert early.
|
|
32
|
+
class TopicLagMonitor
|
|
33
|
+
OK = 0
|
|
34
|
+
WARN = 1
|
|
35
|
+
CRITICAL = 2
|
|
36
|
+
|
|
37
|
+
# Lag must drop below threshold * CLEAR_RATIO to count as recovering
|
|
38
|
+
CLEAR_RATIO = 0.5
|
|
39
|
+
# Matches Metric::UPDATE_INTERVAL so every lag spike is published
|
|
40
|
+
GAUGE_WINDOW_SECONDS = 5.0
|
|
41
|
+
|
|
42
|
+
attr_reader :state
|
|
43
|
+
|
|
44
|
+
# Lag in seconds that triggers the warning and critical notifications.
|
|
45
|
+
# A warning <= 0 disables notifications. Also used by Data Flows so its
|
|
46
|
+
# indicators match the notifications.
|
|
47
|
+
def self.thresholds
|
|
48
|
+
{
|
|
49
|
+
'warning' => env_float('OPENC3_LAG_WARN_SECONDS', 5.0),
|
|
50
|
+
'critical' => env_float('OPENC3_LAG_CRITICAL_SECONDS', 30.0),
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.env_float(name, default)
|
|
55
|
+
value = ENV.fetch(name, '').strip
|
|
56
|
+
return default if value.empty?
|
|
57
|
+
Float(value)
|
|
58
|
+
rescue ArgumentError
|
|
59
|
+
default
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(name:, logger:, metric: nil)
|
|
63
|
+
@name = name
|
|
64
|
+
@logger = logger
|
|
65
|
+
@metric = metric
|
|
66
|
+
thresholds = self.class.thresholds
|
|
67
|
+
@warn_seconds = thresholds['warning']
|
|
68
|
+
@critical_seconds = thresholds['critical']
|
|
69
|
+
@clear_seconds = self.class.env_float('OPENC3_LAG_CLEAR_SECONDS', 10.0)
|
|
70
|
+
@renotify_seconds = self.class.env_float('OPENC3_LAG_RENOTIFY_SECONDS', 300.0)
|
|
71
|
+
@state = OK
|
|
72
|
+
@max_lag = 0.0
|
|
73
|
+
@max_lag_topic = nil
|
|
74
|
+
@below_since = nil
|
|
75
|
+
@below_ok_since = nil
|
|
76
|
+
@last_notify = nil
|
|
77
|
+
@last_notify_level = OK
|
|
78
|
+
@notified = false
|
|
79
|
+
@gauges = {}
|
|
80
|
+
@mutex = Mutex.new
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Record that a message was read from a topic
|
|
84
|
+
#
|
|
85
|
+
# @param topic [String] Redis stream the message was read from
|
|
86
|
+
# @param msg_id [String] Redis stream id of the message
|
|
87
|
+
# @param metric_name [String] Gauge metric to update with the lag
|
|
88
|
+
# @param help [String] Help text for the gauge metric
|
|
89
|
+
# @return [Float] The lag in seconds
|
|
90
|
+
def record(topic, msg_id, metric_name: nil, help: nil, now: Time.now.to_f)
|
|
91
|
+
lag = now - (id_ms(msg_id) / 1000.0)
|
|
92
|
+
update_gauge(metric_name, help, lag, now) if @metric and metric_name
|
|
93
|
+
return lag if @warn_seconds <= 0 # Notifications disabled
|
|
94
|
+
|
|
95
|
+
# Fast path for the common case of keeping up: skip the mutex and state machine
|
|
96
|
+
return lag if @state == OK and lag < @warn_seconds
|
|
97
|
+
|
|
98
|
+
@mutex.synchronize { update_state(topic, lag, now) }
|
|
99
|
+
return lag
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
protected
|
|
103
|
+
|
|
104
|
+
# Topics read by one microservice can lag by very different amounts, so
|
|
105
|
+
# publish the maximum lag rather than whichever topic was read last. The
|
|
106
|
+
# previous window's maximum is held through the current window so a spike
|
|
107
|
+
# just before a window boundary is still published.
|
|
108
|
+
def update_gauge(metric_name, help, lag, now)
|
|
109
|
+
window = (now / GAUGE_WINDOW_SECONDS).to_i
|
|
110
|
+
gauge = @gauges[metric_name]
|
|
111
|
+
if gauge.nil?
|
|
112
|
+
gauge = @gauges[metric_name] = { window: window, max: lag, previous_max: 0.0 }
|
|
113
|
+
elsif window != gauge[:window]
|
|
114
|
+
gauge[:previous_max] = (window == gauge[:window] + 1) ? gauge[:max] : 0.0
|
|
115
|
+
gauge[:window] = window
|
|
116
|
+
gauge[:max] = lag
|
|
117
|
+
elsif lag > gauge[:max]
|
|
118
|
+
gauge[:max] = lag
|
|
119
|
+
else
|
|
120
|
+
return # Published value is unchanged
|
|
121
|
+
end
|
|
122
|
+
value = (gauge[:max] > gauge[:previous_max]) ? gauge[:max] : gauge[:previous_max]
|
|
123
|
+
@metric.set(name: metric_name, value: value, type: 'gauge', unit: 'seconds', help: help)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def update_state(topic, lag, now)
|
|
127
|
+
if lag < @warn_seconds * CLEAR_RATIO
|
|
128
|
+
@below_ok_since ||= now
|
|
129
|
+
else
|
|
130
|
+
@below_ok_since = nil
|
|
131
|
+
end
|
|
132
|
+
if lag > @max_lag
|
|
133
|
+
@max_lag = lag
|
|
134
|
+
@max_lag_topic = topic
|
|
135
|
+
end
|
|
136
|
+
level = if lag >= @critical_seconds
|
|
137
|
+
CRITICAL
|
|
138
|
+
elsif lag >= @warn_seconds
|
|
139
|
+
WARN
|
|
140
|
+
else
|
|
141
|
+
OK
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
if level > @state
|
|
145
|
+
@state = level
|
|
146
|
+
@below_since = nil
|
|
147
|
+
# Bursty load can make a microservice recover and fall behind over and
|
|
148
|
+
# over, so falling back to an already announced level soon after the
|
|
149
|
+
# last notification continues that episode rather than notifying again
|
|
150
|
+
unless @last_notify and level <= @last_notify_level and (now - @last_notify) < @renotify_seconds
|
|
151
|
+
notify_lag(topic, lag, now)
|
|
152
|
+
end
|
|
153
|
+
elsif @state != OK
|
|
154
|
+
threshold = (@state == CRITICAL) ? @critical_seconds : @warn_seconds
|
|
155
|
+
if lag < threshold * CLEAR_RATIO
|
|
156
|
+
@below_since ||= now
|
|
157
|
+
if (now - @below_since) >= @clear_seconds
|
|
158
|
+
@below_since = nil
|
|
159
|
+
if @state == CRITICAL and (@below_ok_since.nil? or (now - @below_ok_since) < @clear_seconds)
|
|
160
|
+
@state = WARN
|
|
161
|
+
@below_since = @below_ok_since
|
|
162
|
+
else
|
|
163
|
+
@state = OK
|
|
164
|
+
@logger.info("#{@name} has caught up processing (max lag #{@max_lag.round(1)}s on #{@max_lag_topic})", type: Logger::NOTIFICATION) if @notified
|
|
165
|
+
@notified = false
|
|
166
|
+
@max_lag = 0.0
|
|
167
|
+
@max_lag_topic = nil
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
else
|
|
171
|
+
@below_since = nil
|
|
172
|
+
notify_lag(topic, lag, now) if (now - @last_notify) >= @renotify_seconds
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def notify_lag(topic, lag, now)
|
|
178
|
+
message = "#{@name} is falling behind processing: #{lag.round(1)}s behind on #{topic} (max #{@max_lag.round(1)}s on #{@max_lag_topic})"
|
|
179
|
+
if @state == CRITICAL
|
|
180
|
+
@logger.error(message, type: Logger::NOTIFICATION)
|
|
181
|
+
else
|
|
182
|
+
@logger.warn(message, type: Logger::NOTIFICATION)
|
|
183
|
+
end
|
|
184
|
+
@last_notify = now
|
|
185
|
+
@last_notify_level = @state
|
|
186
|
+
@notified = true
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def id_ms(id)
|
|
190
|
+
id.to_s.split('-')[0].to_i
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|