openc3 7.0.0.pre.rc3 → 7.0.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/data/config/interface_modifiers.yaml +1 -1
  3. data/data/config/item_modifiers.yaml +18 -6
  4. data/data/config/telemetry.yaml +1 -1
  5. data/lib/openc3/accessors/json_accessor.rb +1 -1
  6. data/lib/openc3/api/tlm_api.rb +3 -3
  7. data/lib/openc3/config/config_parser.rb +4 -4
  8. data/lib/openc3/conversions/conversion.rb +3 -3
  9. data/lib/openc3/core_ext/faraday.rb +4 -0
  10. data/lib/openc3/logs/log_writer.rb +24 -6
  11. data/lib/openc3/logs/packet_log_writer.rb +1 -4
  12. data/lib/openc3/logs/stream_log_pair.rb +11 -4
  13. data/lib/openc3/logs/text_log_writer.rb +1 -4
  14. data/lib/openc3/microservices/interface_microservice.rb +8 -2
  15. data/lib/openc3/microservices/log_microservice.rb +7 -2
  16. data/lib/openc3/microservices/microservice.rb +10 -4
  17. data/lib/openc3/microservices/queue_microservice.rb +3 -0
  18. data/lib/openc3/microservices/scope_cleanup_microservice.rb +116 -1
  19. data/lib/openc3/microservices/text_log_microservice.rb +4 -1
  20. data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +2 -0
  21. data/lib/openc3/models/activity_model.rb +15 -3
  22. data/lib/openc3/models/cvt_model.rb +2 -247
  23. data/lib/openc3/models/plugin_store_model.rb +1 -1
  24. data/lib/openc3/models/script_engine_model.rb +1 -1
  25. data/lib/openc3/models/target_model.rb +32 -34
  26. data/lib/openc3/models/tool_model.rb +18 -5
  27. data/lib/openc3/models/trigger_model.rb +1 -1
  28. data/lib/openc3/models/widget_model.rb +1 -2
  29. data/lib/openc3/operators/operator.rb +9 -7
  30. data/lib/openc3/packets/json_packet.rb +2 -0
  31. data/lib/openc3/packets/packet.rb +1 -0
  32. data/lib/openc3/packets/packet_config.rb +28 -12
  33. data/lib/openc3/script/calendar.rb +8 -0
  34. data/lib/openc3/script/script.rb +19 -0
  35. data/lib/openc3/script/storage.rb +6 -6
  36. data/lib/openc3/system/system.rb +6 -6
  37. data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +0 -2
  38. data/lib/openc3/top_level.rb +15 -63
  39. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  40. data/lib/openc3/utilities/bucket_utilities.rb +3 -1
  41. data/lib/openc3/utilities/cli_generator.rb +7 -0
  42. data/lib/openc3/utilities/cmd_log.rb +1 -1
  43. data/lib/openc3/utilities/local_mode.rb +3 -0
  44. data/lib/openc3/utilities/process_manager.rb +1 -1
  45. data/lib/openc3/utilities/python_proxy.rb +11 -4
  46. data/lib/openc3/utilities/questdb_client.rb +735 -19
  47. data/lib/openc3/utilities/running_script.rb +25 -7
  48. data/lib/openc3/utilities/script.rb +452 -0
  49. data/lib/openc3/utilities/secrets.rb +1 -1
  50. data/lib/openc3/version.rb +5 -5
  51. data/templates/conversion/conversion.py +0 -8
  52. data/templates/conversion/conversion.rb +0 -11
  53. data/templates/tool_angular/package.json +2 -2
  54. data/templates/tool_react/package.json +1 -1
  55. data/templates/tool_svelte/package.json +1 -1
  56. data/templates/tool_vue/package.json +3 -3
  57. data/templates/widget/package.json +2 -2
  58. metadata +16 -2
  59. data/lib/openc3/migrations/20251022000000_remove_unique_id.rb +0 -23
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 920a681652723ab469d3311e6f28332ea202068300c3a506d3314c552f5c0f30
4
- data.tar.gz: 2f35277fa1e25eb30646a27e8f45365cb2fd39d7d3b24758baabf004e9fe54b5
3
+ metadata.gz: 0a06901f01bc9d8e7b6f0b123296da67475c08ea9cf50ef788a86b6da1308fcb
4
+ data.tar.gz: 34732b912cea1b4677f3e94ac0ba9f437e99217ccf54f9250816176ef5b63db1
5
5
  SHA512:
