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.
- checksums.yaml +4 -4
- data/bin/pipinstall +18 -5
- data/bin/pipuninstall +1 -1
- data/bin/uvinstall +36 -8
- data/data/config/default_plugins.yaml +43 -0
- data/data/config/interface_modifiers.yaml +22 -7
- data/data/config/item_modifiers.yaml +14 -4
- data/data/config/plugins.yaml +1 -1
- data/data/config/widgets.yaml +1 -1
- data/lib/openc3/api/cmd_api.rb +10 -13
- data/lib/openc3/api/interface_api.rb +1 -2
- data/lib/openc3/api/limits_api.rb +149 -160
- data/lib/openc3/api/metrics_api.rb +26 -1
- data/lib/openc3/api/router_api.rb +1 -2
- data/lib/openc3/api/tlm_api.rb +225 -71
- data/lib/openc3/config/config_parser.rb +3 -1
- data/lib/openc3/core_ext/tempfile.rb +2 -0
- data/lib/openc3/interfaces/file_interface.rb +6 -1
- data/lib/openc3/interfaces/http_client_interface.rb +14 -6
- data/lib/openc3/interfaces/interface.rb +27 -3
- data/lib/openc3/interfaces/read_queue_interface.rb +331 -0
- data/lib/openc3/interfaces/stream_interface.rb +22 -3
- data/lib/openc3/interfaces/tcpip_server_interface.rb +13 -0
- data/lib/openc3/interfaces/udp_interface.rb +16 -2
- data/lib/openc3/interfaces.rb +1 -0
- data/lib/openc3/logs/log_writer.rb +14 -4
- data/lib/openc3/microservices/decom_microservice.rb +30 -5
- data/lib/openc3/microservices/interface_decom_common.rb +3 -1
- data/lib/openc3/microservices/interface_microservice.rb +146 -50
- data/lib/openc3/microservices/log_microservice.rb +1 -3
- data/lib/openc3/microservices/microservice.rb +25 -1
- data/lib/openc3/microservices/plugin_microservice.rb +32 -0
- data/lib/openc3/microservices/text_log_microservice.rb +1 -3
- data/lib/openc3/models/cvt_model.rb +7 -2
- data/lib/openc3/models/interface_model.rb +1 -1
- data/lib/openc3/models/interface_status_model.rb +6 -0
- data/lib/openc3/models/metric_model.rb +52 -4
- data/lib/openc3/models/microservice_model.rb +7 -3
- data/lib/openc3/models/plugin_model.rb +12 -12
- data/lib/openc3/models/python_package_model.rb +3 -13
- data/lib/openc3/models/target_model.rb +92 -15
- data/lib/openc3/operators/microservice_operator.rb +5 -0
- data/lib/openc3/operators/operator.rb +69 -0
- data/lib/openc3/packets/packet.rb +10 -4
- data/lib/openc3/packets/packet_config.rb +12 -1
- data/lib/openc3/packets/packet_item.rb +40 -22
- data/lib/openc3/packets/parsers/state_parser.rb +1 -1
- data/lib/openc3/packets/structure_item.rb +3 -2
- data/lib/openc3/script/api_shared.rb +62 -86
- data/lib/openc3/script/extract.rb +263 -20
- data/lib/openc3/script/plugins.rb +4 -11
- data/lib/openc3/script/script.rb +1 -1
- data/lib/openc3/script/storage.rb +81 -10
- data/lib/openc3/system/system.rb +9 -6
- data/lib/openc3/topics/command_decom_topic.rb +1 -1
- data/lib/openc3/topics/command_topic.rb +2 -1
- data/lib/openc3/topics/limits_event_topic.rb +90 -3
- data/lib/openc3/topics/router_topic.rb +7 -2
- data/lib/openc3/topics/system_events_topic.rb +9 -6
- data/lib/openc3/topics/telemetry_decom_topic.rb +1 -1
- data/lib/openc3/topics/telemetry_topic.rb +3 -2
- data/lib/openc3/topics/topic.rb +162 -3
- data/lib/openc3/utilities/bucket.rb +5 -0
- data/lib/openc3/utilities/cli_generator.rb +67 -15
- data/lib/openc3/utilities/config_overlay.rb +35 -17
- data/lib/openc3/utilities/default_plugins.rb +33 -0
- data/lib/openc3/utilities/local_mode.rb +82 -46
- data/lib/openc3/utilities/metric.rb +16 -0
- data/lib/openc3/utilities/process_stats.rb +160 -0
- data/lib/openc3/utilities/pypi_url.rb +65 -0
- data/lib/openc3/utilities/questdb_client.rb +140 -32
- data/lib/openc3/utilities/running_script.rb +22 -18
- data/lib/openc3/utilities/secrets.rb +5 -0
- data/lib/openc3/utilities/store_autoload.rb +23 -4
- data/lib/openc3/utilities/store_queued.rb +37 -9
- data/lib/openc3/utilities/target_file.rb +57 -12
- data/lib/openc3/utilities/topic_lag_monitor.rb +193 -0
- data/lib/openc3/version.rb +5 -5
- data/templates/conversion/conversion.py +6 -2
- data/templates/conversion/conversion.rb +5 -0
- data/templates/plugin/.github/workflows/playwright.yml +62 -0
- data/templates/plugin/.github/workflows/release.yml +45 -0
- data/templates/plugin/.github/workflows/unit-tests.yml +35 -0
- data/templates/plugin/.gitignore +6 -0
- data/templates/plugin/README.md +114 -2
- data/templates/plugin/lib/PACKAGE/__init__.py +4 -0
- data/templates/plugin/plugin.gemspec +10 -1
- data/templates/plugin/pyproject.toml +119 -0
- data/templates/tool_angular/package.json +2 -2
- data/templates/tool_angular/pnpm-workspace.yaml +16 -0
- data/templates/tool_react/package.json +1 -1
- data/templates/tool_react/pnpm-workspace.yaml +16 -0
- data/templates/tool_svelte/package.json +2 -1
- data/templates/tool_svelte/pnpm-workspace.yaml +20 -0
- data/templates/tool_vue/package.json +3 -3
- data/templates/tool_vue/pnpm-workspace.yaml +16 -0
- data/templates/widget/package.json +2 -2
- data/templates/widget/pnpm-workspace.yaml +16 -0
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
+
procedure_name = RunningScript.instance.current_filename
|
|
270
|
+
line_no = line_no_or_procedure_name
|
|
279
271
|
else
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
29
|
-
def self.instance(update_interval =
|
|
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
|
-
#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
@
|
|
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
|