openc3 7.1.0 → 7.2.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +3 -0
  3. data/data/config/command_modifiers.yaml +2 -2
  4. data/data/config/interface_modifiers.yaml +3 -1
  5. data/data/config/item_modifiers.yaml +10 -3
  6. data/data/config/microservice.yaml +3 -1
  7. data/data/config/plugins.yaml +1 -0
  8. data/lib/openc3/api/api.rb +1 -0
  9. data/lib/openc3/api/calendar_api.rb +183 -0
  10. data/lib/openc3/api/tlm_api.rb +6 -0
  11. data/lib/openc3/microservices/decom_microservice.rb +4 -2
  12. data/lib/openc3/microservices/microservice.rb +20 -5
  13. data/lib/openc3/models/plugin_model.rb +20 -8
  14. data/lib/openc3/models/queue_model.rb +36 -46
  15. data/lib/openc3/models/trigger_model.rb +1 -1
  16. data/lib/openc3/operators/operator.rb +34 -9
  17. data/lib/openc3/packets/packet_config.rb +17 -4
  18. data/lib/openc3/packets/parsers/xtce_parser.rb +23 -1
  19. data/lib/openc3/script/script.rb +4 -2
  20. data/lib/openc3/script/suite.rb +1 -1
  21. data/lib/openc3/script/web_socket_api.rb +5 -1
  22. data/lib/openc3/topics/command_topic.rb +1 -0
  23. data/lib/openc3/topics/telemetry_decom_topic.rb +1 -0
  24. data/lib/openc3/utilities/cli_generator.rb +434 -403
  25. data/lib/openc3/utilities/questdb_client.rb +51 -4
  26. data/lib/openc3/utilities/running_script.rb +49 -13
  27. data/lib/openc3/utilities/simulated_target.rb +4 -2
  28. data/lib/openc3/version.rb +5 -5
  29. data/templates/command_validator/command_validator.py +8 -10
  30. data/templates/command_validator/command_validator.rb +6 -9
  31. data/templates/microservice/microservices/TEMPLATE/microservice.py +9 -0
  32. data/templates/plugin/LICENSE.md +16 -4
  33. data/templates/tool_angular/package.json +2 -2
  34. data/templates/tool_react/package.json +1 -1
  35. data/templates/tool_svelte/package.json +1 -1
  36. data/templates/tool_vue/package.json +3 -3
  37. data/templates/widget/package.json +2 -2
  38. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fa824943ce72cce586cdfe03c1a5a8395120801bd845174c25433a15bf986306
4
- data.tar.gz: 620cc66576e14e7e696419e5be0b5704190452d29efc35a315a489099b20f82e
3
+ metadata.gz: 90e17f6bf28158ea786646171220e3c23aca07cce30559b3e64a29f83dabb50d
4
+ data.tar.gz: 9a13e73273ba67e7d6003a9578a34a8b1c0abadea3b8d4d369f1fd3803a4f0ec
5
5
  SHA512:
6
- metadata.gz: 1f392d51b2e5870128a995aa9e5717f9a4ed3bf0b8f55952b9141ec34898406734a2a834cf18d09b78d6e82f5675c2c0f3269e2ef62964ec58f2b96b8933ce80
7
- data.tar.gz: a92a073ff672da55a44b85b700cb5b6a908208e9a10ddcad77828f02de0f877c3db2252d7d93857ab5b65d55b39594dfc109d72028a82cbfeaa6e468878eef94
6
+ metadata.gz: eec5ccdb945747570717e5f60f9a89158d500d994f1fa5e6ebc81ed8bb263229950f10e1f277e0c68bf5d9fb531f3470354d45fda2164f7608c9ad22413b2022
7
+ data.tar.gz: a32f7675eacf6b41b345a4b5ae554292db011499d12a9e5e65173c514e5fac7e66da3bacea7ad5cf9a3738fcb96e3a82009997993fc70722d4a9ba4edd236261
data/bin/openc3cli CHANGED
@@ -444,6 +444,9 @@ def unload_plugin(plugin_name, scope:)
444
444
  plugin_model = OpenC3::PluginModel.get_model(name: plugin_name, scope: scope)
445
445
  plugin_model.destroy
446
446
  OpenC3::LocalMode.remove_local_plugin(plugin_name, scope: scope)