6
- metadata.gz: 1cd5cb567a743e90742e76db3b4ab8498f34d8b6ba6dc0032e351bb6e3fdfbc4f8db826cc907d500f4670ef461bd49193be74e8264648b6a0f5908d090b7e6cf
7
- data.tar.gz: 9af5921a88413af21113b00c0f99dcc023e70c21b4dfc9e7f0f8840fbe226765f86d80e82a7b874458468c45cea590a3e6b0ab2d702d29613818e19e48b473be
6
+ metadata.gz: 5cda606ccb010606ad4b432685f53428cbf10b216f1cd06f2f55baac28fb723514dc3f8dfeadafc0958734237d3a7faf3f42439695481145914434e2a4bb1a58
7
+ data.tar.gz: 98532ea424c421554d45a6d155fadfc27e67ddf969a96be7653daebb30d5530dbb7601e13b5d3a3afa7dfd56718c161c060e81d67a5ee967a9248225554e1a03
@@ -53,7 +53,7 @@ RECONNECT_DELAY:
53
53
  parameters:
54
54
  - name: Delay
55
55
  required: true
56
- description: Delay in seconds between reconnect attempts. The default is 15 seconds.
56
+ description: Delay in seconds between reconnect attempts. The default is 5 seconds.
57
57
  values: ([0-9]*[.])?[0-9]+
58
58
  DISABLE_DISCONNECT:
59
59
  summary: Disable the Disconnect button on the Interfaces tab in the Server
@@ -72,7 +72,8 @@ GENERIC_READ_CONVERSION_START:
72
72
  class (Note, referencing the packet as 'myself' is still supported for backwards
73
73
  compatibility). The last line of code should return the converted
74
74
  value. The GENERIC_READ_CONVERSION_END keyword specifies that all lines of
75
- code for the conversion have been given.
75
+ code for the conversion have been given. To specify the bit size, type, and array size of the converted data,
76
+ use the CONVERTED_DATA keyword.
76
77
  warning: Generic conversions are not a good long term solution. Consider creating
77
78
  a conversion class and using READ_CONVERSION instead. READ_CONVERSION is easier
78
79
  to debug and has higher performance.
@@ -81,22 +82,33 @@ GENERIC_READ_CONVERSION_START:
81
82
  GENERIC_READ_CONVERSION_START
82
83
  (value * 1.5).to_i # Convert the value by a scale factor
83
84
  GENERIC_READ_CONVERSION_END
85
+ CONVERTED_DATA 32 UINT
84
86
  python_example: |
85
87
  APPEND_ITEM ITEM1 32 UINT
86
88
  GENERIC_READ_CONVERSION_START
87
89
  int(value * 1.5) # Convert the value by a scale factor
88
90
  GENERIC_READ_CONVERSION_END
91
+ CONVERTED_DATA 32 UINT
92
+ GENERIC_READ_CONVERSION_END:
93
+ summary: Complete a generic read conversion
94
+ CONVERTED_DATA:
95
+ summary: Defines the bit size, type, and array size of the converted data for a read conversion
96
+ description: This keyword is used in conjunction with DERIVED items to specify the bit size, type, and array size of the converted data.
97
+ If this keyword is not used, DERIVED items are stored as strings in the decommutated data.
98
+ since: 7.0.0
89
99
  parameters:
100
+ - name: Converted Bit Size
101
+ required: true
102
+ description: Bit size of converted value
103
+ values: \d+
90
104
  - name: Converted Type
91
- required: false
105
+ required: true
92
106
  description: Type of the converted value
93
107
  values: <%= %w(INT UINT FLOAT STRING BLOCK) %>
94
- - name: Converted Bit Size
108
+ - name: Converted Array Size
95
109
  required: false
96
- description: Bit size of converted value
110
+ description: Bit size of the total array if the converted value is an array. Only specified if the converted type is an array.
97
111
  values: \d+
98
- GENERIC_READ_CONVERSION_END:
99
- summary: Complete a generic read conversion
100
112
  LIMITS:
101
113
  summary: Defines a set of limits for a telemetry item
