openc3 7.4.1 → 7.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/bin/pipinstall +18 -5
  3. data/bin/pipuninstall +1 -1
  4. data/bin/uvinstall +36 -8
  5. data/data/config/default_plugins.yaml +43 -0
  6. data/data/config/interface_modifiers.yaml +22 -7
  7. data/data/config/item_modifiers.yaml +14 -4
  8. data/data/config/plugins.yaml +1 -1
  9. data/data/config/widgets.yaml +1 -1
  10. data/lib/openc3/api/cmd_api.rb +10 -13
  11. data/lib/openc3/api/interface_api.rb +1 -2
  12. data/lib/openc3/api/limits_api.rb +149 -160
  13. data/lib/openc3/api/metrics_api.rb +26 -1
  14. data/lib/openc3/api/router_api.rb +1 -2
  15. data/lib/openc3/api/tlm_api.rb +225 -71
  16. data/lib/openc3/config/config_parser.rb +3 -1
  17. data/lib/openc3/core_ext/tempfile.rb +2 -0
  18. data/lib/openc3/interfaces/file_interface.rb +6 -1
  19. data/lib/openc3/interfaces/http_client_interface.rb +14 -6
  20. data/lib/openc3/interfaces/interface.rb +27 -3
  21. data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
  22. data/lib/openc3/interfaces/stream_interface.rb +22 -3
  23. data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
  24. data/lib/openc3/interfaces/udp_interface.rb +16 -2
  25. data/lib/openc3/interfaces.rb +1 -0
  26. data/lib/openc3/logs/log_writer.rb +14 -4
  27. data/lib/openc3/microservices/decom_microservice.rb +30 -5
  28. data/lib/openc3/microservices/interface_decom_common.rb +3 -1
  29. data/lib/openc3/microservices/interface_microservice.rb +146 -50
  30. data/lib/openc3/microservices/log_microservice.rb +1 -3
  31. data/lib/openc3/microservices/microservice.rb +25 -1
  32. data/lib/openc3/microservices/plugin_microservice.rb +32 -0
  33. data/lib/openc3/microservices/text_log_microservice.rb +1 -3
  34. data/lib/openc3/models/cvt_model.rb +7 -2
  35. data/lib/openc3/models/interface_model.rb +1 -1
  36. data/lib/openc3/models/interface_status_model.rb +6 -0
  37. data/lib/openc3/models/metric_model.rb +52 -4
  38. data/lib/openc3/models/microservice_model.rb +7 -3
  39. data/lib/openc3/models/plugin_model.rb +12 -12
  40. data/lib/openc3/models/python_package_model.rb +3 -13
  41. data/lib/openc3/models/target_model.rb +92 -15
  42. data/lib/openc3/operators/microservice_operator.rb +5 -0
  43. data/lib/openc3/operators/operator.rb +69 -0
  44. data/lib/openc3/packets/packet.rb +10 -4
  45. data/lib/openc3/packets/packet_config.rb +12 -1
  46. data/lib/openc3/packets/packet_item.rb +40 -22
  47. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  48. data/lib/openc3/packets/structure_item.rb +3 -2
  49. data/lib/openc3/script/api_shared.rb +62 -86
  50. data/lib/openc3/script/extract.rb +263 -20
  51. data/lib/openc3/script/plugins.rb +4 -11
  52. data/lib/openc3/script/script.rb +1 -1
  53. data/lib/openc3/script/storage.rb +81 -10
  54. data/lib/openc3/system/system.rb +9 -6
  55. data/lib/openc3/topics/command_decom_topic.rb +1 -1
  56. data/lib/openc3/topics/command_topic.rb +2 -1
  57. data/lib/openc3/topics/limits_event_topic.rb +90 -3
  58. data/lib/openc3/topics/router_topic.rb +7 -2
  59. data/lib/openc3/topics/system_events_topic.rb +9 -6
  60. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
  61. data/lib/openc3/topics/telemetry_topic.rb +3 -2
  62. data/lib/openc3/topics/topic.rb +162 -3
  63. data/lib/openc3/utilities/bucket.rb +5 -0
  64. data/lib/openc3/utilities/cli_generator.rb +67 -15
  65. data/lib/openc3/utilities/config_overlay.rb +35 -17
  66. data/lib/openc3/utilities/default_plugins.rb +33 -0
  67. data/lib/openc3/utilities/local_mode.rb +82 -46
  68. data/lib/openc3/utilities/metric.rb +16 -0
  69. data/lib/openc3/utilities/process_stats.rb +160 -0
  70. data/lib/openc3/utilities/pypi_url.rb +65 -0
  71. data/lib/openc3/utilities/questdb_client.rb +140 -32
  72. data/lib/openc3/utilities/running_script.rb +22 -18
  73. data/lib/openc3/utilities/secrets.rb +5 -0
  74. data/lib/openc3/utilities/store_autoload.rb +23 -4
  75. data/lib/openc3/utilities/store_queued.rb +37 -9
  76. data/lib/openc3/utilities/target_file.rb +57 -12
  77. data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
  78. data/lib/openc3/version.rb +5 -5
  79. data/templates/conversion/conversion.py +6 -2
  80. data/templates/conversion/conversion.rb +5 -0
  81. data/templates/plugin/.github/workflows/playwright.yml +62 -0
  82. data/templates/plugin/.github/workflows/release.yml +45 -0
  83. data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
  84. data/templates/plugin/.gitignore +6 -0
  85. data/templates/plugin/README.md +114 -2
  86. data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
  87. data/templates/plugin/plugin.gemspec +10 -1
  88. data/templates/plugin/pyproject.toml +119 -0
  89. data/templates/tool_angular/package.json +2 -2
  90. data/templates/tool_angular/pnpm-workspace.yaml +16 -0
  91. data/templates/tool_react/package.json +1 -1
  92. data/templates/tool_react/pnpm-workspace.yaml +16 -0
  93. data/templates/tool_svelte/package.json +2 -1
  94. data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
  95. data/templates/tool_vue/package.json +3 -3
  96. data/templates/tool_vue/pnpm-workspace.yaml +16 -0
  97. data/templates/widget/package.json +2 -2
  98. data/templates/widget/pnpm-workspace.yaml +16 -0
  99. metadata +16 -1