447
+ # Remove the backing gem now that no PluginModel references it,
448
+ # so it disappears from the admin Packages tab.
449
+ OpenC3::PluginModel.cleanup_gem(plugin_name, scope: scope)
447
450
  OpenC3::Logger.info("PluginModel destroyed: #{plugin_name}", scope: scope)
448
451
  rescue => e
449
452
  abort("Error uninstalling plugin: #{scope}: #{plugin_name}: #{e.formatted}")
@@ -417,12 +417,12 @@ VALIDATOR:
417
417
  return [False, "TGT PKT ITEM is 0"]
418
418
  self.cmd_acpt_cnt = tlm("INST HEALTH_STATUS CMD_ACPT_CNT")
419
419
  # Return true to indicate Success, false to indicate Failure,
420
- # and nil to indicate Unknown. The second value is the optional message.
420
+ # and None to indicate Unknown. The second value is the optional message.
421
421
  return [True, None]
422
422
 
423
423
  def post_check(self, command):
424
424
  wait_check(f"INST HEALTH_STATUS CMD_ACPT_CNT > {self.cmd_acpt_cnt}", 10)
425
425
  # Return true to indicate Success, false to indicate Failure,
426
- # and nil to indicate Unknown. The second value is the optional message.
426
+ # and None to indicate Unknown. The second value is the optional message.
427
427
  return [True, None]
428
428
  since: 5.19.0
@@ -212,7 +212,9 @@ WORK_DIR:
212
212
  WORK_DIR '/openc3/lib/openc3/microservices'
213
213
  PORT:
214
214
  summary: Open port for the microservice
215
- description: Kubernetes needs a Service to be applied to open a port so this is required for Kubernetes support
215
+ description:
216
+ Kubernetes needs a Service to be applied to open a port so this is required for Kubernetes support
217
+ See [Exposing Microservices](/docs/guides/exposing-microservices) for more information.
216
218
  since: 5.7.0
217
219
  parameters:
218
220
  - name: Number
@@ -111,20 +111,27 @@ CONVERTED_DATA:
111
111
  values: \d+
112
112
  LIMITS:
113
113
  summary: Defines a set of limits for a telemetry item
114
- description: If limits are violated a message is printed in the Command and Telemetry Server
114
+ description: |
115
+ If limits are violated a message is printed in the Command and Telemetry Server
115
116
  to indicate an item went out of limits. Other tools also use this information
116
117
  to update displays with different colored telemetry items or other useful information.
117
118
  The concept of "limits sets" is defined to allow for different limits values
118
119
  in different environments. For example, you might want tighter or looser limits
119
120
  on telemetry if your environment changes such as during thermal vacuum testing.
121
+
122
+ A DEFAULT limits set is required for every telemetry item with limits. If you
123
+ define additional named sets (e.g. TVAC), the DEFAULT set must be defined first.
124
+ Attempting to define a named set before DEFAULT will raise an error of the form
125
+ "DEFAULT limits set must be defined for TARGET PACKET ITEM before setting limits set NAME".
120
126
  example: |
121
127
  LIMITS DEFAULT 3 ENABLED -80.0 -70.0 60.0 80.0 -20.0 20.0
122
128
  LIMITS TVAC 3 ENABLED -80.0 -30.0 30.0 80.0
123
129
  parameters:
124
130
  - name: Limits Set
125
131
  required: true
126
- description: Name of the limits set. If you have no unique limits sets use
127
- the keyword DEFAULT.
132
+ description: Name of the limits set. A DEFAULT set is required and must be
133
+ defined before any other named sets for this item. If you have no unique
134
+ limits sets use the keyword DEFAULT.
128
135
  values: .+
129
136
  - name: Persistence
130
137
  required: true
@@ -40,7 +40,9 @@ MICROSERVICE:
40
40
  WORK_DIR .
41
41
  PORT:
42
42
  summary: Open port for the microservice
43
- description: Kubernetes needs a Service to be applied to open a port so this is required for Kubernetes support
43
+ description:
44
+ Kubernetes needs a Service to be applied to open a port so this is required for Kubernetes support.
45
+ See [Exposing Microservices](/docs/guides/exposing-microservices) for more information.
44
46
  since: 5.0.10
45
47
  parameters:
46
48
  - name: Number
@@ -38,6 +38,7 @@ VARIABLE_STATE:
38
38
  VARIABLE log_retain_time 172800
39
39
  VARIABLE_STATE "24 hours" 86400
40
40
  VARIABLE_STATE "48 hours" 172800