102
114
  description: If limits are violated a message is printed in the Command and Telemetry Server
@@ -10,7 +10,7 @@ TELEMETRY:
10
10
  required: true
11
11
  description: Name of the target this telemetry packet is associated with
12
12
  values: .+
13
- - name: Command
13
+ - name: Packet
14
14
  required: true
15
15
  description:
16
16
  Name of this telemetry packet. Also referred to as its mnemonic.
@@ -20,7 +20,7 @@ require 'openc3/accessors/accessor'
20
20
  OpenC3.disable_warnings do
21
21
  class JsonPath
22
22
  def self.process_object(obj_or_str, opts = {})
23
- obj_or_str.is_a?(String) ? MultiJson.load(obj_or_str, max_nesting: opts[:max_nesting], create_additions: true, allow_nan: true) : obj_or_str
23
+ obj_or_str.is_a?(String) ? JSON.parse(obj_or_str, max_nesting: opts[:max_nesting], create_additions: true, allow_nan: true) : obj_or_str
24
24
  end
25
25
  end
26
26
  end
@@ -23,7 +23,7 @@
23
23
 
24
24
  require 'openc3/models/target_model'
25
25
  require 'openc3/models/cvt_model'
26
- require 'openc3/packets/packet'
26
+ # require 'openc3/packets/packet' # Circular require
27
27
  require 'openc3/topics/telemetry_topic'
28
28
  require 'openc3/topics/interface_topic'
29
29
  require 'openc3/topics/decom_interface_topic'
@@ -281,7 +281,7 @@ module OpenC3
281
281
 
282
282
  case value_type
283
283
  when 'FORMATTED', 'WITH_UNITS'
284
- if item['format_string']
284
+ if item['format_string'] or item['units']
285
285
  results << [target_name, orig_packet_name, item_name, 'FORMATTED'].join('__')
286
286
  # This logic must match the logic in Packet#decom
287
287
  elsif item['states'] or (item['read_conversion'] and item['data_type'] != 'DERIVED')
@@ -304,7 +304,7 @@ module OpenC3
304
304
  if item['limits']['DEFAULT']
305
305
  results[-1] += '__LIMITS'
306
306
  end
307
- rescue RuntimeError => e
307
+ rescue RuntimeError
308
308
  results << nil
309
309
  end
310
310
  end
@@ -15,10 +15,11 @@
15
15
  # This file may also be used under the terms of a commercial license
16
16
  # if purchased from OpenC3, Inc.
17
17
 
18
- require 'openc3/top_level'
18
+ # require 'openc3/top_level' # Circular require
19
19
  require 'openc3/ext/config_parser' if RUBY_ENGINE == 'ruby' and !ENV['OPENC3_NO_EXT']
20
20
  require 'erb'
21
21
  require 'fileutils'
22
+ require 'tempfile'
22
23
 
23
24
  module OpenC3
24
25
  # Reads OpenC3 style configuration data which consists of keywords followed
@@ -218,6 +219,7 @@ module OpenC3
218
219
  &)
219
220
  ensure
220
221
  file.close unless file.closed?
222
+ file.unlink
221
223
  end
222
224
  end
223
225
 
@@ -417,9 +419,7 @@ module OpenC3
417
419
  elsif copy.include?(':') # Check for Windows drive letter
418
420
  copy = copy.split(':')[1]
419
421
  end
420
- parsed_filename = File.join(Dir.tmpdir, 'openc3', 'tmp', copy)
421
- FileUtils.mkdir_p(File.dirname(parsed_filename)) # Create the path
422
- file = File.open(parsed_filename, 'w+')
422
+ file = Tempfile.new(copy)
423
423
  file.puts output
424
424
  file.rewind # Rewind so the file is ready to read
425
425
  file
@@ -20,11 +20,11 @@ module OpenC3
20
20
  class Conversion
21
21
  # @return [Symbol] The converted data type. Must be one of
22
22
  # {OpenC3::StructureItem#data_type}
23
- attr_reader :converted_type
23
+ attr_accessor :converted_type
24
24
  # @return [Integer] The size in bits of the converted value
25
- attr_reader :converted_bit_size
25
+ attr_accessor :converted_bit_size
26
26
  # @return [Integer] The size in bits of the converted array value