@@ -0,0 +1,331 @@
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 'timeout' # For Timeout::Error
15
+ require 'openc3/interfaces/interface'
16
+ require 'openc3/core_ext/exception'
17
+ require 'openc3/core_ext/time'
18
+ require 'openc3/utilities/logger'
19
+
20
+ module OpenC3
21
+ # Interface base class which reads an underlying socket / stream (the reader)
22
+ # in a dedicated thread and buffers the results on a queue. This keeps the
23
+ # operating system receive buffers drained as fast as possible so data isn't
24
+ # dropped while the packet reading thread is busy processing protocols and
25
+ # writing to the database.
26
+ #
27
+ # Subclasses call start_read_queue_thread with the object they read from
28
+ # (anything with a read method) once it is connected and stop_read_queue_thread
29
+ # once it is disconnected. While the thread is running the reader's read
30
+ # method is redirected to the queue, so the subclass (and anything else such
31
+ # as derived classes or protocols) keeps calling reader.read exactly as before
32
+ # and receives the data in the order it was read rather than competing with
33
+ # the read thread for it.
34
+ #
35
+ # Setting READ_QUEUE_MAX_SIZE to 0 disables the read thread so the reader is
36
+ # read inline, which is how interfaces read before the queue existed.
37
+ class ReadQueueInterface < Interface
38
+ # Maximum number of bytes buffered on the queue before the read thread
39
+ # blocks and lets the operating system do the buffering instead
40
+ DEFAULT_READ_QUEUE_MAX_SIZE = 20 * 1024 * 1024 # 20MB
41
+
42
+ # Approximate memory each queued read costs on top of the data itself (the
43
+ # String object plus the queue entry). Small reads are dominated by this so
44
+ # it is charged against the budget, otherwise a stream delivering a few
45
+ # bytes at a time would hold many times the budget in actual memory.
46
+ READ_QUEUE_ENTRY_OVERHEAD = 64
47
+
48
+ # How long to wait before re-checking whether the interface has been
49
+ # disconnected while blocked waiting for room on the queue
50
+ QUEUE_POLL_TIMEOUT = 0.1
51
+
52
+ # How long to wait for the read thread to exit when disconnecting
53
+ THREAD_JOIN_TIMEOUT = 2
54
+
55
+ # @return [Integer] Maximum number of bytes buffered on the queue
56
+ attr_accessor :read_queue_max_size
57
+
58
+ # @return [Time, nil] When the read thread read the data most recently
59
+ # returned by the reader (nil when the read queue is disabled)
60
+ attr_reader :read_queue_data_time
61
+
62
+ def initialize
63
+ super()
64
+ initialize_read_queue()
65
+ end
66
+
67
+ # Initialize (or reset) the read queue attributes. Called by initialize.
68
+ def initialize_read_queue(max_size = DEFAULT_READ_QUEUE_MAX_SIZE)
69
+ @raw_read_queue = nil
70
+ @raw_read_thread = nil
71
+ @raw_reader = nil
72
+ @raw_reader_read = nil
73
+ @raw_read_bytes = 0
74
+ @raw_read_budget = 0
75
+ @raw_read_timeout_queued = false
76
+ @read_queue_data_time = nil
77
+ # Guards @raw_read_queue / @raw_read_bytes / @raw_read_budget and wakes the
78
+ # read thread when the bytes it queued are consumed and there is room to
79
+ # queue more. Each read thread is cancelled by closing its own queue under
80
+ # this mutex, so a thread which outlives stop_read_queue_thread (and a new
81
+ # thread starting) keeps seeing its old queue closed and can never touch
82
+ # the byte counters or read again.
83
+ @raw_read_mutex = Mutex.new
84
+ @raw_read_condition = ConditionVariable.new
85
+ @read_queue_max_size = max_size
86
+ end
87
+
88
+ # Supported Options
89
+ # READ_QUEUE_MAX_SIZE - Maximum number of bytes buffered on the read queue
90
+ # or 0 to disable the read thread and read inline (see Interface#set_option)
91
+ def set_option(option_name, option_values)
92
+ super(option_name, option_values)
93
+ if option_name.upcase == 'READ_QUEUE_MAX_SIZE'
94
+ max_size = Integer(option_values[0])
95
+ raise "READ_QUEUE_MAX_SIZE must be 0 (disabled) or a positive integer but was #{max_size}" if max_size < 0
96
+
97
+ @read_queue_max_size = max_size
98
+ end
99
+ end
100
+
101
+ # @return [Boolean] Whether reads are buffered by a read thread (false when
102
+ # READ_QUEUE_MAX_SIZE is 0 and reads happen inline)
103
+ def read_queue_enabled?
104
+ @read_queue_max_size > 0
105
+ end
106
+
107
+ # @return [Boolean] Whether a read thread has been started (and not stopped)
108
+ def read_queue_started?
109
+ !@raw_read_queue.nil?
110
+ end
111
+
112
+ # @return [Integer] The number of reads waiting on the read queue
113
+ def read_queue_size
114
+ queue = @raw_read_queue
115
+ queue ? queue.size : 0
116
+ end
117
+
118
+ # @return [Integer] The number of bytes waiting on the read queue
119
+ def read_queue_bytes
120
+ return 0 unless @raw_read_queue
121
+
122
+ @raw_read_mutex.synchronize { @raw_read_bytes }
123
+ end
124
+
125
+ # Start the thread which continuously reads and queues data from the reader
126
+ # and redirect the reader's read method to the queue. Does nothing beyond
127
+ # stopping any existing thread if the read queue is disabled.
128
+ #
129
+ # @param reader [#read] Object to read from, e.g. a Stream or socket
130
+ # @param read_args [Array] Arguments the read thread passes to reader.read.
131
+ # Arguments passed to the redirected read are ignored.
132
+ def start_read_queue_thread(reader, *read_args)
133
+ stop_read_queue_thread()
134
+ return unless read_queue_enabled?
135
+
136
+ queue = Queue.new
137
+ @raw_read_mutex.synchronize do
138
+ @raw_read_bytes = 0
139
+ @raw_read_budget = 0
140
+ @raw_read_timeout_queued = false
141
+ @raw_read_queue = queue
142
+ end
143
+ begin
144
+ raw_read = redirect_reader(reader)
145
+ rescue FrozenError, TypeError
146
+ Logger.warn "#{@name}: Can't redirect #{reader.class}#read so disabling the read queue"
147
+ @raw_read_mutex.synchronize { @raw_read_queue = nil }
148
+ @read_queue_max_size = 0
149
+ return
150
+ end
151
+ @raw_read_thread = Thread.new do
152
+ read_queue_thread_body(queue, raw_read, read_args)
153
+ rescue Exception => e
154
+ Logger.error "#{@name}: Read queue thread unexpectedly died: #{e.formatted}"
155
+ end
156
+ end
157
+
158
+ # Stop the read thread, restore the reader's read method and discard anything
159
+ # left on the queue. The reader should be disconnected first so the thread
160
+ # isn't blocked reading.
161
+ def stop_read_queue_thread
162
+ # Allow subclasses to set their reader before initialize_read_queue
163
+ return unless @raw_read_mutex
164
+
165
+ @raw_read_mutex.synchronize do
166
+ # Closing the queue under the mutex cancels the read thread (it either
167
+ # already charged its bytes, which are reset below, or never will) and
168
+ # unblocks read_queue_pop
169
+ @raw_read_queue.close if @raw_read_queue
170
+ @raw_read_queue = nil
171
+ @raw_read_bytes = 0
172
+ @raw_read_budget = 0
173
+ @raw_read_timeout_queued = false
174
+ @read_queue_data_time = nil
175
+ # Unblock the read thread if it is waiting for room on the queue
176
+ @raw_read_condition.broadcast
177
+ end
178
+ restore_reader()
179
+ thread = @raw_read_thread
180
+ @raw_read_thread = nil
181
+ # The read source should already be disconnected which unblocks the read
182
+ if thread and thread != Thread.current and !thread.join(THREAD_JOIN_TIMEOUT)
183
+ OpenC3.kill_thread(nil, thread)
184
+ end
185
+ end
186
+
187
+ # Retrieve the next data off the read queue, blocking until it is available.
188
+ # This is what the reader's read method returns while it is redirected.
189
+ #
190
+ # @return [String, nil] Data read or nil if the read source is done
191
+ def read_queue_pop
192
+ queue = @raw_read_queue
193
+ return nil unless queue
194
+
195
+ loop do
196
+ data = queue.pop
197
+ if data.kind_of?(Array)
198
+ data, time = data
199
+ @raw_read_mutex.synchronize do
200
+ # Counters were reset if the queue was stopped / replaced
201
+ if queue.equal?(@raw_read_queue)
202
+ @raw_read_bytes -= data.length
203
+ @raw_read_budget -= data.length + READ_QUEUE_ENTRY_OVERHEAD
204
+ @read_queue_data_time = time
205
+ # Tell the read thread there is room for more data
206
+ @raw_read_condition.broadcast
207
+ end
208
+ end
209
+ return data
210
+ end
211
+
212
+ if data.kind_of?(Timeout::Error)
213
+ @raw_read_mutex.synchronize { @raw_read_timeout_queued = false if queue.equal?(@raw_read_queue) }
214
+ # Data queued behind the timeout arrived while nobody was waiting on
215
+ # it, so an inline read would have returned it rather than timing out
216
+ next unless queue.empty?
217
+ end
218
+ # Exceptions raised by the read thread are re-raised here so they are
219
+ # handled exactly as they were when the read happened inline
220
+ raise data if data.kind_of?(Exception)
221
+
222
+ return data
223
+ end
224
+ end
225
+
226
+ # @param data [String, nil] Data returned by reader.read
227
+ # @return [Boolean] Whether the data indicates the reader is done (closed) so
228
+ # the read thread should stop. Subclasses can override this.
229
+ def read_queue_done?(data)
230
+ data.nil? or data.length <= 0
231
+ end
232
+
233
+ # protected
234
+
235
+ def read_queue_thread_body(queue, raw_read, read_args)
236
+ until queue.closed?
237
+ begin
238
+ data = raw_read.call(*read_args)
239
+ data_time = Time.now.sys
240
+ rescue Timeout::Error => e
241
+ # A read timeout doesn't end the reader so pass it along and keep
242
+ # reading. Only one is queued at a time so a reader which times out
243
+ # quickly can't fill the queue with them.
244
+ queue_timeout = @raw_read_mutex.synchronize do
245
+ next false if queue.closed? or @raw_read_timeout_queued
246
+
247
+ @raw_read_timeout_queued = true
248
+ end
249
+ queue.push(e) if queue_timeout
250
+ next
251
+ rescue Exception => e
252
+ queue.push(e)
253
+ break
254
+ end
255
+ # Tell whoever reads next that the reader is done and stop reading
256
+ if read_queue_done?(data)
257
+ queue.push(data)
258
+ break
259
+ end
260
+ # Charge the bytes against the budget before the push so
261
+ # read_queue_bytes never goes negative if the data is dequeued
262
+ # before we get back here
263
+ break unless reserve_read_queue_bytes(queue, data.length)
264
+
265
+ # Queue the time of the read so packets are timestamped when the data
266
+ # arrived rather than when it was processed
267
+ queue.push([data, data_time])
268
+ end
269
+ rescue ClosedQueueError
270
+ # Interface disconnected while we were pushing
271
+ ensure
272
+ # Closing tells read_queue_pop there will never be more data so it
273
+ # returns nil rather than blocking forever
274
+ queue.close
275
+ end
276
+
277
+ # Wait until the data fits in the byte budget and charge it against the
278
+ # budget. A read larger than the entire budget is queued on its own rather
279
+ # than never fitting, so the budget can be exceeded by at most one read.
280
+ #
281
+ # @return [Boolean] Whether the bytes were reserved (false if disconnected)
282
+ def reserve_read_queue_bytes(queue, length)
283
+ cost = length + READ_QUEUE_ENTRY_OVERHEAD
284
+ @raw_read_mutex.synchronize do
285
+ while @raw_read_budget > 0 and (@raw_read_budget + cost) > @read_queue_max_size
286
+ return false if queue.closed?
287
+
288
+ @raw_read_condition.wait(@raw_read_mutex, QUEUE_POLL_TIMEOUT)
289
+ end
290
+ return false if queue.closed?
291
+
292
+ @raw_read_bytes += length
293
+ @raw_read_budget += cost
294
+ end
295
+ return true
296
+ end
297
+
298
+ # Redirect reader.read to the read queue so everything which reads the
299
+ # reader directly gets the queued data in order.
300
+ #
301
+ # @return [Method] The original read method for the read thread to call
302
+ def redirect_reader(reader)
303
+ raw_read = reader.method(:read)
304
+ # Remember a read already defined on the reader itself (e.g. a test stub)
305
+ # so it can be put back rather than removed
306
+ reader_read = reader.singleton_methods(false).include?(:read) ? raw_read : nil
307
+ interface = self
308
+ reader.define_singleton_method(:read) { |*_args| interface.read_queue_pop }
309
+ @raw_reader = reader
310
+ @raw_reader_read = reader_read
311
+ return raw_read
312
+ end
313
+
314
+ # Undo redirect_reader so the reader reads inline again
315
+ def restore_reader
316
+ reader = @raw_reader
317
+ return unless reader
318
+
319
+ if @raw_reader_read
320
+ reader.define_singleton_method(:read, @raw_reader_read)
321
+ else
322
+ reader.singleton_class.send(:remove_method, :read)
323
+ end
324
+ rescue NameError
325
+ # The redirect was already removed
326
+ ensure
327
+ @raw_reader = nil
328
+ @raw_reader_read = nil
329
+ end
330
+ end
331
+ end
@@ -15,12 +15,12 @@
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/interfaces/interface'
18
+ require 'openc3/interfaces/read_queue_interface'
19
19
 
