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
@@ -18,6 +18,18 @@ module OpenC3
18
18
  class PypiUrl
19
19
  DEFAULT = 'https://pypi.org/simple'
20
20
 
21
+ # Turn a configured PyPI base URL (the pypi_url setting or PYPI_URL) into
22
+ # its simple index URL. Trailing slashes are dropped first, matching the
23
+ # ${PYPI_URL%/}/simple the image builds use, so https://mirror/ does not
24
+ # give https://mirror//simple and https://pypi.org/ still equals DEFAULT.
25
+ #
26
+ # @param base_url [String, nil] the configured base url
27
+ # @return [String, nil] the simple index url, or nil when base_url is nil
28
+ def self.index_url(base_url)
29
+ return nil if base_url.nil?
30
+ "#{base_url.sub(%r{/+\z}, '')}/simple"
31
+ end
32
+
21
33
  # Validate that a resolved pypi_url is an http(s) URL before it is handed to
22
34
  # pip. The value can come from a user-writable setting or ENV, so a malformed
23
35
  # or non-http value is rejected and replaced with the default rather than
@@ -35,5 +47,58 @@ module OpenC3
35
47
  Logger.error("Invalid pypi_url '#{pypi_url}' (#{e.message}); falling back to #{DEFAULT}")
36
48
  DEFAULT
37
49
  end
50
+
51
+ # Environment variable that opts a deployment into insecure (unverified TLS)
52
+ # connections to the pypi index host, for operators running a private index
53
+ # with a self-signed certificate. OPENC3_ prefixed like our other settings,
54
+ # and kept out of uv's own UV_* namespace (uv reads UV_INSECURE_HOST itself).
55
+ INSECURE_HOST_ENV = 'OPENC3_PYPI_ALLOW_INSECURE'
56
+
57
+ # Previous name for INSECURE_HOST_ENV, still honored so existing helm values
58
+ # and compose files keep working. It was named for pip's --trusted-host, but
59
+ # every install path goes through uv (openc3/bin/pipinstall is a uv pip
60
+ # install wrapper), so the flag it produces is uv's --allow-insecure-host.
61
+ DEPRECATED_INSECURE_HOST_ENV = 'PIP_ENABLE_TRUSTED_HOST'
62
+
63
+ # Build the argv array of index arguments shared by every uv invocation.
64
+ #
65
+ # --default-index rather than -i/--index-url: the latter is deprecated by uv
66
+ # on both `uv sync` and `uv pip install`. Note this only affects the index uv
67
+ # resolves against, so it has no effect on `uv sync --frozen`, where uv.lock
68
+ # already pins each package's registry and wheel URL.
69
+ #
70
+ # --allow-insecure-host rather than --trusted-host: --trusted-host is an
71
+ # undocumented uv alias, so use the real uv option.
72
+ #
73
+ # @param pypi_url [String] the resolved and validated pypi index url
74
+ # @return [Array<String>] arguments to append to a uv command line
75
+ def self.build_args(pypi_url)
76
+ args = ["--default-index", pypi_url]
77
+ # --default-index does not win on its own: uv searches a named index
78
+ # declared in the plugin's own [tool.uv].index table first, so a plugin
79
+ # can quietly resolve against its author's index instead of the one the
80
+ # operator configured. --no-config makes uv ignore that table. Gated on
81
+ # the operator having actually designated an index, because at the public
82
+ # default there is no operator policy to enforce and the plugin author's
83
+ # own index configuration is the only signal there is.
84
+ #
85
+ # This is half the story: a package pinned with [tool.uv].sources is
86
+ # project metadata that survives --no-config. openc3/bin/uvinstall and
87
+ # openc3/bin/pipinstall add --no-sources for that, on the commands where
88
+ # uv actually resolves.
89
+ args << "--no-config" if pypi_url != DEFAULT
90
+ if allow_insecure_host?
91
+ args += ["--allow-insecure-host", URI.parse(pypi_url).host]
92
+ end
93
+ args
94
+ end
95
+
96
+ # @return [Boolean] whether the deployment opted into insecure connections
97
+ # to the pypi index host
98
+ def self.allow_insecure_host?
99
+ # Empty counts as unset: compose passes both names through as
100
+ # ${VAR:-}, so an unset .env entry arrives as an empty string.
101
+ !ENV[INSECURE_HOST_ENV].to_s.empty? || !ENV[DEPRECATED_INSECURE_HOST_ENV].to_s.empty?
102
+ end
38
103
  end