27
- attr_reader :converted_array_size
27
+ attr_accessor :converted_array_size
28
28
  # @return [Array] The arguments passed to the conversion
29
29
  attr_reader :params
30
30
 
@@ -1,4 +1,8 @@
1
+ # Remove warnings in CGI
2
+ saved_verbose = $VERBOSE
3
+ $VERBOSE = false
1
4
  require 'faraday'
5
+ $VERBOSE = saved_verbose
2
6
 
3
7
  module Faraday
4
8
  class Response
@@ -126,6 +126,7 @@ module OpenC3
126
126
  @cleanup_times = []
127
127
  @previous_time_nsec_since_epoch = nil
128
128
  @tmp_dir = Dir.mktmpdir
129
+ @wait_threads = []
129
130
 
130
131
  # This is an optimization to avoid creating a new entry object
131
132
  # each time we create an entry which we do a LOT!
@@ -154,9 +155,8 @@ module OpenC3
154
155
 
155
156
  # Stops all logging and closes the current log file.
156
157
  def stop
157
- threads = nil
158
- @mutex.synchronize { threads = close_file(false); @logging_enabled = false; }
159
- return threads
158
+ @mutex.synchronize { close_file(false); @logging_enabled = false; }
159
+ return @wait_threads
160
160
  end
161
161
 
162
162
  # Stop all logging, close the current log file, and kill the logging threads.
@@ -173,6 +173,13 @@ module OpenC3
173
173
  return threads
174
174
  end
175
175
 
176
+ def cleanup
177
+ if @tmp_dir
178
+ FileUtils.remove_entry_secure(@tmp_dir, true)
179
+ @tmp_dir = nil
180
+ end
181
+ end
182
+
176
183
  def graceful_kill
177
184
  @cancel_threads = true
178
185
  end
@@ -307,8 +314,19 @@ module OpenC3
307
314
  # to keep a full file's worth of data in the stream. This is what prevents continuous stream growth.
308
315
  # Returns thread that moves log to bucket
309
316
  def close_file(take_mutex = true)
310
- threads = []
311
317
  @mutex.lock if take_mutex
318
+
319
+ # Remove old wait_threads
320
+ to_remove = []
321
+ @wait_threads.each do |thread|
322
+ unless thread.alive?
323
+ to_remove << thread
324
+ end
325
+ end
326
+ to_remove.each do |thread|
327
+ @wait_threads.delete(thread)
328
+ end
329
+
312
330
  begin
313
331
  if @file
314
332
  begin
@@ -322,7 +340,7 @@ module OpenC3
322
340
  # Cleanup timestamps here so they are unset for the next file
323
341
  @first_time = nil
324
342
  @last_time = nil
325
- threads << BucketUtilities.move_log_file_to_bucket(@filename, bucket_key)
343
+ @wait_threads << BucketUtilities.move_log_file_to_bucket(@filename, bucket_key)
326
344
  # Now that the file is in storage, trim the Redis stream after a delay
327
345
  @cleanup_offsets << {}
328
346
  @last_offsets.each do |redis_topic, last_offset|
@@ -342,7 +360,7 @@ module OpenC3
342
360
  ensure
343
361
  @mutex.unlock if take_mutex
344
362
  end
345
- return threads
363
+ return @wait_threads
346
364
  end
347
365
 
348
366
  def bucket_filename
@@ -137,7 +137,6 @@ module OpenC3
137
137
  # Closing a log file isn't critical so we just log an error
138
138
  # Returns threads that moves log to bucket
139
139
  def close_file(take_mutex = true)
140
- threads = []
141
140
  @mutex.lock if take_mutex
142
141
  begin
143
142
  # Need to write the OFFSET_MARKER for each packet
@@ -145,12 +144,10 @@ module OpenC3
145
144
  write_entry(:OFFSET_MARKER, nil, nil, nil, nil, nil, last_offset + ',' + redis_topic, nil) if @file
146
145
  end
147
146
 
148
- threads.concat(super(false))
149
-
147
+ return super(false)
150
148
  ensure
151
149
  @mutex.unlock if take_mutex
152
150
  end
153
- return threads
154
151
  end
155
152
 
156
153
  def get_packet_index(cmd_or_tlm, target_name, packet_name, entry_type, data)