20
20
  module OpenC3
21
21
  # Base class for interfaces that act read and write from a stream
22
- class StreamInterface < Interface
23
- attr_accessor :stream
22
+ class StreamInterface < ReadQueueInterface
23
+ attr_reader :stream
24
24
 
25
25
  def initialize(protocol_type = nil, protocol_args = [])
26
26
  super()
@@ -34,9 +34,20 @@ module OpenC3
34
34
  end
35
35
  end
36
36
 
37
+ # Replacing the stream stops the read thread which is reading the old one
38
+ def stream=(stream)
39
+ stop_read_queue_thread()
40
+ @stream = stream
41
+ end
42
+
37
43
  def connect
38
44
  super() # Reset the protocols
39
45
  @stream.connect if @stream
46
+ # The read thread continuously drains the stream so the operating system
47
+ # buffers don't fill up while we're busy processing the previous read.
48
+ # @stream.read is redirected to the queue so everything reading the
49
+ # stream (including subclasses and protocols) still sees all the data.
50
+ start_read_queue_thread(@stream) if @stream and read_allowed?
40
51
  end
41
52
 
42
53
  def connected?
@@ -48,11 +59,19 @@ module OpenC3
48
59
  end
49
60
 
50
61
  def disconnect
62
+ # Disconnect the stream first to unblock the read thread
51
63
  @stream.disconnect if @stream