39
104
  end
@@ -77,8 +77,9 @@ module OpenC3
77
77
  end
78
78
 
79
79
  # Get or create a thread-local PG connection for the given db_shard with type mapping configured.
80
+ # The port defaults to OPENC3_TSDB_QUERY_PORT.
80
81
  # Returns the thread-local connection - callers should not close it.
81
- def self.connection(db_shard: 0)
82
+ def self.connection(db_shard: 0, port: nil)
82
83
  conns = @thread_conns.value
83
84
  conn = conns[db_shard]
84
85
  if conn and not conn.finished?
@@ -91,7 +92,7 @@ module OpenC3
91
92
  end
92
93
  conn = PG::Connection.new(
93
94
  host: hostname_for_db_shard(db_shard),
94
- port: ENV['OPENC3_TSDB_QUERY_PORT'],
95
+ port: port || ENV.fetch('OPENC3_TSDB_QUERY_PORT', nil),
95
96
  user: ENV['OPENC3_TSDB_USERNAME'],
96
97
  password: ENV['OPENC3_TSDB_PASSWORD'],
97
98
  dbname: 'qdb'
@@ -122,11 +123,12 @@ module OpenC3
122
123
  end
123
124
 
124
125
  # Health check - attempt to connect and immediately close.
126
+ # The port defaults to OPENC3_TSDB_QUERY_PORT.
125
127
  # Returns true if successful, raises on failure.
126
- def self.check_connection(db_shard: 0)
128
+ def self.check_connection(db_shard: 0, port: nil)
127
129
  conn = PG::Connection.new(
128
130
  host: hostname_for_db_shard(db_shard),
129
- port: ENV['OPENC3_TSDB_QUERY_PORT'],
131
+ port: port || ENV.fetch('OPENC3_TSDB_QUERY_PORT', nil),
130
132
  user: ENV['OPENC3_TSDB_USERNAME'],
131
133
  password: ENV['OPENC3_TSDB_PASSWORD'],
132
134
  dbname: 'qdb'
@@ -628,10 +630,44 @@ module OpenC3
628
630
  query += " LIMIT 1"
629
631
  result = query_with_retry(query, max_retries: 1, label: "table_has_data", db_shard: db_shard)
630
632
  result && result.ntuples > 0
631
- rescue RuntimeError
633
+ rescue QuestDBError, RuntimeError
634
+ # A table which doesn't exist fails the query
632
635
  false
633
636
  end
634
637
 
638
+ # Return the telemetry packets which have a table. The TSDB microservice only
639
+ # creates tables for its initial packets so a packet added later has no table
640
+ # until it's received. If the table names can't be fetched assume they all exist.
641
+ #
642
+ # @param packets [Array<Array(String, String)>] [target_name, packet_name] pairs
643
+ # @return [Set<Array(String, String)>] The pairs which have a table
644
+ def self.tlm_packets_with_tables(packets, scope: "DEFAULT")
645
+ existing = Set.new
646
+ packets.group_by { |target_name, _| db_shard_for_target(target_name, scope: scope) }.each do |db_shard, shard_packets|
647
+ names = table_names(db_shard: db_shard)
648
+ shard_packets.each do |target_name, packet_name|
649
+ if names.nil? or names.include?(sanitize_table_name(target_name, packet_name, scope: scope).upcase)
650
+ existing << [target_name, packet_name]
651
+ end
652
+ end
653
+ end
654
+ existing
655
+ end
656
+
657
+ # Return the names of all tables in a single query. Used to avoid querying
658
+ # (and failing on) tables that were never created, e.g. commands that have
659
+ # never been sent, which is the common case for targets with thousands of packets.
660
+ # QuestDB table names are case insensitive so the names are upcased.
661
+ #
662
+ # @return [Set<String>, nil] Upcased table names, or nil if they cannot be queried
663
+ def self.table_names(db_shard: 0)
664
+ result = query_with_retry("SELECT table_name FROM tables()", max_retries: 1, label: "table names", db_shard: db_shard)
665
+ return nil unless result
666
+ result.values.each_with_object(Set.new) { |row, set| set << row[0].to_s.upcase }
667
+ rescue StandardError
668
+ nil
669
+ end
670
+
635
671
  # QuestDB column types that can be aggregated with min/max/avg/stddev.
636
672
  # Used by reduced queries to decide whether a converted (__C) column is
637
673
  # numeric or must fall back to the raw column (e.g. states stored as VARCHAR).
@@ -918,19 +954,23 @@ module OpenC3
918
954
  # Query historical telemetry data from QuestDB for a list of items.
919
955
  # Builds the SQL query, executes it, and decodes all results.
920
956
  # Supports cross-db_shard queries by grouping items by db_shard, executing
921
- # separate queries per db_shard, and merging results positionally.
957
+ # separate queries per db_shard, and aligning the results by packet time.
922
958
  #
923
959
  # @param items [Array] Array of [target_name, packet_name, item_name, value_type, limits]
924
960
  # item_name may be nil to indicate a placeholder (non-existent item)
925
961
  # @param start_time [String, Numeric] Start timestamp for the query
926
962
  # @param end_time [String, Numeric, nil] End timestamp, or nil for "latest single row"
927
963
  # @param scope [String] Scope name
964
+ # @param flatten [Boolean] Whether a single row result is returned as a flat array
928
965
  # @return [Array, Hash] Array of [value, limits_state] pairs per row, or {} if no results.
929
- # Single-row results return a flat array; multi-row results return array of arrays.
930
- def self.tsdb_lookup(items, start_time:, end_time: nil, scope: "DEFAULT")
966
+ # Single-row results return a flat array (unless flatten is false); multi-row results return array of arrays.
967
+ def self.tsdb_lookup(items, start_time:, end_time: nil, scope: "DEFAULT", flatten: true)
931
968
  # Every item is a placeholder for an item which doesn't exist, so there's
932
969
  # nothing to query. Return a single row of nil values, one per item.
933
- return Array.new(items.length) { [nil, nil] } if items.all? { |item| item[2].nil? }
970
+ if items.all? { |item| item[2].nil? }
971
+ row = Array.new(items.length) { [nil, nil] }
972
+ return flatten ? row : [row]
973
+ end
934
974
 
935
975
  # Group items by db_shard number while preserving their original positions
936
976
  db_shard_groups = {} # db_shard => { positions: [], items: [] }
@@ -945,39 +985,95 @@ module OpenC3
945
985
  # Single-db_shard fast path (most common case)
946
986
  if db_shard_groups.length == 1
947
987
  db_shard, group = db_shard_groups.first
948
- return tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
988
+ return tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard, flatten: flatten)
949
989
  end
950
990
 
951
- # Cross-db_shard: execute per-db_shard queries (as arrays of rows) and merge results
952
- db_shard_rows = {} # db_shard => rows
953
- db_shard_groups.each do |db_shard, group|
954
- result = tsdb_lookup_single_db_shard(group[:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard, flatten: false)
955
- db_shard_rows[db_shard] = result.is_a?(Array) ? result : []
956
- end
991
+ # Cross-db_shard: a single query can't ASOF JOIN tables on different QuestDBs
992
+ tsdb_lookup_db_shards(items, db_shard_groups, start_time: start_time, end_time: end_time, scope: scope, flatten: flatten)
993
+ end
957
994
 
958
- # Merge results positionally back into the original item order. Each db_shard
959
- # may have different row counts so use the maximum row count and fill missing
960
- # positions with [nil, nil]. If all db_shards returned empty, return empty.
961
- max_rows = db_shard_rows.values.map(&:length).max
962
- return {} if max_rows == 0
995
+ # Cross-db_shard lookup which matches the rows of a single db_shard ASOF JOIN.
996
+ # The packet of the first item drives the rows (like T0 in a single db_shard query) so
997
+ # its db_shard is queried as usual. Every packet on another db_shard is queried on its
998
+ # own and aligned to each driving row's packet time, using its last row at or before
999
+ # that time (like ASOF JOIN). Each packet is aligned independently and includes its
1000
+ # last row before start_time, so its value carries forward like it would in ASOF JOIN.
1001
+ def self.tsdb_lookup_db_shards(items, db_shard_groups, start_time:, end_time:, scope:, flatten: true)
1002
+ driver = db_shard_for_target(items.find { |item| item[2] }[0], scope: scope)
1003
+ driver_rows = tsdb_lookup_single_db_shard(db_shard_groups[driver][:items], start_time: start_time, end_time: end_time, scope: scope, db_shard: driver, flatten: false, with_time: true)
1004
+ return {} if !driver_rows.is_a?(Array) or driver_rows.empty?
1005
+
1006
+ # The packet time in nanoseconds is the last value of each row
1007
+ times = driver_rows.map { |row| row[-1][0] }
1008
+
1009
+ merged = driver_rows.map do |driver_row|
1010
+ row = Array.new(items.length) { [nil, nil] }
1011
+ db_shard_groups[driver][:positions].each_with_index do |orig_pos, db_shard_idx|
1012
+ row[orig_pos] = driver_row[db_shard_idx] if driver_row[db_shard_idx]
1013
+ end
1014
+ row
1015
+ end
963
1016
 
964
- merged = Array.new(max_rows) { Array.new(items.length) { [nil, nil] } }
965
1017
  db_shard_groups.each do |db_shard, group|
966
- db_shard_rows[db_shard].each_with_index do |row, row_num|
967
- group[:positions].each_with_index do |orig_pos, db_shard_idx|
968
- merged[row_num][orig_pos] = row[db_shard_idx] if row[db_shard_idx]
1018
+ next if db_shard == driver
1019
+
1020
+ # Group the items by packet, leaving placeholders as nils
1021
+ packets = {} # [target_name, packet_name] => { positions: [], items: [] }
1022
+ group[:positions].zip(group[:items]).each do |orig_pos, item|
1023
+ next unless item[2]
1024
+
1025
+ packet = (packets[[item[0], item[1]]] ||= { positions: [], items: [] })
1026
+ packet[:positions] << orig_pos
1027
+ packet[:items] << item
1028
+ end
1029
+
1030
+ packets.each_value do |packet|
1031
+ rows = tsdb_lookup_packet_rows(packet[:items], times, start_time: start_time, end_time: end_time, scope: scope, db_shard: db_shard)
1032
+ index = -1 # Index of the last packet row at or before the driving row
1033
+ times.each_with_index do |driver_time, row_num|
1034
+ index += 1 while index + 1 < rows.length and rows[index + 1][-1][0] <= driver_time
1035
+ next if index < 0
1036
+
1037
+ packet[:positions].each_with_index do |orig_pos, packet_idx|
1038
+ merged[row_num][orig_pos] = rows[index][packet_idx] if rows[index][packet_idx]
1039
+ end
969
1040
  end
970
1041
  end
971
1042
  end
1043
+
972
1044
  # Match the single db_shard behavior of returning a flat array for a single row
973
- return merged[0] if max_rows == 1
1045
+ return merged[0] if flatten and merged.length == 1
1046
+
974
1047
  merged
975
1048
  end
976
1049
 
1050
+ # Query the rows of a single packet needed to align it to the driving row times.
1051
+ # Each row ends with its packet time in nanoseconds.
1052
+ def self.tsdb_lookup_packet_rows(items, times, start_time:, end_time:, scope:, db_shard:)
1053
+ if end_time
1054
+ # The last row before start_time followed by the rows in the range
1055
+ queries = [{ start_time: start_time }, { start_time: start_time, end_time: end_time }]
1056
+ else
1057
+ # The single driving row is before start_time so get the last row at or before it
1058
+ queries = [{ start_time: nsec_to_iso(times[0]), inclusive: true }]
1059
+ end
1060
+ queries.flat_map do |query|
1061
+ result = tsdb_lookup_single_db_shard(items, scope: scope, db_shard: db_shard, flatten: false, with_time: true, **query)
1062
+ result.is_a?(Array) ? result : []
1063
+ end
1064
+ end
1065
+
1066
+ # Convert a nanosecond integer timestamp to an ISO 8601 string with nanosecond precision
1067
+ def self.nsec_to_iso(nsec)
1068
+ Time.at(nsec / 1_000_000_000, nsec % 1_000_000_000, :nsec).utc.strftime('%Y-%m-%dT%H:%M:%S.%9NZ')
1069
+ end
1070
+
977
1071
  # Execute a tsdb_lookup query against a single db_shard.
978
1072
  # This contains the original ASOF JOIN logic for items all on the same QuestDB instance.
979
1073
  # When flatten is true a single row result is returned as a flat array rather than an array of rows.
980
- def self.tsdb_lookup_single_db_shard(items, start_time:, end_time: nil, scope: "DEFAULT", db_shard: 0, flatten: true)
1074
+ # When inclusive is true a query without an end_time returns the last row at or before start_time.
1075
+ # When with_time is true each row ends with its packet time in nanoseconds.
1076
+ def self.tsdb_lookup_single_db_shard(items, start_time:, end_time: nil, scope: "DEFAULT", db_shard: 0, flatten: true, inclusive: false, with_time: false)
981
1077
  tables = {}
982
1078
  names = []
983
1079
  nil_count = 0
@@ -986,6 +1082,14 @@ module OpenC3
986
1082
  calculated_items = {}
987
1083
  needed_timestamps = {}
988
1084
  current_position = 0
1085
+ # Columns with the same name in different tables come back renamed by QuestDB
1086
+ # (e.g. VALUE and VALUE_2) so alias every column and map it back when decoding
1087
+ aliases = {}
1088
+ add_column = lambda do |col_name|
1089
+ alias_name = "__col#{aliases.length}"
1090
+ aliases[alias_name] = col_name
1091
+ names << "\"#{col_name}\" as #{alias_name}"
1092
+ end
989
1093
 
990
1094
  items.each do |item|
991
1095
  target_name, packet_name, orig_item_name, value_type, limits = item
@@ -1000,7 +1104,7 @@ module OpenC3
1000
1104
  index = tables.find_index {|k,_v| k == table_name }
1001
1105
 
1002
1106
  if STORED_TIMESTAMP_ITEMS.include?(orig_item_name)
1003
- names << "\"T#{index}.#{orig_item_name}\""
1107
+ add_column.call("T#{index}.#{orig_item_name}")
1004
1108
  current_position += 1
1005
1109
  next
1006
1110
  end
@@ -1030,11 +1134,11 @@ module OpenC3
1030
1134
 
1031
1135
  suffix = column_suffix_for_value_type(value_type)
1032
1136
  col_name = "T#{index}.#{safe_item_name}#{suffix}"
1033
- names << "\"#{col_name}\""
1137
+ add_column.call(col_name)
1034
1138
  item_types[col_name] = resolve_item_type(item_def, value_type)
1035
1139
  current_position += 1
1036
1140
  if limits
1037
- names << "\"T#{index}.#{safe_item_name}__L\""
1141
+ add_column.call("T#{index}.#{safe_item_name}__L")
1038
1142
  end
1039
1143
  end
1040
1144
 
@@ -1049,6 +1153,7 @@ module OpenC3
1049
1153
  names << "T#{table_index}.#{ts_col} as T#{table_index}___ts_#{ts_col}"
1050
1154
  end
1051
1155
  end
1156
+ names << "cast(T0.PACKET_TIMESECONDS as long) as __time" if with_time
1052
1157
 
1053
1158
  # Build the SQL query
1054
1159
  query = "SELECT #{names.join(", ")} FROM "
@@ -1061,7 +1166,7 @@ module OpenC3
1061
1166
  end
1062
1167
  query_params = []
1063
1168
  if start_time && !end_time
1064
- query += "WHERE T0.PACKET_TIMESECONDS < $1 LIMIT -1"
1169
+ query += "WHERE T0.PACKET_TIMESECONDS #{inclusive ? '<=' : '<'} $1 LIMIT -1"
1065
1170
  query_params << start_time
1066
1171
  elsif start_time && end_time
1067
1172
  query += "WHERE T0.PACKET_TIMESECONDS >= $1 AND T0.PACKET_TIMESECONDS < $2"
@@ -1080,13 +1185,16 @@ module OpenC3
1080
1185
  row_index = 0
1081
1186
  row_timestamps = {}
1082
1187
  tuples.each do |tuple|
1083
- col_name = tuple[0]
1188
+ col_name = aliases.fetch(tuple[0], tuple[0])
1084
1189
  col_value = tuple[1]
1085
1190
  if col_name.include?("__L")
1086
1191
  data[row_num][row_index - 1][1] = col_value
1087
1192
  elsif col_name =~ /^__nil/
1088
1193
  data[row_num][row_index] = [nil, nil]
1089
1194
  row_index += 1
1195
+ elsif col_name == '__time'
1196
+ data[row_num][row_index] = [Integer(col_value), nil]
1197
+ row_index += 1
1090
1198
  elsif col_name =~ /^T(\d+)___ts_(.+)$/
1091
1199
  table_idx = $1.to_i
1092
1200
  ts_source = $2
@@ -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
@@ -71,6 +71,11 @@ module OpenC3
71
71
  klass.new
72
72
  end
73
73
 
74
+ # snake_case alias to match the Python Secrets.get_client
75
+ def self.get_client # rubocop:disable Naming/AccessorMethodName
76
+ getClient()
77
+ end
78
+
74
79
  def keys(secret_store: nil, scope:)
75
80
  raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
76
81
  end
@@ -54,11 +54,20 @@ 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
 
60
68
  attr_reader :redis_url
61
69
  attr_reader :redis_pool
70
+ attr_reader :db_shard
62
71
 
63
72
  # Look up the db_shard number for a target with a 1-minute cache.
64
73
  # Reads directly from Redis db_shard 0 to avoid circular deps with TargetModel.
@@ -116,6 +125,7 @@ module OpenC3
116
125
  end
117
126
 
118
127
  def initialize(pool_size = 10, db_shard: 0)
128
+ @db_shard = db_shard
119
129
  @redis_username = ENV.fetch('OPENC3_REDIS_USERNAME', nil)
120
130
  @redis_key = ENV.fetch('OPENC3_REDIS_PASSWORD', nil)
121
131
  hostname = ENV['OPENC3_REDIS_HOSTNAME'].to_s.gsub("SHARDNUM", db_shard.to_s)
@@ -225,8 +235,10 @@ module OpenC3
225
235
  return offsets
226
236
  end
227
237
 
238
+ # @param count [Integer] Maximum entries to return per stream. nil uses READ_TOPICS_DEFAULT_COUNT.
228
239
  def read_topics(topics, offsets = nil, timeout_ms = 1000, count = nil)
229
240
  return {} if topics.empty?
241
+ count ||= READ_TOPICS_DEFAULT_COUNT
230
242
  Thread.current[:topic_offsets] ||= {}
231
243
  topic_offsets = Thread.current[:topic_offsets]
232
244
  begin
@@ -264,13 +276,20 @@ module OpenC3
264
276
  # @option opts [String] :id the entry id, default value is `*`, it means auto generation,
265
277
  # if `nil` id is passed it will be changed to `*`
266
278
  # @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'
279
+ # @option opts [String] :approximate whether to add `~` modifier of maxlen or minid or not, default value is 'true'
280
+ # @option opts [String] :minid trim entries with ids lower than this as part of the add (XADD MINID).
281
+ # Cannot be combined with maxlen. See Topic.stream_safety_minid for the safety cap applied to target streams.
268
282
  #
269
283
  # @return [String] the entry id
270
- def write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true')
284
+ def write_topic(topic, msg_hash, id = '*', maxlen = nil, approximate = 'true', minid: nil)
271
285
  id = '*' if id.nil?
286
+ raise ArgumentError, "write_topic can't supply both maxlen and minid" if maxlen and minid
272
287
  @redis_pool.with do |redis|
273
- return redis.xadd(topic, msg_hash, id: id, maxlen: maxlen, approximate: approximate)
288
+ if minid
289
+ return redis.xadd(topic, msg_hash, id: id, minid: minid, approximate: approximate)
290
+ else
291
+ return redis.xadd(topic, msg_hash, id: id, maxlen: maxlen, approximate: approximate)
292
+ end
274
293
  end
275
294
  end
276
295
 
@@ -297,7 +316,7 @@ module OpenC3
297
316
 
298
317
  class EphemeralStore < Store
299
318
  def initialize(pool_size = 10, db_shard: 0)
300
- super(pool_size)
319
+ super(pool_size, db_shard: db_shard)
301
320
  hostname = ENV['OPENC3_REDIS_EPHEMERAL_HOSTNAME'].to_s.gsub("SHARDNUM", db_shard.to_s)
302
321
  @redis_url = "redis://#{hostname}:#{ENV.fetch('OPENC3_REDIS_EPHEMERAL_PORT', 6380)}"
303
322
  @redis_pool = StoreConnectionPool.new(size: pool_size) { build_redis() }
@@ -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