@@ -43,13 +43,20 @@ module OpenC3
43
43
 
44
44
  # Close any open stream log files
45
45
  def stop
46
- @read_log.stop
47
- @write_log.stop
46
+ threads = @read_log.stop
47
+ threads.concat(@write_log.stop)
48
+ return threads
48
49
  end
49
50
 
50
51
  def shutdown
51
- @read_log.shutdown
52
- @write_log.shutdown
52
+ threads = @read_log.shutdown
53
+ threads.concat(@write_log.shutdown)
54
+ return threads
55
+ end
56
+
57
+ def cleanup
58
+ @read_log.cleanup
59
+ @write_log.cleanup
53
60
  end
54
61
 
55
62
  # Clone the stream log pair
@@ -68,7 +68,6 @@ module OpenC3
68
68
  # Closing a log file isn't critical so we just log an error
69
69
  # Returns threads that moves log to bucket
70
70
  def close_file(take_mutex = true)
71
- threads = []
72
71
  @mutex.lock if take_mutex
73
72
  begin
74
73
  # Need to write the OFFSET_MARKER for each packet
@@ -79,12 +78,10 @@ module OpenC3
79
78
  write_entry(time.to_nsec_from_epoch, data.as_json(allow_nan: true).to_json(allow_nan: true)) if @file
80
79
  end
81
80
 
82
- threads.concat(super(false))
83
-
81
+ return super(false)
84
82
  ensure
85
83
  @mutex.unlock if take_mutex
86
84
  end
87
- return threads
88
85
  end
89
86
 
90
87
  def extension
@@ -779,7 +779,6 @@ module OpenC3
779
779
  else
780
780
  @logger.error "#{@interface.name}: #{connect_error.formatted}"
781
781
  unless @connection_failed_messages.include?(connect_error.message)
782
- OpenC3.write_exception_file(connect_error)
783
782
  @connection_failed_messages << connect_error.message
784
783
  end
785
784
  end
@@ -800,7 +799,6 @@ module OpenC3
800
799
  else
801
800
  @logger.error "#{@interface.name}: #{err.formatted}"
802
801
  unless @connection_lost_messages.include?(err.message)
803
- OpenC3.write_exception_file(err)
804
802
  @connection_lost_messages << err.message
805
803
  end
806
804
  end
@@ -888,6 +886,14 @@ module OpenC3
888
886
  def shutdown(_sig = nil)
889
887
  @logger.info "#{@interface ? @interface.name : @name}: shutdown requested"
890
888
  stop()
889
+ if @interface and @interface.stream_log_pair
890
+ threads = @interface.stream_log_pair.shutdown
891
+ # Wait for all the logging threads to move files to buckets
892
+ threads.flatten.compact.each do |thread|
893
+ thread.join
894
+ end
895
+ @interface.stream_log_pair.cleanup
896
+ end
891
897
  super()
892
898
  end
893
899
 
@@ -125,8 +125,8 @@ module OpenC3
125
125
  def shutdown
126
126
  # Make sure all the existing logs are properly closed down
127
127
  threads = []
128
- @plws.each do |target_name, plw_hash|
129
- plw_hash.each do |type, plw|
128
+ @plws.each do |_target_name, plw_hash|
129
+ plw_hash.each do |_type, plw|
130
130
  threads.concat(plw.shutdown)
131
131
  end
132
132
  end
@@ -134,6 +134,11 @@ module OpenC3
134
134
  threads.flatten.compact.each do |thread|
135
135
  thread.join
136
136
  end
137
+ @plws.each do |_target_name, plw_hash|
138
+ plw_hash.each do |_type, plw|
139
+ plw.cleanup
140
+ end
141
+ end
137
142
  super()
138
143
  end
139
144
  end
@@ -88,6 +88,7 @@ module OpenC3
88
88
  @name = name
89
89
  split_name = name.split("__")
90
90
  raise "Name #{name} doesn't match convention of SCOPE__TYPE__NAME" if split_name.length != 3
91
+ microservice_type = split_name[1].to_s.upcase
91
92
 
92
93
  @scope = split_name[0]
93
94
  $openc3_scope = @scope
@@ -102,8 +103,14 @@ module OpenC3
102
103
 
103
104
  OpenC3.setup_open_telemetry(@name, false)