64
+ stop_read_queue_thread()
52
65
  super()
53
66
  end
54
67
 
55
68
  def read_interface
69
+ # The read thread is normally started by connect but interfaces can also
70
+ # be handed an already connected stream so start it on demand
71
+ if @stream and !read_queue_started? and read_queue_enabled? and read_allowed? and connected?
72
+ start_read_queue_thread(@stream)
73
+ end
74
+
56
75
  timeout = false
57
76
  begin
58
77
  data = @stream.read
@@ -257,6 +257,15 @@ module OpenC3
257
257
  @read_queue ? @read_queue.size : 0
258
258
  end
259
259
 
260
+ # @return [Integer] The number of bytes waiting on the read queues of the
261
+ # connected clients. The server itself has no stream so the raw reads are
262
+ # buffered by each client connection.
263
+ def read_queue_bytes
264
+ @connection_mutex.synchronize do
265
+ @read_interface_infos.sum { |rii| rii.interface.read_queue_bytes }
266
+ end
267
+ end
268
+
260
269
  # @return [Integer] The number of packets waiting on the write queue
261
270
  def write_queue_size
262
271
  @write_queue ? @write_queue.size : 0
@@ -400,6 +409,10 @@ module OpenC3
400
409
  end
401
410
 
402
411
  interface = StreamInterface.new