41
+ VARIABLE_STATE 60 # Both description and value are set to 60
41
42
  parameters:
42
43
  - name: State Description
43
44
  required: false
@@ -17,6 +17,7 @@
17
17
 
18
18
  require 'openc3/script/extract'
19
19
  require 'openc3/script/api_shared'
20
+ require 'openc3/api/calendar_api'
20
21
  require 'openc3/api/cmd_api'
21
22
  require 'openc3/api/config_api'
22
23
  require 'openc3/api/interface_api'
@@ -0,0 +1,183 @@
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 'date'
15
+ require 'openc3/models/timeline_model'
16
+ require 'openc3/models/activity_model'
17
+ require 'openc3/topics/timeline_topic'
18
+
19
+ module OpenC3
20
+ module Api
21
+ # NOTE: These methods are intentionally NOT added to WHITELIST. Their signatures
22
+ # match openc3/lib/openc3/script/calendar.rb (no manual:/token: kwargs), so they
23
+ # cannot be dispatched via JSON-RPC (which auto-injects manual/token from headers).
24
+ # The script-side calendar methods reach the server through the timeline/activity
25
+ # HTTP controllers, which call these helpers after performing their own
26
+ # authorization.
27
+
28
+ # Returns an array of all timelines for the given scope.
29
+ def list_timelines(scope: $openc3_scope)
30
+ ret = []
31
+ TimelineModel.all.each do |timeline, value|
32
+ if scope == timeline.split('__')[0]
33
+ ret << value
34
+ end
35
+ end
36
+ ret
37
+ end
38
+
39
+ # Creates a new timeline and deploys its microservice.
40
+ # @return [Hash] the created timeline as a hash
41
+ def create_timeline(name, color: nil, scope: $openc3_scope)
42
+ model = TimelineModel.new(name: name, color: color, scope: scope)
43
+ model.create()
44
+ model.deploy()
45
+ model.as_json()
46
+ end
47
+
48
+ # @return [Hash, nil] the timeline as a hash, or nil if not found
49
+ def get_timeline(name, scope: $openc3_scope)
50
+ model = TimelineModel.get(name: name, scope: scope)
51
+ return nil if model.nil?
52
+ model.as_json()
53
+ end
54
+
55
+ # Updates the color of an existing timeline.
56
+ # @return [Hash, nil] the updated timeline as a hash, or nil if not found
57
+ def set_timeline_color(name, color, scope: $openc3_scope)
58
+ model = TimelineModel.get(name: name, scope: scope)
59
+ return nil if model.nil?
60
+ model.color = color
61
+ model.update()
62
+ model.notify(kind: 'updated')
63
+ model.as_json()
64
+ end
65
+
66
+ # Updates the execute flag of an existing timeline.
67
+ # @return [Hash, nil] the updated timeline as a hash, or nil if not found
68
+ def set_timeline_execute(name, enable, scope: $openc3_scope)
69
+ model = TimelineModel.get(name: name, scope: scope)
70
+ return nil if model.nil?
71
+ model.execute = enable
72
+ model.update()
73
+ model.notify(kind: 'updated')
74
+ model.as_json()
75
+ end
76
+
77
+ # Deletes a timeline (and optionally all of its activities when force is true).
78
+ # @return [Hash, nil] {'name' => name}, or nil if not found
79
+ def delete_timeline(name, force: false, scope: $openc3_scope)
80
+ model = TimelineModel.get(name: name, scope: scope)
81
+ return nil if model.nil?
82
+ TimelineModel.delete(name: name, scope: scope, force: force)
83
+ model.undeploy()
84
+ model.notify(kind: 'deleted')
85
+ { 'name' => name }
86
+ end
87
+
88
+ # Creates a new activity on the specified timeline.
89
+ # username is read from data['username'] if present and is used for the audit event.
90
+ # @return [Hash] the created activity as a hash
91
+ def create_timeline_activity(name, kind:, start:, stop:, data: {}, recurring: nil, scope: $openc3_scope)
92
+ data ||= {}
93
+ hash = {
94
+ kind: kind,
95
+ start: _cal_to_epoch(start),
96
+ stop: _cal_to_epoch(stop),
97
+ data: data,
98
+ }
99
+ if recurring
100
+ recurring = recurring.dup
101
+ if recurring['end']
102
+ recurring['end'] = _cal_to_epoch(recurring['end'])
103
+ end
104
+ hash[:recurring] = recurring
105
+ end
106
+ model = ActivityModel.from_json(hash, name: name, scope: scope)
107
+ model.create(username: data['username'])
108
+ model.as_json()
109
+ end
110
+
111
+ # Updates an existing activity on the specified timeline.
112
+ # @return [Hash, nil] the updated activity as a hash, or nil if not found
113
+ def update_timeline_activity(name, id:, kind:, start:, stop:, uuid:, data: {}, scope: $openc3_scope)
114
+ data ||= {}
115
+ model = ActivityModel.score(name: name, score: id.to_i, uuid: uuid, scope: scope)
116
+ return nil if model.nil?
117
+ model.update(
118
+ start: _cal_to_epoch(start),
119
+ stop: _cal_to_epoch(stop),
120
+ kind: kind,
121
+ data: data,
122
+ username: data['username'],
123
+ )
124
+ model.as_json()
125
+ end
126
+
127
+ # @return [Hash, nil] the activity as a hash, or nil if not found
128
+ def get_timeline_activity(name, start, uuid, scope: $openc3_scope)
129
+ model = ActivityModel.score(name: name, score: start.to_i, uuid: uuid, scope: scope)
130
+ return nil if model.nil?
131
+ model.as_json()
132
+ end
133
+
134
+ # Returns activities on the timeline in the given window.
135
+ # When start/stop are nil, defaults to a window of [now - 7 days, now + 7 days].
136
+ # When limit is nil, defaults to one event per minute over the window.
137
+ # @return [Array<Hash>] the matching activities
138
+ def get_timeline_activities(name, start: nil, stop: nil, limit: nil, scope: $openc3_scope)
139
+ now = DateTime.now.new_offset(0)
140
+ start_score = start.nil? ? (now - 7).strftime('%s').to_i : _cal_to_epoch(start)
141
+ stop_score = stop.nil? ? (now + 7).strftime('%s').to_i : _cal_to_epoch(stop)
142
+ limit ||= ((stop_score - start_score) / 60).to_i
143
+ ActivityModel.get(name: name, scope: scope, start: start_score, stop: stop_score, limit: limit)
144
+ end
145
+
146
+ # Removes an activity (or all members of its recurring group when recurring is truthy).
147
+ # @return [Integer] number of activities removed (0 indicates not found)
148
+ def delete_timeline_activity(name, start, uuid, recurring: nil, scope: $openc3_scope)
149
+ ActivityModel.destroy(name: name, scope: scope, score: start.to_i, uuid: uuid, recurring: recurring)
150
+ end
151
+
152
+ # @return [Integer] count of activities on the timeline
153
+ def count_timeline_activities(name, scope: $openc3_scope)
154
+ ActivityModel.count(name: name, scope: scope)
155
+ end
156
+
157
+ # Commits an event to an existing activity.
158
+ # @return [Hash, nil] the activity as a hash, or nil if not found
159
+ def commit_timeline_activity(name, start, uuid, status:, message: nil, scope: $openc3_scope)
160
+ model = ActivityModel.score(name: name, score: start.to_i, uuid: uuid, scope: scope)
161
+ return nil if model.nil?
162
+ model.commit(status: status, message: message)
163
+ model.as_json()
164
+ end
165
+
166
+ # Convert a value to an epoch integer. Accepts Integer/Numeric (treated as already-epoch),
167
+ # numeric strings, and ISO-style date/time strings or DateTime/Time objects.
168
+ def _cal_to_epoch(value)
169
+ case value
170
+ when Integer
171
+ value
172
+ when Numeric
173
+ value.to_i
174
+ when DateTime, Time, Date
175
+ value.to_datetime.strftime('%s').to_i
176
+ else
177
+ s = value.to_s
178
+ return s.to_i if s.match?(/\A-?\d+\z/)
179
+ DateTime.parse(s).strftime('%s').to_i
180
+ end
181
+ end
182
+ end
183
+ end
@@ -457,6 +457,12 @@ module OpenC3
457
457
  alias subscribe_packet subscribe_packets