104
105
 
106
+ @temp_dir = OpenC3.sanitize_path(File.join(Dir.tmpdir, @name))
107
+
105
108
  # Create temp folder for this microservice
106
- @temp_dir = Dir.mktmpdir
109
+ # This will already have been setup by plugin_microservice.rb if USER
110
+ if is_plugin or microservice_type != 'USER'
111
+ FileUtils.remove_entry_secure(@temp_dir, true)
112
+ Dir.mkdir(@temp_dir)
113
+ end
107
114
 
108
115
  # Get microservice configuration from Redis
109
116
  @config = MicroserviceModel.get(name: @name, scope: @scope)
@@ -142,14 +149,13 @@ module OpenC3
142
149
  cmd_array = @config["cmd"]
143
150
 
144
151
  # Get Microservice files from bucket storage
145
- temp_dir = Dir.mktmpdir
146
152
  bucket = ENV['OPENC3_CONFIG_BUCKET']
147
153
  client = Bucket.getClient()
148
154
 
149
155
  prefix = "#{@scope}/microservices/#{@name}/"
150
156
  file_count = 0
151
157
  client.list_objects(bucket: bucket, prefix: prefix).each do |object|
152
- response_target = File.join(temp_dir, object.key.split(prefix)[-1])
158
+ response_target = OpenC3.sanitize_path(File.join(@temp_dir, object.key.split(prefix)[-1]))
153
159
  FileUtils.mkdir_p(File.dirname(response_target))
154
160
  client.get_object(bucket: bucket, key: object.key, path: response_target)
155
161
  file_count += 1
@@ -157,7 +163,7 @@ module OpenC3
157
163
 
158
164
  # Adjust @work_dir to microservice files downloaded if files and a relative path
159
165
  if file_count > 0 and @work_dir[0] != '/'
160
- @work_dir = File.join(temp_dir, @work_dir)
166
+ @work_dir = OpenC3.sanitize_path(File.join(@temp_dir, @work_dir))
161
167
  end
162
168
 
163
169
  # Check Syntax on any ruby files
@@ -18,6 +18,8 @@ require 'openc3/utilities/authentication'
18
18
  require 'openc3/api/api'
19
19
 
20
20
  module OpenC3
21
+ saved_verbose = $VERBOSE
22
+ $VERBOSE = false
21
23
  module Script
22
24
  private
23
25
  # Override the prompt_for_hazardous method to always return true since there is no user to prompt
@@ -25,6 +27,7 @@ module OpenC3
25
27
  return true
26
28
  end
27
29
  end
30
+ $VERBOSE = saved_verbose
28
31
 
29
32
  # The queue processor runs in a single thread and processes commands via cmd_api.
30
33
  class QueueProcessor
@@ -16,6 +16,120 @@ require 'openc3/microservices/cleanup_microservice'
16
16
 
17
17
  module OpenC3
18
18
  class ScopeCleanupMicroservice < CleanupMicroservice