412
+ # Only the read side of the connection needs a read thread
413
+ interface.read_allowed = listen_read
414
+ # Each connection buffers its own reads so give it the configured limit
415
+ interface.read_queue_max_size = @read_queue_max_size
403
416
  interface.target_names = @target_names
404
417
  interface.cmd_target_names = @cmd_target_names
405
418
  interface.tlm_target_names = @tlm_target_names
@@ -15,13 +15,13 @@
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/interfaces/interface'
18
+ require 'openc3/interfaces/read_queue_interface'
19
19
  require 'openc3/io/udp_sockets'
20
20
  require 'openc3/config/config_parser'
21
21
 
22
22
  module OpenC3
23
23
  # Base class for interfaces that send and receive messages over UDP
24
- class UdpInterface < Interface
24
+ class UdpInterface < ReadQueueInterface
25
25
  HOST_127_0_0_1 = '127.0.0.1'
26
26
  HOST_0_0_0_0 = '0.0.0.0'
27
27
 
@@ -140,6 +140,11 @@ module OpenC3
140
140
  end
141
141
  @thread_sleeper = nil
142
142
  super()
143
+ # The read thread continuously drains the socket so the operating system
144
+ # buffers don't fill up (and drop datagrams) while we're busy processing
145
+ # the previous read. @read_socket.read is redirected to the queue so
146
+ # everything reading the socket (including subclasses) still sees all the data.
147
+ start_read_queue_thread(@read_socket, @read_timeout) if @read_socket
143
148
  end