458
458
 
459
459
  # Get packets based on ID returned from subscribe_packet.
460
+ # Packets are ordered within each subscribed packet stream (target/packet pair)
461
+ # but are NOT interleaved by time across streams. If chronological order across
462
+ # streams is required, sort the returned array by the 'time' field. Sorting only
463
+ # orders the current batch - packets across separate get_packets calls may still
464
+ # arrive out of order, so subscribers needing global ordering must buffer and merge
465
+ # across calls.
460
466
  # @param id [String] ID returned from subscribe_packets or last call to get_packets
461
467
  # @param block [Integer] Unused - Blocking must be implemented at the client
462
468
  # @param count [Integer] Maximum number of packets to return from EACH packet stream
@@ -94,13 +94,15 @@ module OpenC3
94
94
  @limits_event_topic = "#{@scope}__openc3_limits_events"
95
95
  @topics << @limits_event_topic
96
96
  Topic.update_topic_offsets(@topics, db_shard: @db_shard)
97
+ # Initialize before assigning limits_change_callback - sync_system below
98
+ # can fire the callback synchronously for any persisted-disabled items.
99
+ @limits_response_queue = Queue.new
100
+ @limits_response_thread = nil
97
101
  System.telemetry.limits_change_callback = method(:limits_change_callback)