19
+ TSDB_HEALTH_QUERY =
20
+ "SELECT
21
+ table_name,
22
+ table_row_count,
23
+ wal_pending_row_count,
24
+ CASE
25
+ WHEN table_suspended THEN 'SUSPENDED'
26
+ WHEN table_memory_pressure_level = 2 THEN 'BACKOFF'
27
+ WHEN table_memory_pressure_level = 1 THEN 'PRESSURE'
28
+ ELSE 'OK'
29
+ END AS status,
30
+ wal_txn - table_txn AS lag_txns,
31
+ table_write_amp_p50 AS write_amp,
32
+ table_merge_rate_p99 AS slowest_merge
33
+ FROM tables()
34
+ WHERE walEnabled
35
+ ORDER BY
36
+ table_suspended DESC,
37
+ table_memory_pressure_level DESC,
38
+ wal_pending_row_count DESC;"
39
+
40
+ GROWTH_NUM_SAMPLE_PERIODS = 4
41
+
42
+ def initialize(*args)
43
+ super(*args)
44
+ @run_time = nil
45
+ @cleanup_poll_time = nil
46
+ @delta_time = 0.0
47
+ @wal_pending_row_count = {}
48
+ @lag_txns = {}
49
+ end
50
+
51
+ def cleanup(areas, bucket)
52
+ current_time = Time.now
53
+ if @run_time
54
+ delta = current_time - @run_time
55
+ if delta > 0.0
56
+ @delta_time += delta
57
+ end
58
+ end
59
+ @run_time = current_time
60
+ if @delta_time > @cleanup_poll_time
61
+ @delta_time = 0.0
62
+ super(areas, bucket)
63
+ end
64
+
65
+ # Always check TSDB health
66
+ if @scope == 'DEFAULT'
67
+ begin
68
+ conn = OpenC3::QuestDBClient.connection
69
+ result = conn.exec(TSDB_HEALTH_QUERY)
70
+ columns = result.fields
71
+ rows = result.values
72
+
73
+ table_name_column = columns.index("table_name")
74
+ wal_pending_row_count_column = columns.index("wal_pending_row_count")
75
+ status_column = columns.index("status")
76
+ lag_txns_column = columns.index("lag_txns")
77
+
78
+ rows.each do |values|
79
+ table_name = values[table_name_column]
80
+ wal_pending_row_count = values[wal_pending_row_count_column].to_i
81
+ status = values[status_column]
82
+ lag_txns = values[lag_txns_column].to_i
83
+
84
+ if status != 'OK'
85
+ @logger.error("QuestDB: #{table_name} in bad state: #{status}")
86
+
87
+ if status == 'SUSPENDED'
88
+ # Try to automatically unsuspend
89
+ @logger.info("QuestDB: Attempting to unsuspend: #{table_name}")
90
+ conn.exec("ALTER TABLE #{table_name} RESUME WAL;")
91
+ end
92
+ end
93
+
94
+ @wal_pending_row_count[table_name] ||= []
95
+ @wal_pending_row_count[table_name] << wal_pending_row_count
96
+ @lag_txns[table_name] ||= []
97
+ @lag_txns[table_name] << lag_txns
98
+
99
+ if @wal_pending_row_count[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
100
+ if detect_growth(@wal_pending_row_count[table_name], GROWTH_NUM_SAMPLE_PERIODS)
101
+ # Crossed threshold of sample periods of growth
102
+ @logger.error("QuestDB: #{table_name} has growing wal_pending_row_count: #{wal_pending_row_count}")
103
+ end
104
+
105
+ # Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
106
+ @wal_pending_row_count[table_name] = @wal_pending_row_count[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
107
+ end
108
+
109
+ if @lag_txns[table_name].length > GROWTH_NUM_SAMPLE_PERIODS
110
+ if detect_growth(@lag_txns[table_name], GROWTH_NUM_SAMPLE_PERIODS)
111
+ # Crossed threshold of sample periods of growth
112
+ @logger.error("QuestDB: #{table_name} has growing lag_txns: #{lag_txns}")
113
+ end
114
+
115
+ # Leave the last GROWTH_NUM_SAMPLE_PERIODS samples
116
+ @lag_txns[table_name] = @lag_txns[table_name][-GROWTH_NUM_SAMPLE_PERIODS..-1]
117
+ end
118
+ end
119
+ rescue => e
120
+ OpenC3::QuestDBClient.disconnect
121
+ @logger.error("QuestDB Error: #{e.formatted}")
122
+ end
123
+ end
124
+ end
125
+
126
+ def detect_growth(array, num_samples)
127
+ num_samples.times do |index|
128
+ return false if array[index + 1] <= array[index]
129
+ end
130
+ return true
131
+ end
132
+
19
133
  def get_areas_and_poll_time
20
134
  scope = ScopeModel.get_model(name: @scope)
21
135
  areas = [
@@ -28,7 +142,8 @@ module OpenC3
28
142
  areas << ["NOSCOPE/tool_logs/sr", scope.tool_log_retain_time]
29
143
  end
30
144
 
31
- return areas, scope.cleanup_poll_time
145
+ @cleanup_poll_time = scope.cleanup_poll_time
146
+ return areas, 60 # Run every 1 minute for TSDB checks
32
147
  end
33
148
  end
34
149
  end
@@ -98,13 +98,16 @@ module OpenC3
98
98
  def shutdown
99
99
  # Make sure all the existing logs are properly closed down
100
100
  threads = []
101
- @tlws.each do |topic, tlw|
101
+ @tlws.each do |_topic, tlw|
102
102
  threads.concat(tlw.shutdown)
103
103
  end
104
104
  # Wait for all the logging threads to move files to buckets
105
105
  threads.flatten.compact.each do |thread|
106
106
  thread.join
107
107
  end
108
+ @tlws.each do |_topic, tlw|
109
+ tlw.cleanup
110
+ end
108
111
  super()
109
112
  end
110
113
  end
@@ -23,6 +23,8 @@ module OpenC3
23
23
  target_models = TargetModel.all(scope: scope)
24
24
  target_models.each do |name, target_model|
25
25
  # Remove deprecated decom log settings from target model
26
+ target_model.delete("cmd_unique_id_mode")
27
+ target_model.delete("tlm_unique_id_mode")
26
28
  target_model.delete("cmd_decom_log_cycle_time")
27
29
  target_model.delete("cmd_decom_log_cycle_size")
28
30
  target_model.delete("cmd_decom_log_retain_time")
@@ -18,6 +18,7 @@
18
18
  # https://www.rubydoc.info/gems/redis/Redis/Commands/SortedSets
19
19
 
20
20
  require 'openc3/models/model'
21
+ require 'openc3/models/timeline_model'
21
22
  require 'openc3/topics/timeline_topic'
22
23
  require 'securerandom'
23
24
 
@@ -28,6 +29,11 @@ module OpenC3
28
29
 
29
30
  class ActivityModel < Model
30
31
  MAX_DURATION = Time::SEC_PER_DAY
32
+ # Grace window (in seconds) to allow creating activities slightly in the past.
33
+ # This handles race conditions where real-time activity notifications arrive
34
+ # after the start time has already passed (e.g. from external systems).
35
+ # This is consistent with the -15 second window in the timeline microservice.
36
+ START_GRACE_SECONDS = 15
31
37
  PRIMARY_KEY = '__openc3_timelines'.freeze # MUST be equal to `TimelineModel::PRIMARY_KEY` minus the leading __
32
38
  # See run_activity(activity) in openc3/lib/openc3/microservices/timeline_microservice.rb
33
39
  VALID_KINDS = %w(command script reserve expire)
@@ -212,7 +218,7 @@ module OpenC3
212
218
  end
213
219
 
214
220
  # validate the input to the rules we have created for timelines.
215
- # - A task's start MUST NOT be in the past.
221
+ # - A task's start MUST NOT be more than START_GRACE_SECONDS in the past.
216
222
  # - A task's start MUST be before the stop.
217
223
  # - A task CAN NOT be longer than MAX_DURATION (86400) in seconds.
218
224
  # - A task MUST have a kind.
@@ -230,8 +236,8 @@ module OpenC3
230
236
  rescue NoMethodError
231
237
  raise ActivityInputError.new "start and stop must be seconds: #{start}, #{stop}"
232
238
  end
233
- if now_f >= start and kind != 'expire'
234
- raise ActivityInputError.new "activity must be in the future, current_time: #{now_f} vs #{start}"
239
+ if now_f >= start + START_GRACE_SECONDS and kind != 'expire'
240
+ raise ActivityInputError.new "activity must not be more than #{START_GRACE_SECONDS} seconds in the past, current_time: #{now_f} vs #{start}"
235
241
  elsif duration > MAX_DURATION and kind != 'expire'
236
242
  raise ActivityInputError.new "activity can not be longer than #{MAX_DURATION} seconds"
237
243
  elsif duration <= 0
@@ -262,6 +268,12 @@ module OpenC3
262
268
  # Update the Redis hash at primary_key and set the score equal to the start Epoch time
263
269
  # the member is set to the JSON generated via calling as_json
264
270
  def create(overlap: true, username: nil)
271
+ # Validate that the timeline exists in this scope before creating activities.
272
+ # Activities must be attached to an existing timeline within the same scope.
273
+ unless TimelineModel.get(name: @name, scope: @scope)
274
+ raise ActivityError.new "timeline '#{@name}' does not exist in scope '#{@scope}'"
275
+ end
276
+
265
277
  if @recurring['end'] and @recurring['frequency'] and @recurring['span']
266
278
  # First validate the initial recurring activity ... all others are just offsets
267
279
  validate_input(start: @start, stop: @stop, kind: @kind, data: @data)