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.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +4 -3
  3. data/bin/pipinstall +18 -5
  4. data/bin/pipuninstall +1 -1
  5. data/bin/uvinstall +36 -8
  6. data/data/config/default_plugins.yaml +43 -0
  7. data/data/config/interface_modifiers.yaml +22 -7
  8. data/data/config/item_modifiers.yaml +14 -4
  9. data/data/config/plugins.yaml +1 -1
  10. data/data/config/widgets.yaml +1 -1
  11. data/lib/openc3/api/cmd_api.rb +10 -13
  12. data/lib/openc3/api/interface_api.rb +1 -2
  13. data/lib/openc3/api/limits_api.rb +1 -1
  14. data/lib/openc3/api/metrics_api.rb +26 -1
  15. data/lib/openc3/api/router_api.rb +1 -2
  16. data/lib/openc3/api/tlm_api.rb +21 -21
  17. data/lib/openc3/config/config_parser.rb +3 -1
  18. data/lib/openc3/core_ext/tempfile.rb +2 -0
  19. data/lib/openc3/interfaces/file_interface.rb +6 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  21. data/lib/openc3/interfaces/interface.rb +27 -3
  22. data/lib/openc3/interfaces/protocols/protocol.rb +8 -0
  23. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  24. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  25. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  26. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  27. data/lib/openc3/interfaces.rb +1 -0
  28. data/lib/openc3/io/json_api_object.rb +24 -3
  29. data/lib/openc3/io/json_drb_object.rb +3 -2
  30. data/lib/openc3/logs/log_writer.rb +13 -4
  31. data/lib/openc3/microservices/decom_microservice.rb +7 -4
  32. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  33. data/lib/openc3/microservices/log_microservice.rb +1 -3
  34. data/lib/openc3/microservices/microservice.rb +25 -1
  35. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  36. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  37. data/lib/openc3/models/interface_model.rb +1 -1
  38. data/lib/openc3/models/interface_status_model.rb +6 -0
  39. data/lib/openc3/models/metric_model.rb +52 -4
  40. data/lib/openc3/models/microservice_model.rb +7 -3
  41. data/lib/openc3/models/plugin_model.rb +12 -12
  42. data/lib/openc3/models/python_package_model.rb +3 -13
  43. data/lib/openc3/models/reaction_model.rb +6 -3
  44. data/lib/openc3/models/setting_model.rb +5 -3
  45. data/lib/openc3/models/target_model.rb +83 -9
  46. data/lib/openc3/operators/microservice_operator.rb +5 -0
  47. data/lib/openc3/operators/operator.rb +69 -0
  48. data/lib/openc3/packets/packet.rb +10 -4
  49. data/lib/openc3/packets/packet_config.rb +48 -2
  50. data/lib/openc3/packets/packet_item.rb +27 -14
  51. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  52. data/lib/openc3/packets/parsers/xtce_parser.rb +8 -1
  53. data/lib/openc3/packets/structure_item.rb +3 -2
  54. data/lib/openc3/script/api_shared.rb +58 -84
  55. data/lib/openc3/script/extract.rb +215 -19
  56. data/lib/openc3/script/plugins.rb +4 -11
  57. data/lib/openc3/script/storage.rb +81 -10
  58. data/lib/openc3/script/tables.rb +5 -1
  59. data/lib/openc3/system/system.rb +9 -6
  60. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/command_topic.rb +2 -1
  62. data/lib/openc3/topics/router_topic.rb +2 -1
  63. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  64. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  65. data/lib/openc3/topics/topic.rb +154 -2
  66. data/lib/openc3/utilities/cli_generator.rb +67 -15
  67. data/lib/openc3/utilities/config_overlay.rb +35 -17
  68. data/lib/openc3/utilities/default_plugins.rb +33 -0
  69. data/lib/openc3/utilities/local_mode.rb +82 -46
  70. data/lib/openc3/utilities/metric.rb +16 -0
  71. data/lib/openc3/utilities/process_stats.rb +160 -0
  72. data/lib/openc3/utilities/pypi_url.rb +65 -0
  73. data/lib/openc3/utilities/questdb_client.rb +43 -46
  74. data/lib/openc3/utilities/running_script.rb +22 -18
  75. data/lib/openc3/utilities/store_autoload.rb +20 -3
  76. data/lib/openc3/utilities/store_queued.rb +37 -9
  77. data/lib/openc3/utilities/target_file.rb +57 -12
  78. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  79. data/lib/openc3/version.rb +5 -5
  80. data/templates/conversion/conversion.py +6 -2
  81. data/templates/conversion/conversion.rb +5 -0
  82. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  83. data/templates/plugin/.github/workflows/release.yml +45 -0
  84. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  85. data/templates/plugin/.gitignore +6 -0
  86. data/templates/plugin/README.md +114 -2
  87. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  88. data/templates/plugin/plugin.gemspec +10 -1
  89. data/templates/plugin/pyproject.toml +119 -0
  90. data/templates/tool_angular/package.json +2 -2
  91. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  92. data/templates/tool_react/package.json +1 -1
  93. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  94. data/templates/tool_svelte/package.json +2 -1
  95. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  96. data/templates/tool_vue/package.json +3 -3
  97. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  98. data/templates/widget/package.json +2 -2
  99. data/templates/widget/pnpm-workspace.yaml +16 -0
  100. 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
- db_shard_results = {} # db_shard => data
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
- db_shard_results[db_shard] = result
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
- # If all db_shards returned empty, return empty
955
- return {} if db_shard_results.values.all? { |r| r == {} }
956
-
957
- # Merge results positionally back into the original item order.
958
- # For single-row results (no end_time), merge flat arrays.
959
- # For multi-row results, each db_shard may have different row counts;
960
- # use the maximum row count and fill missing positions with [nil, nil].
961
- if !end_time
962
- # Single-row mode: each db_shard returns a flat array of [value, limits] pairs.
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] = result[db_shard_idx] if result[db_shard_idx]
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
- def self.tsdb_lookup_single_db_shard(items, start_time:, end_time: nil, scope: "DEFAULT", db_shard: 0)
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
- bucket = input['bucket']
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
- start(RunningScript.instance.current_filename, line_no: line_no_or_procedure_name, bind_variables: true, complete: true)
269
+ procedure_name = RunningScript.instance.current_filename
270
+ line_no = line_no_or_procedure_name
279
271
  else
280
- start(line_no_or_procedure_name, line_no: line_no, bind_variables: true, complete: true)
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
- def execute_while_paused(filename, line_no = 1, end_line_no = nil)
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
- if @script_status.current_filename == @execute_while_paused_info[:filename]
1221
- bind_variables = true
1222
- else
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
- return redis.xadd(topic, msg_hash, id: id, maxlen: maxlen, approximate: approximate)
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
- # Sets the update interval to 1 second by default
29
- def self.instance(update_interval = 1, db_shard: 0) # seconds
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
- # Drain the queue before shutdown
106
- begin
107
- process_queue()
108
- rescue
109
- # Best effort - Redis may already be unavailable during shutdown
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
- @store_queue.push(MessageStruct.new(message, args, kwargs, block))
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
- result, _ = remote_target_files(bucket_client: bucket, prefix: prefix, include_temp: false, path_matchers: path_matchers)
50
- modified, temp = remote_target_files(bucket_client: bucket, prefix: modified_prefix, include_temp: include_temp, path_matchers: path_matchers)
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 include_temp and filename.include?(TEMP_FOLDER)
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
- local_file = OpenC3::LocalMode.open_local_file(name, scope: scope)
106
- if local_file
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