98
102
  LimitsEventTopic.sync_system(scope: @scope)
99
103
  @error_count = 0
100
104
  @metric.set(name: 'decom_total', value: @count, type: 'counter')
101
105
  @metric.set(name: 'decom_error_total', value: @error_count, type: 'counter')
102
- @limits_response_queue = Queue.new
103
- @limits_response_thread = nil
104
106
  end
105
107
 
106
108
  def run
@@ -155,11 +155,26 @@ module OpenC3
155
155
 
156
156
  prefix = "#{@scope}/microservices/#{@name}/"
157
157
  file_count = 0
158
- client.list_objects(bucket: bucket, prefix: prefix).each do |object|
159
- response_target = OpenC3.sanitize_path(File.join(@temp_dir, object.key.split(prefix)[-1]))
160
- FileUtils.mkdir_p(File.dirname(response_target))
161
- client.get_object(bucket: bucket, key: object.key, path: response_target)
162
- file_count += 1
158
+ # Tolerate transient object store failures during startup. On a busy or
159
+ # underpowered cluster the bucket store (e.g. MinIO) can be briefly
160
+ # unreachable while many microservices start at once. Rather than crash
161
+ # and CrashLoopBackOff (which can outlast deploy timeouts), retry for a
162
+ # bounded time before giving up.
163
+ startup_timeout = (ENV['OPENC3_MICROSERVICE_STARTUP_BUCKET_TIMEOUT'] || 60).to_f
164
+ startup_deadline = Time.now + startup_timeout
165
+ begin
166
+ file_count = 0
167
+ client.list_objects(bucket: bucket, prefix: prefix).each do |object|
168
+ response_target = OpenC3.sanitize_path(File.join(@temp_dir, object.key.split(prefix)[-1]))
169
+ FileUtils.mkdir_p(File.dirname(response_target))
170
+ client.get_object(bucket: bucket, key: object.key, path: response_target)
171
+ file_count += 1
172
+ end
173
+ rescue => error
174
+ raise if Time.now >= startup_deadline
175
+ @logger.warn("Microservice #{@name} startup: bucket access failed (#{error.class}: #{error.message}); retrying for up to #{startup_timeout.to_i}s")
176
+ sleep(5)
177
+ retry
163
178
  end
164
179
 
165
180
  # Adjust @work_dir to microservice files downloaded if files and a relative path
@@ -158,12 +158,12 @@ module OpenC3
158
158
  variables[current_variable_name]['description'] = params[0]
159
159
  when 'VARIABLE_STATE'
160
160
  usage = "#{keyword} <Display Text> <Value>"
161
- parser.verify_num_parameters(2, 2, usage)
161
+ parser.verify_num_parameters(1, 2, usage)
162
162
  unless current_variable_name
163
163
  raise "VARIABLE_STATE must follow a VARIABLE definition"
164
164
  end
165
165
  variables[current_variable_name]['options'] ||= []
166
- option = { 'value' => params[1], 'text' => params[0] }
166
+ option = { 'value' => params[1] || params[0], 'text' => params[0] }
167
167
  variables[current_variable_name]['options'] << option
168
168
  end
169
169
  end
@@ -229,12 +229,13 @@ module OpenC3
229
229
  plugin_model.homepage = pkg.spec.homepage
230
230
  plugin_model.repository = pkg.spec.metadata['source_code_uri'] # this key because it's in the official gemspec examples
231
231
  plugin_model.keywords = pkg.spec.metadata['openc3_store_keywords']&.split(/, ?/)