144
149
 
145
150
  # @return [Boolean] Whether the active ports (read and/or write) have
@@ -163,6 +168,8 @@ module OpenC3
163
168
  OpenC3.close_socket(@read_socket)
164
169
  @write_socket = nil
165
170
  @read_socket = nil
171
+ # The sockets are closed above which unblocks the read thread
172
+ stop_read_queue_thread()
166
173
  @thread_sleeper.cancel if @thread_sleeper
167
174
  @thread_sleeper = nil
168
175
  super()
@@ -177,9 +184,16 @@ module OpenC3
177
184
  return nil
178
185
  end
179
186
 
187
+ # Zero length datagrams are valid so only nil means the socket is done
188
+ def read_queue_done?(data)
189
+ data.nil?
190
+ end
191
+
180
192
  # Reads from the socket if the read_port is defined
181
193
  def read_interface
182
194
  data = @read_socket.read(@read_timeout)
195
+ return nil unless data # Disconnected
196
+
183
197
  Logger.info "#{@name}: Udp read returned 0 bytes (stream closed)" if data.length <= 0
184
198
  extra = nil
185
199
  read_interface_base(data, extra)
@@ -22,6 +22,7 @@ module OpenC3
22
22
  autoload(:Interface, 'openc3/interfaces/interface.rb')