232
- img_path = pkg.spec.metadata['openc3_store_image']
233
- unless img_path
234
- default_img_path = 'public/store_img.png'
235
- full_default_path = File.join(gem_path, default_img_path)
236
- img_path = default_img_path if File.exist? full_default_path
237
- end
232
+ # Resolve the plugin's store image path. The gemspec generated by
233
+ # `cli generate plugin` sets `openc3_store_image` to `public/store_img.png`
234
+ # but does not create that file, so we must verify existence regardless
235
+ # of whether the path came from metadata or the default — otherwise the
236
+ # frontend hits a 500 when fetching the missing file.
237
+ img_path = pkg.spec.metadata['openc3_store_image'] || 'public/store_img.png'
238
+ img_path = nil unless File.exist?(File.join(gem_path, img_path))
238
239
  package_name = "#{pkg.spec.name}-#{pkg.spec.version}"
239
240
  plugin_model.img_path = File.join('gems', package_name, img_path) if img_path # convert this filesystem path to volumes mount path
240
241
  plugin_model.update() unless validate_only
@@ -525,5 +526,16 @@ module OpenC3
525
526
  end
526
527
  return result.sort
527
528
  end
529
+
530
+ # Remove the backing gem for an unloaded plugin so it disappears from
531
+ # GemModel.names (and the admin Packages tab). Skips the removal if any
532
+ # other PluginModel (any scope, any counter) still references the gem,
533
+ # via the existing PluginModel.gem_names check inside GemModel.destroy.
534
+ def self.cleanup_gem(plugin_name, scope:)
535
+ gem_filename = plugin_name.split("__")[0]
536
+ OpenC3::GemModel.destroy(gem_filename, log_and_raise_needed_errors: false)
537
+ rescue => e
538
+ Logger.warn("Could not remove gem #{gem_filename}: #{e.message}", scope: scope)
539
+ end
528
540
  end
529
541
  end
@@ -44,34 +44,37 @@ module OpenC3
44
44
  model = get_model(name: name, scope: scope)
45
45
  raise QueueError, "Queue '#{name}' not found in scope '#{scope}'" unless model
46
46
 
47
- if model.state != 'DISABLE'
48
- result = Store.zrevrange("#{scope}:#{name}", 0, 0, with_scores: true)
49
- if result.empty?
50
- id = 1.0
51
- else
52
- id = result[0][1].to_f + 1
53
- end
54
-
55
- # Build command data with support for both formats
56
- command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch, validate: validate, timeout: timeout }
57
- if target_name && cmd_name
58
- # New format: store target_name, cmd_name, and cmd_params separately
59
- command_data[:target_name] = target_name
60
- command_data[:cmd_name] = cmd_name
61
- command_data[:cmd_params] = JSON.generate(cmd_params.as_json, allow_nan: true) if cmd_params
62
- elsif command
63
- # Legacy format: store command string for backwards compatibility
64
- command_data[:value] = command
65
- else
66
- raise QueueError, "Must provide either command string or target_name/cmd_name parameters"
67
- end
68
-
69
- Store.zadd("#{scope}:#{name}", id, command_data.to_json)
70
- model.notify(kind: 'command')
71
- else
47
+ if model.state == 'DISABLE'
72
48
  error_msg = command || "#{target_name} #{cmd_name}"
73
49
  raise QueueError, "Queue '#{name}' is disabled. Command '#{error_msg}' not queued."
74
50
  end
51
+
52
+ result = Store.zrevrange("#{scope}:#{name}", 0, 0, with_scores: true)
53
+ id = result.empty? ? 1.0 : result[0][1].to_f + 1
54
+
55
+ command_data = build_command_data(username: username, command: command, target_name: target_name,
56
+ cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
57
+ Store.zadd("#{scope}:#{name}", id, command_data.to_json)
58
+ model.notify(kind: 'command')
59
+ end
60
+
61
+ # Build the hash that gets serialized into Redis. Always uses symbol keys so
62
+ # downstream code in this class can access values consistently. cmd_params is
63
+ # JSON-encoded as a string so binary data survives the round-trip via as_json.
64
+ def self.build_command_data(username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
65
+ command_data = { username: username, timestamp: Time.now.to_nsec_from_epoch }
66
+ if target_name && cmd_name
67
+ command_data[:target_name] = target_name
68
+ command_data[:cmd_name] = cmd_name
69
+ command_data[:cmd_params] = JSON.generate(cmd_params.as_json, allow_nan: true) if cmd_params
70
+ elsif command
71
+ command_data[:value] = command
72
+ else
73
+ raise QueueError, "Must provide either command string or target_name/cmd_name parameters"
74
+ end
75
+ command_data[:validate] = validate unless validate.nil?
76
+ command_data[:timeout] = timeout unless timeout.nil?
77
+ command_data
75
78
  end
76
79
 
77
80
  attr_accessor :name, :state
@@ -115,49 +118,36 @@ module OpenC3
115
118
  QueueTopic.write_notification(notification, scope: @scope)
116
119
  end
117
120
 
118
- def insert_command(id, command_data)
121
+ def insert_command(id: nil, username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
119
122
  if @state == 'DISABLE'
120
- if command_data['value']
121
- command_name = command_data['value']
122
- else
123
- command_name = "#{command_data['target_name']} #{command_data['cmd_name']}"
124
- end
123
+ command_name = command || "#{target_name} #{cmd_name}"
125
124
  raise QueueError, "Queue '#{@name}' is disabled. Command '#{command_name}' not queued."
126
125
  end
127
126
 
128
127
  unless id
129
128
  result = Store.zrevrange("#{@scope}:#{@name}", 0, 0, with_scores: true)
130
- if result.empty?
131
- id = 1.0
132
- else
133
- id = result[0][1].to_f + 1
134
- end
129
+ id = result.empty? ? 1.0 : result[0][1].to_f + 1
135
130
  end
136
131
 
137
- # Convert cmd_params values to JSON-safe format if present
138
- if command_data['cmd_params']
139
- command_data['cmd_params'] = JSON.generate(command_data['cmd_params'].as_json, allow_nan: true)
140
- end
132
+ command_data = self.class.build_command_data(username: username, command: command, target_name: target_name,
133
+ cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
141
134
  Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
142
135
  notify(kind: 'command')
143
136
  end
144
137
 
145
- def update_command(id:, command:, username:, validate: nil, timeout: nil)
138
+ def update_command(id:, username:, command: nil, target_name: nil, cmd_name: nil, cmd_params: nil, validate: nil, timeout: nil)
146
139
  if @state == 'DISABLE'
147
140
  raise QueueError, "Queue '#{@name}' is disabled. Command at id #{id} not updated."
148
141
  end
149
142
 
150
- # Check if command exists at the given id
151
143
  existing = Store.zrangebyscore("#{@scope}:#{@name}", id, id)
152
144
  if existing.empty?
153
145
  raise QueueError, "No command found at id #{id} in queue '#{@name}'"
154
146
  end
155
147
 
156
- # Remove the existing command and add the new one at the same id
157
148
  Store.zremrangebyscore("#{@scope}:#{@name}", id, id)
158
- command_data = { username: username, value: command, timestamp: Time.now.to_nsec_from_epoch }
159
- command_data[:validate] = validate unless validate.nil?
160
- command_data[:timeout] = timeout unless timeout.nil?
149
+ command_data = self.class.build_command_data(username: username, command: command, target_name: target_name,
150
+ cmd_name: cmd_name, cmd_params: cmd_params, validate: validate, timeout: timeout)
161
151
  Store.zadd("#{@scope}:#{@name}", id, command_data.to_json)
162
152
  notify(kind: 'command')
163
153
  end
@@ -107,7 +107,7 @@ module OpenC3
107
107
  group:,
108
108
  left:,
109
109
  operator:,
110
- right:,
110
+ right: nil,
111
111
  state: false,
112
112
  enabled: true,
113
113
  dependents: nil,
@@ -238,6 +238,11 @@ module OpenC3
238
238
 
239
239
  OperatorProcess.setup()
240
240
  @cycle_time = (ENV['OPERATOR_CYCLE_TIME'] and ENV['OPERATOR_CYCLE_TIME'].to_f) || CYCLE_TIME # time in seconds
241
+ # Maximum number of new microservices to start per cycle. This spreads a
242
+ # large startup burst (e.g. installing a plugin with many targets) across
243
+ # multiple cycles so the shared services (object store, redis) aren't
244
+ # stampeded by every microservice connecting at once. 0 = no limit.
245
+ @max_start_per_cycle = (ENV['OPENC3_OPERATOR_MAX_START_PER_CYCLE'] || 5).to_i
241
246
 
242
247
  @ruby_process_name = ENV['OPENC3_RUBY']
243
248
  if RUBY_ENGINE != 'ruby'
@@ -262,10 +267,17 @@ module OpenC3
262
267
  def start_new
263
268
  @mutex.synchronize do
264
269
  if @new_processes.length > 0
265
- # Start all the processes
266
- Logger.info("#{self.class} starting each new process...")
267
- @new_processes.each { |_name, p| p.start }
268
- @new_processes = {}
270
+ # Start at most @max_start_per_cycle processes this cycle; any
271
+ # remaining stay queued in @new_processes and start on later cycles.
272
+ # This avoids a startup stampede when many microservices appear at
273
+ # once (e.g. a plugin install) overwhelming the object store / redis.
274
+ start_names = @new_processes.keys
275
+ start_names = start_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
276
+ Logger.info("#{self.class} starting #{start_names.length} of #{@new_processes.length} new process(es)...")
277
+ start_names.each do |name|
278
+ @new_processes[name].start
279
+ @new_processes.delete(name)
280
+ end
269
281
  end
270
282
  end
271
283
  end
@@ -273,12 +285,22 @@ module OpenC3
273
285
  def respawn_changed
274
286
  @mutex.synchronize do
275
287
  if @changed_processes.length > 0
276
- Logger.info("Cycling #{@changed_processes.length} changed microservices...")
277
- shutdown_processes(@changed_processes)
288
+ # Cycle at most @max_start_per_cycle changed microservices this cycle;
289
+ # any remaining stay queued in @changed_processes and are cycled on
290
+ # later cycles. This avoids a restart stampede when many microservices
291
+ # change at once (e.g. a configmap change) overwhelming shared
292
+ # services. Processes not yet cycled keep running until their turn.
293
+ cycle_names = @changed_processes.keys
294
+ cycle_names = cycle_names[0...@max_start_per_cycle] if @max_start_per_cycle > 0
295
+ cycle = @changed_processes.slice(*cycle_names)
296
+ Logger.info("Cycling #{cycle.length} of #{@changed_processes.length} changed microservices...")
297
+ shutdown_processes(cycle)
278
298
  break if @shutdown
279
299
 
280
- @changed_processes.each { |_name, p| p.start }
281
- @changed_processes = {}
300
+ cycle_names.each do |name|
301
+ @changed_processes[name].start
302
+ @changed_processes.delete(name)
303
+ end
282
304
  end
283
305
  end
284
306
  end
@@ -295,8 +317,11 @@ module OpenC3
295
317
 
296
318
  def respawn_dead
297
319
  @mutex.synchronize do
298
- @processes.each do |_name, p|
320
+ @processes.each do |name, p|
299
321
  break if @shutdown
322
+ # Skip processes still queued by the per-cycle start limit; they
323
+ # haven't been started yet so they aren't "dead" to be respawned.
324
+ next if @new_processes[name]
300
325
  p.output_increment
301
326
  unless p.alive?
302
327
  # Respawn process
@@ -437,12 +437,25 @@ module OpenC3
437
437
  if packet.id_items.length > 0
438
438
  key = []
439
439
  id_signature = ""
440
- # Accessor class is part of the signature so packets in the same target
441
- # with different accessors trigger unique_id_mode (different accessors
442
- # decode the buffer differently, so the hash-lookup path is unsafe).
440
+ # Accessor class AND args are part of the signature so packets in the
441
+ # same target with different accessors -- or the same accessor class
442
+ # configured with different args -- trigger unique_id_mode. Different
443
+ # accessors (or same accessor, different args) decode the buffer
444
+ # differently, so the shared hash-lookup path is unsafe.
443
445
  packet.id_items.each do |item|
444
446
  key << item.id_value
445
- id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}"
447
+ id_signature << "__#{item.key}__#{item.bit_offset}__#{item.bit_size}__#{item.data_type}__#{packet.accessor.class.to_s}__#{packet.accessor.args.inspect}"
448
+ # STRUCTURE-derived id_items are decoded by the parent's structure
449
+ # accessor (see Accessor#read_item), so include that accessor's class
450
+ # and args in the signature too.
451
+ if item.parent_item
452
+ parent = packet.get_item(item.parent_item)
453
+ structure = parent.structure
454
+ if structure
455
+ structure_accessor = structure.accessor
456
+ id_signature << "__#{structure_accessor.class.to_s}__#{structure_accessor.args.inspect}"
457
+ end
458
+ end
446
459
  end
447
460
  target_id_value_hash[key] = packet
448
461
  target_id_signature = id_signature_hash[packet.target_name]