23
23
  autoload(:MqttInterface, 'openc3/interfaces/mqtt_interface.rb')
24
24
  autoload(:MqttStreamInterface, 'openc3/interfaces/mqtt_stream_interface.rb')
25
+ autoload(:ReadQueueInterface, 'openc3/interfaces/read_queue_interface.rb')
25
26
  autoload(:SerialInterface, 'openc3/interfaces/serial_interface.rb')
26
27
  autoload(:SimulatedTargetInterface, 'openc3/interfaces/simulated_target_interface.rb')
27
28
  autoload(:StreamInterface, 'openc3/interfaces/stream_interface.rb')
@@ -63,7 +63,10 @@ module OpenC3
63
63
  CYCLE_TIME_INTERVAL = 10
64
64
 
65
65
  # Delay in seconds before trimming Redis streams
66
- CLEANUP_DELAY = 60
66
+ # This is the primary trimmer of the TELEMETRY__ and COMMAND__ streams. Writers
67
+ # also apply a safety MINID cap (see Topic.stream_safety_minid) which depends on this
68
+ # value, so it is defined in Topic.
69
+ CLEANUP_DELAY = Topic::LOG_CLEANUP_DELAY_SECONDS
67
70
 
68
71
  # Mutex protecting class variables
69
72
  @@mutex = Mutex.new
@@ -211,6 +214,15 @@ module OpenC3
211
214
  end
212
215
  end
213
216
 
217
+ # The db_shard of a target topic like DEFAULT__DECOM__{INST}__HEALTH_STATUS
218
+ # or db_shard 0 for a topic without a target
219
+ def self.db_shard_for_topic(redis_topic)
220
+ scope, _, rest = redis_topic.delete_prefix('{').partition('__')
221
+ # The target is a {TARGET} part between two others, e.g. DECOM__{INST}__HEALTH_STATUS
222
+ target = rest.split('__', -1)[1...-1]&.find { |part| part.length > 2 and part.start_with?('{') and part.end_with?('}') }
223
+ (!scope.empty? and target) ? Store.db_shard_for_target(target[1...-1], scope: scope) : 0
224
+ end
225
+
214
226
  def cycle_thread_body
215
227
  @@cycle_sleeper = Sleeper.new
216
228
  while true
@@ -245,9 +257,7 @@ module OpenC3
245
257
  # Now that the file is in S3, trim the Redis stream up until the previous file.
246
258
  # This keeps one minute of data in Redis
247
259
  instance.cleanup_offsets[index].each do |redis_topic, cleanup_offset|
248
- target_match = redis_topic.match(/__\{?([^}_]+)\}?__/)
249
- db_shard = target_match ? Store.db_shard_for_target(target_match[1]) : 0
250
- Topic.trim_topic(redis_topic, cleanup_offset, db_shard: db_shard)
260
+ Topic.trim_topic(redis_topic, cleanup_offset, db_shard: LogWriter.db_shard_for_topic(redis_topic))
251
261
  end
252
262
  indexes_to_clear << index
253
263
  end
@@ -84,6 +84,11 @@ module OpenC3
84
84
  include InterfaceDecomCommon
85
85
  LIMITS_STATE_INDEX = { RED_LOW: 0, YELLOW_LOW: 1, YELLOW_HIGH: 2, RED_HIGH: 3, GREEN_LOW: 4, GREEN_HIGH: 5 }
86
86
 
87
+ # Decom never uses descriptions so don't keep them in memory
88
+ def load_descriptions?
89
+ false
90
+ end
91
+
87
92
  def initialize(*args)
88
93
  super(*args)
89
94
  # Should only be one target, but there might be multiple decom microservices for a given target
@@ -94,6 +99,10 @@ module OpenC3
94
99
  @limits_event_topic = "#{@scope}__openc3_limits_events"
95
100
  @topics << @limits_event_topic
96
101
  Topic.update_topic_offsets(@topics, db_shard: @db_shard)
102
+ # Last limits event read, used to detect events trimmed before they were read.
103
+ # Read from the stream rather than the thread's cached offsets. The run thread
104
+ # starts reading at or after this point, so it can only cause an extra resync.
105
+ @limits_event_offset = Topic.get_last_offset(@limits_event_topic, db_shard: @db_shard)
97
106
  # Initialize before assigning limits_change_callback - sync_system below
98
107
  # can fire the callback synchronously for any persisted-disabled items.
99
108
  @limits_response_queue = Queue.new
@@ -116,14 +125,21 @@ module OpenC3
116
125
  break if @cancel_thread
117
126
 
118
127
  begin
128
+ limits_events_checked = false
119
129
  OpenC3.in_span("read_topics") do
120
130
  Topic.read_topics(@topics, db_shard: @db_shard) do |topic, msg_id, msg_hash, redis|
121
131
  break if @cancel_thread
122
132
  if topic == @microservice_topic
123
133
  microservice_cmd(topic, msg_id, msg_hash, redis)
124
134
  elsif topic == @limits_event_topic
135
+ # Any gap is before the first event in a read so only check once per read
136
+ unless limits_events_checked
137
+ resync_if_limits_events_trimmed()
138
+ limits_events_checked = true
139
+ end
140
+ @limits_event_offset = msg_id
125
141
  event = JSON.parse(msg_hash['event'], allow_nan: true, create_additions: true)
126
- LimitsEventTopic.process_event(event)
142
+ LimitsEventTopic.process_event(event, scope: @scope)
127
143
  elsif topic =~ /__DECOMINTERFACE/
128
144
  if msg_hash.key?('inject_tlm')
129
145
  handle_inject_tlm_with_ack(msg_hash['inject_tlm'], msg_id)
@@ -156,11 +172,20 @@ module OpenC3
156
172
  @limits_response_thread = nil
157
173
  end
158
174
 
159
- def decom_packet(_topic, msg_id, msg_hash, _redis)
175
+ # Limits events are only read between batches of packets, so a decom microservice
176
+ # working through a backlog can fall far enough behind that a flood of LIMITS_CHANGE
177
+ # events trims control events (enable, settings, etc) before it reads them.
178
+ # Recover by reloading all limits state from the store, which is always current.
179
+ def resync_if_limits_events_trimmed
180
+ if LimitsEventTopic.events_trimmed?(@limits_event_offset, scope: @scope, db_shard: @db_shard)
181
+ @logger.warn("#{@name}: Limits events were trimmed before they could be read, resyncing limits")
182
+ LimitsEventTopic.resync_system(scope: @scope)
183
+ end
184
+ end
185
+
186
+ def decom_packet(topic, msg_id, msg_hash, _redis)
160
187
  OpenC3.in_span("decom_packet") do
161
- msgid_seconds_from_epoch = msg_id.split('-')[0].to_i / 1000.0
162
- delta = Time.now.to_f - msgid_seconds_from_epoch
163
- @metric.set(name: 'decom_topic_delta_seconds', value: delta, type: 'gauge', unit: 'seconds', help: 'Delta time between data written to stream and decom start')
188
+ update_topic_lag(topic, msg_id, metric_name: 'decom_topic_delta_seconds', help: 'Delta time between data written to stream and decom start')
164
189
 
165
190
  #######################################
166
191
  # Build packet object from topic data
@@ -42,6 +42,8 @@ module OpenC3
42
42
  def handle_inject_tlm_with_ack(inject_tlm_json, msg_id)
43
43
  inject_tlm_hash = JSON.parse(inject_tlm_json, allow_nan: true, create_additions: true)
44
44
  target_name = inject_tlm_hash['target_name']
45
+ # Ack on the db_shard of the target where DecomInterfaceTopic.inject_tlm waits for it
46
+ db_shard = Store.db_shard_for_target(target_name, scope: @scope)
45
47
  ack_topic = "{#{@scope}__ACKCMD}TARGET__#{target_name}"
46
48
  begin
47
49
  handle_inject_tlm(inject_tlm_json)
@@ -56,7 +58,7 @@ module OpenC3
56
58
  result: error.message
57
59
  }
58
60
  end
59
- Topic.write_topic(ack_topic, msg_hash)
61
+ Topic.write_topic(ack_topic, msg_hash, db_shard: db_shard)
60
62
  end
61
63
 
62
64
  def handle_build_cmd(build_cmd_json, msg_id)