openc3 7.0.0.pre.rc3 → 7.0.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +58 -10
  3. data/bin/pipinstall +38 -6
  4. data/data/config/command_modifiers.yaml +1 -0
  5. data/data/config/interface_modifiers.yaml +1 -1
  6. data/data/config/item_modifiers.yaml +20 -7
  7. data/data/config/table_parameter_modifiers.yaml +3 -1
  8. data/data/config/telemetry.yaml +1 -1
  9. data/lib/openc3/accessors/json_accessor.rb +1 -1
  10. data/lib/openc3/accessors/template_accessor.rb +9 -0
  11. data/lib/openc3/api/tlm_api.rb +3 -3
  12. data/lib/openc3/config/config_parser.rb +4 -4
  13. data/lib/openc3/conversions/conversion.rb +3 -3
  14. data/lib/openc3/core_ext/faraday.rb +4 -0
  15. data/lib/openc3/interfaces/interface.rb +1 -6
  16. data/lib/openc3/logs/log_writer.rb +24 -6
  17. data/lib/openc3/logs/packet_log_writer.rb +1 -4
  18. data/lib/openc3/logs/stream_log_pair.rb +11 -4
  19. data/lib/openc3/logs/text_log_writer.rb +1 -4
  20. data/lib/openc3/microservices/decom_microservice.rb +1 -1
  21. data/lib/openc3/microservices/interface_decom_common.rb +22 -8
  22. data/lib/openc3/microservices/interface_microservice.rb +14 -3
  23. data/lib/openc3/microservices/log_microservice.rb +7 -2
  24. data/lib/openc3/microservices/microservice.rb +10 -4
  25. data/lib/openc3/microservices/queue_microservice.rb +3 -0
  26. data/lib/openc3/microservices/scope_cleanup_microservice.rb +116 -1
  27. data/lib/openc3/microservices/text_log_microservice.rb +4 -1
  28. data/lib/openc3/migrations/20260204000000_remove_decom_reducer.rb +2 -0
  29. data/lib/openc3/models/activity_model.rb +15 -3
  30. data/lib/openc3/models/cvt_model.rb +2 -247
  31. data/lib/openc3/models/plugin_model.rb +9 -1
  32. data/lib/openc3/models/plugin_store_model.rb +1 -1
  33. data/lib/openc3/models/python_package_model.rb +1 -1
  34. data/lib/openc3/models/reaction_model.rb +27 -9
  35. data/lib/openc3/models/script_engine_model.rb +1 -1
  36. data/lib/openc3/models/target_model.rb +32 -34
  37. data/lib/openc3/models/tool_model.rb +18 -5
  38. data/lib/openc3/models/trigger_model.rb +25 -8
  39. data/lib/openc3/models/widget_model.rb +1 -2
  40. data/lib/openc3/operators/operator.rb +9 -7
  41. data/lib/openc3/packets/json_packet.rb +2 -0
  42. data/lib/openc3/packets/packet.rb +1 -0
  43. data/lib/openc3/packets/packet_config.rb +28 -12
  44. data/lib/openc3/script/api_shared.rb +39 -2
  45. data/lib/openc3/script/calendar.rb +40 -10
  46. data/lib/openc3/script/extract.rb +46 -13
  47. data/lib/openc3/script/script.rb +19 -0
  48. data/lib/openc3/script/storage.rb +6 -6
  49. data/lib/openc3/system/system.rb +6 -6
  50. data/lib/openc3/tools/cmd_tlm_server/interface_thread.rb +0 -2
  51. data/lib/openc3/top_level.rb +15 -63
  52. data/lib/openc3/topics/decom_interface_topic.rb +19 -4
  53. data/lib/openc3/topics/interface_topic.rb +21 -2
  54. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  55. data/lib/openc3/utilities/bucket_utilities.rb +3 -1
  56. data/lib/openc3/utilities/cli_generator.rb +7 -0
  57. data/lib/openc3/utilities/cmd_log.rb +1 -1
  58. data/lib/openc3/utilities/ctrf.rb +231 -0
  59. data/lib/openc3/utilities/local_mode.rb +3 -0
  60. data/lib/openc3/utilities/process_manager.rb +1 -1
  61. data/lib/openc3/utilities/python_proxy.rb +11 -4
  62. data/lib/openc3/utilities/questdb_client.rb +739 -22
  63. data/lib/openc3/utilities/running_script.rb +25 -7
  64. data/lib/openc3/utilities/script.rb +452 -0
  65. data/lib/openc3/utilities/secrets.rb +1 -1
  66. data/lib/openc3/version.rb +6 -6
  67. data/templates/conversion/conversion.py +0 -8
  68. data/templates/conversion/conversion.rb +0 -11
  69. data/templates/tool_angular/package.json +2 -2
  70. data/templates/tool_react/package.json +1 -1
  71. data/templates/tool_svelte/package.json +1 -1
  72. data/templates/tool_vue/package.json +3 -4
  73. data/templates/widget/package.json +2 -2
  74. metadata +17 -2
  75. data/lib/openc3/migrations/20251022000000_remove_unique_id.rb +0 -23
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2026 OpenC3, Inc.
4
4
  # All Rights Reserved.
@@ -11,48 +11,55 @@
11
11
  # This file may also be used under the terms of a commercial license
12
12
  # if purchased from OpenC3, Inc.
13
13
 
14
- require 'json'
15
14
  require 'base64'
16
15
  require 'bigdecimal'
16
+ require 'concurrent'
17
+ require 'json'
17
18
  require 'pg'
19
+ require 'set'
20
+ require 'time'
21
+ require 'openc3/models/target_model'
18
22
 
19
23
  module OpenC3
20
24
  # Utility class for QuestDB data encoding and decoding.
21
25
  # This provides a common interface for serializing/deserializing COSMOS data types
22
26
  # when writing to and reading from QuestDB.
23
27
  class QuestDBClient
24
- @@conn = nil
25
- @@conn_mutex = Mutex.new
28
+ class QuestDBError < StandardError; end
29
+
30
+ # Thread-local PG connection storage using Concurrent::ThreadLocalVar.
31
+ # Each thread gets its own connection to avoid thread-safety issues with PG::Connection.
32
+ # Connections are automatically garbage collected when threads terminate.
33
+ @thread_conn = Concurrent::ThreadLocalVar.new(nil)
26
34
 
27
- # Get or create a thread-safe PG connection with type mapping configured.
28
- # Returns a shared singleton connection callers should not close it.
35
+ # Get or create a thread-local PG connection with type mapping configured.
36
+ # Returns the thread-local connection - callers should not close it.
29
37
  def self.connection
30
- @@conn_mutex.synchronize do
31
- @@conn ||= PG::Connection.new(
38
+ conn = @thread_conn.value
39
+ if conn.nil? || conn.finished?
40
+ conn = PG::Connection.new(
32
41
  host: ENV['OPENC3_TSDB_HOSTNAME'],
33
42
  port: ENV['OPENC3_TSDB_QUERY_PORT'],
34
43
  user: ENV['OPENC3_TSDB_USERNAME'],
35
44
  password: ENV['OPENC3_TSDB_PASSWORD'],
36
45
  dbname: 'qdb'
37
46
  )
38
- if @@conn.type_map_for_results.is_a? PG::TypeMapAllStrings
39
- @@conn.type_map_for_results = PG::BasicTypeMapForResults.new @@conn
40
- end
41
- @@conn
47
+ conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)
48
+ @thread_conn.value = conn
42
49
  end
50
+ conn
43
51
  end
44
52
 
45
- # Reset the connection (close if open, set to nil). Used after errors.
53
+ # Reset the connection for the current thread. Used after errors.
46
54
  def self.disconnect
47
- @@conn_mutex.synchronize do
48
- if @@conn && !@@conn.finished?
49
- @@conn.finish
50
- end
51
- @@conn = nil
55
+ conn = @thread_conn.value
56
+ if conn && !conn.finished?
57
+ conn.finish
52
58
  end
59
+ @thread_conn.value = nil
53
60
  end
54
61
 
55
- # Health check attempt to connect and immediately close.
62
+ # Health check - attempt to connect and immediately close.
56
63
  # Returns true if successful, raises on failure.
57
64
  def self.check_connection
58
65
  conn = PG::Connection.new(
@@ -75,6 +82,10 @@ module OpenC3
75
82
  'RECEIVED_TIMEFORMATTED' => { source: 'RECEIVED_TIMESECONDS', format: :formatted }
76
83
  }.freeze
77
84
 
85
+ # Stored timestamp items that are stored as timestamp_ns columns and need
86
+ # conversion to float seconds on read. Distinguished from calculated items above.
87
+ STORED_TIMESTAMP_ITEMS = Set.new(['PACKET_TIMESECONDS', 'RECEIVED_TIMESECONDS']).freeze
88
+
78
89
  # Sentinel values for storing float special values (inf, -inf, nan) in QuestDB.
79
90
  # QuestDB stores these as NULL, so we use sentinel values near float max instead.
80
91
 
@@ -116,14 +127,15 @@ module OpenC3
116
127
  # - Arrays are JSON-encoded: "[1, 2, 3]" or '["a", "b"]'
117
128
  # - Objects/Hashes are JSON-encoded: '{"key": "value"}'
118
129
  # - Binary data (BLOCK) is base64-encoded
119
- # - Large integers (64-bit) are stored as DECIMAL
130
+ # - Large integers (64-bit) are stored as VARCHAR strings
120
131
  #
121
132
  # @param value [Object] The value to decode
122
133
  # @param data_type [String] COSMOS data type (INT, UINT, FLOAT, STRING, BLOCK, DERIVED, etc.)
123
134
  # @param array_size [Integer, nil] If not nil, indicates this is an array item
124
135
  # @return [Object] The decoded value
125
136
  def self.decode_value(value, data_type: nil, array_size: nil)
126
- # Handle BigDecimal values from QuestDB DECIMAL columns (used for 64-bit integers)
137
+ # Handle BigDecimal values from legacy QuestDB DECIMAL columns
138
+ # (pre-existing tables may still use DECIMAL; new tables use VARCHAR)
127
139
  if value.is_a?(BigDecimal)
128
140
  return value.to_i if data_type == 'INT' || data_type == 'UINT'
129
141
  return value
@@ -156,7 +168,7 @@ module OpenC3
156
168
  end
157
169
  end
158
170
 
159
- # Integer values stored as strings (fallback path, normally DECIMAL)
171
+ # Integer values stored as VARCHAR strings (≥64-bit integers)
160
172
  if data_type == 'INT' || data_type == 'UINT'
161
173
  begin
162
174
  return Integer(value)
@@ -224,6 +236,115 @@ module OpenC3
224
236
  item_name.to_s.gsub(/[?\.,'"\\\/:\)\(\+=\-\*\%~;!@#\$\^&]/, '_')
225
237
  end
226
238
 
239
+ # Find an item definition within a packet definition by name.
240
+ #
241
+ # @param packet_def [Hash, nil] Packet definition from TargetModel.packet
242
+ # @param item_name [String] Item name to find
243
+ # @return [Hash, nil] Item definition hash or nil if not found
244
+ def self.find_item_def(packet_def, item_name)
245
+ return nil unless packet_def
246
+ packet_def['items']&.each do |item|
247
+ return item if item['name'] == item_name
248
+ end
249
+ nil
250
+ end
251
+
252
+ # Resolve the data_type and array_size for a QuestDB column based on the
253
+ # item definition and requested value type. This encapsulates the common
254
+ # logic for determining how to decode a value read from QuestDB.
255
+ #
256
+ # @param item_def [Hash, nil] Item definition from packet definition
257
+ # @param value_type [String] One of 'RAW', 'CONVERTED', 'FORMATTED'
258
+ # @return [Hash] { 'data_type' => String|nil, 'array_size' => Integer|nil }
259
+ def self.resolve_item_type(item_def, value_type)
260
+ case value_type
261
+ when 'FORMATTED', 'WITH_UNITS' # WITH_UNITS is deprecated
262
+ { 'data_type' => 'STRING', 'array_size' => nil }
263
+ when 'CONVERTED'
264
+ if item_def
265
+ rc = item_def['read_conversion']
266
+ if rc && rc['converted_type']
267
+ { 'data_type' => rc['converted_type'], 'array_size' => item_def['array_size'] }
268
+ elsif item_def['states']
269
+ { 'data_type' => 'STRING', 'array_size' => nil }
270
+ else
271
+ { 'data_type' => item_def['data_type'], 'array_size' => item_def['array_size'] }
272
+ end
273
+ else
274
+ { 'data_type' => nil, 'array_size' => nil }
275
+ end
276
+ else # RAW or default
277
+ if item_def
278
+ { 'data_type' => item_def['data_type'], 'array_size' => item_def['array_size'] }
279
+ else
280
+ { 'data_type' => nil, 'array_size' => nil }
281
+ end
282
+ end
283
+ end
284
+
285
+ # Execute a SQL query with automatic retry on connection errors.
286
+ # Handles PG connection management and retries up to max_retries times.
287
+ #
288
+ # @param query [String] SQL query to execute
289
+ # @param params [Array] Query parameters for parameterized queries (uses exec_params)
290
+ # @param max_retries [Integer] Maximum number of retry attempts (default 5)
291
+ # @param label [String, nil] Optional label for log messages
292
+ # @return [PG::Result, nil] Query result
293
+ # @raise [RuntimeError] After exhausting retries
294
+ def self.query_with_retry(query, params: [], max_retries: 5, label: nil)
295
+ retry_count = 0
296
+ begin
297
+ conn = connection
298
+ if params.empty?
299
+ conn.exec(query)
300
+ else
301
+ conn.exec_params(query, params)
302
+ end
303
+ rescue IOError, PG::Error => e
304
+ retry_count += 1
305
+ if retry_count > (max_retries - 1)
306
+ raise QuestDBError.new("Error querying TSDB#{label ? " (#{label})" : ""}: #{e.message}")
307
+ end
308
+ Logger.warn("TSDB#{label ? " #{label}" : ""}: Retrying due to error: #{e.message}")
309
+ Logger.warn("TSDB#{label ? " #{label}" : ""}: Last query: #{query}")
310
+ disconnect
311
+ sleep 0.1
312
+ retry
313
+ end
314
+ end
315
+
316
+ # Convert a nanosecond integer timestamp to a UTC Time object.
317
+ #
318
+ # @param nsec [Integer] Nanoseconds since epoch
319
+ # @return [Time] UTC Time object
320
+ def self.nsec_to_utc_time(nsec)
321
+ return nil unless nsec
322
+ Time.at(nsec / 1_000_000_000, nsec % 1_000_000_000, :nsec, in: '+00:00')
323
+ end
324
+
325
+ # Coerce a value from QuestDB (which may be a Time, Float, Integer, String,
326
+ # or PG timestamp object) into a Ruby UTC Time.
327
+ #
328
+ # @param value [Object] Timestamp value in any supported format
329
+ # @return [Time, nil] UTC Time object or nil
330
+ def self.coerce_to_utc(value)
331
+ return nil unless value
332
+ case value
333
+ when Time
334
+ # PG driver returns Time objects with UTC values but in local timezone,
335
+ # so reconstruct as UTC from components rather than converting
336
+ pg_timestamp_to_utc(value)
337
+ when Float
338
+ Time.at(value).utc
339
+ when Integer
340
+ nsec_to_utc_time(value).utc
341
+ when String
342
+ Time.parse(value).utc
343
+ else
344
+ raise QuestDBError.new("Unsupported timestamp value #{value} with type: #{value.class}")
345
+ end
346
+ end
347
+
227
348
  # Convert a PG timestamp to UTC.
228
349
  # PG driver returns timestamps as naive Time objects that need UTC treatment.
229
350
  # QuestDB stores timestamps in UTC, but the PG driver applies local timezone.
@@ -252,5 +373,601 @@ module OpenC3
252
373
  nil
253
374
  end
254
375
  end
376
+
377
+ # Return the QuestDB column suffix for a given value type.
378
+ #
379
+ # @param value_type [String] One of 'RAW', 'CONVERTED', 'FORMATTED'
380
+ # @return [String] Column suffix (e.g., '__C', '__F', or '')
381
+ def self.column_suffix_for_value_type(value_type)
382
+ case value_type
383
+ when 'FORMATTED', 'WITH_UNITS' # WITH_UNITS is deprecated
384
+ '__F'
385
+ when 'CONVERTED'
386
+ '__C'
387
+ else
388
+ ''
389
+ end
390
+ end
391
+
392
+ # Determine the value type from a QuestDB column name's suffix.
393
+ #
394
+ # @param column_name [String] Column name possibly ending in __C, __F, __L
395
+ # @return [String] One of 'FORMATTED', 'CONVERTED', 'RAW'
396
+ def self.value_type_for_column_suffix(column_name)
397
+ if column_name.end_with?('__F')
398
+ 'FORMATTED'
399
+ elsif column_name.end_with?('__C')
400
+ 'CONVERTED'
401
+ else
402
+ 'RAW'
403
+ end
404
+ end
405
+
406
+ # Build a SQL WHERE clause for PACKET_TIMESECONDS range filtering.
407
+ #
408
+ # @param start_time [Integer, String] Start timestamp (nanoseconds)
409
+ # @param end_time [Integer, String, nil] End timestamp (nanoseconds), or nil for open-ended
410
+ # @param prefix [String] Table alias prefix (e.g., 'T0.') — default ''
411
+ # @return [String] SQL WHERE clause fragment (includes leading space)
412
+ def self.time_where_clause(start_time, end_time, prefix: '')
413
+ where = " WHERE #{prefix}PACKET_TIMESECONDS >= #{start_time}"
414
+ where += " AND #{prefix}PACKET_TIMESECONDS < #{end_time}" if end_time
415
+ where
416
+ end
417
+
418
+ # Fetch a packet definition from TargetModel, returning nil if not found.
419
+ #
420
+ # @param target_name [String] Target name
421
+ # @param packet_name [String] Packet name
422
+ # @param type [Symbol] :CMD or :TLM (default :TLM)
423
+ # @param scope [String] Scope name
424
+ # @return [Hash, nil] Packet definition or nil
425
+ def self.fetch_packet_def(target_name, packet_name, type: :TLM, scope: "DEFAULT")
426
+ TargetModel.packet(target_name, packet_name, type: type, scope: scope)
427
+ rescue RuntimeError
428
+ nil
429
+ end
430
+
431
+ # Build a hash mapping sanitized column names to item definitions.
432
+ # Used for type-aware decoding of QuestDB SELECT * results.
433
+ #
434
+ # @param packet_def [Hash, nil] Packet definition from TargetModel.packet
435
+ # @return [Hash] { sanitized_column_name => item_def_hash }
436
+ def self.build_item_defs_map(packet_def)
437
+ map = {}
438
+ return map unless packet_def
439
+ packet_def['items']&.each do |item|
440
+ map[sanitize_column_name(item['name'])] = item
441
+ end
442
+ map
443
+ end
444
+
445
+ # Build aggregation SELECT columns (min/max/avg/stddev) for a single item.
446
+ # Returns the SELECT fragments and a column_mapping hash.
447
+ #
448
+ # @param safe_item_name [String] Sanitized column name
449
+ # @param value_type [Symbol] :RAW or :CONVERTED
450
+ # @param item_name [String, nil] Original (unsanitized) item name for mapping values.
451
+ # Defaults to safe_item_name if not provided.
452
+ # @return [Array<String>, Hash] Two-element array: [select_fragments, column_mapping]
453
+ # column_mapping maps result column alias to [item_name, reduced_type, value_type]
454
+ def self.build_aggregation_selects(safe_item_name, value_type, item_name: nil)
455
+ item_name ||= safe_item_name
456
+ selects = []
457
+ mapping = {}
458
+ case value_type
459
+ when :RAW
460
+ col = safe_item_name
461
+ { 'N' => :MIN, 'X' => :MAX, 'A' => :AVG, 'S' => :STDDEV }.each do |suffix, reduced_type|
462
+ alias_name = "#{safe_item_name}__#{suffix}"
463
+ selects << "#{reduced_type.to_s.downcase}(\"#{col}\") as \"#{alias_name}\""
464
+ mapping[alias_name] = [item_name, reduced_type, :RAW]
465
+ end
466
+ when :CONVERTED
467
+ col = "#{safe_item_name}__C"
468
+ { 'CN' => :MIN, 'CX' => :MAX, 'CA' => :AVG, 'CS' => :STDDEV }.each do |suffix, reduced_type|
469
+ alias_name = "#{safe_item_name}__#{suffix}"
470
+ selects << "#{reduced_type.to_s.downcase}(\"#{col}\") as \"#{alias_name}\""
471
+ mapping[alias_name] = [item_name, reduced_type, :CONVERTED]
472
+ end
473
+ else
474
+ # No aggregation for FORMATTED type since it is a string
475
+ raise QuestDBError.new("Unsupported value type for aggregation: #{value_type}")
476
+ end
477
+ [selects, mapping]
478
+ end
479
+
480
+ # Build aggregation SELECT columns for all numeric items in a packet definition.
481
+ # Filters out STRING, BLOCK, and DERIVED items since they can't be aggregated.
482
+ #
483
+ # @param packet_def [Hash, nil] Packet definition from TargetModel.packet
484
+ # @param value_type [Symbol] :RAW or :CONVERTED
485
+ # @return [Array<String>, Boolean] Two-element array: [select_fragments, has_numeric_items]
486
+ # select_fragments includes TIMESTAMP_SELECT as the first element.
487
+ def self.build_packet_reduced_selects(packet_def, value_type)
488
+ selects = [TIMESTAMP_SELECT]
489
+ has_items = false
490
+ return [selects, false] unless packet_def && packet_def['items']
491
+
492
+ packet_def['items'].each do |item|
493
+ data_type = item['data_type']
494
+ next if data_type.nil?
495
+ next if ['STRING', 'BLOCK', 'DERIVED'].include?(data_type)
496
+ next unless value_type == :RAW || value_type == :CONVERTED
497
+
498
+ safe_name = sanitize_column_name(item['name'])
499
+ agg_selects, _mapping = build_aggregation_selects(safe_name, value_type)
500
+ selects.concat(agg_selects)
501
+ has_items = true
502
+ end
503
+
504
+ [selects, has_items]
505
+ end
506
+
507
+ # Add TIMESECONDS and TIMEFORMATTED entries to a hash from a nanosecond timestamp.
508
+ # Used when building packet entries from CAST(timestamp AS LONG) columns.
509
+ #
510
+ # @param entry [Hash] Entry hash to populate
511
+ # @param timestamp_ns [Integer] Nanoseconds since epoch
512
+ # @param prefix [String] 'PACKET' or 'RECEIVED'
513
+ def self.add_timestamp_entries!(entry, timestamp_ns, prefix)
514
+ return unless timestamp_ns
515
+ utc_time = nsec_to_utc_time(timestamp_ns)
516
+ entry["#{prefix}_TIMESECONDS"] = format_timestamp(utc_time, :seconds)
517
+ entry["#{prefix}_TIMEFORMATTED"] = format_timestamp(utc_time, :formatted)
518
+ end
519
+
520
+ # SQL: nanosecond-precision packet timestamp for explicit SELECT lists.
521
+ # PG wire protocol truncates timestamp_ns to microseconds; CAST AS LONG preserves full precision.
522
+ TIMESTAMP_SELECT = 'CAST(PACKET_TIMESECONDS AS LONG) as PACKET_TIMESECONDS'
523
+
524
+ # SQL: nanosecond-precision timestamps for SELECT * queries (different aliases avoid column name collision).
525
+ TIMESTAMP_EXTRAS = 'CAST(PACKET_TIMESECONDS AS LONG) as "__pkt_time_ns", CAST(RECEIVED_TIMESECONDS AS LONG) as "__rx_time_ns"'
526
+
527
+ # Returns the SAMPLE BY interval string for a given stream_mode symbol.
528
+ #
529
+ # @param stream_mode [Symbol] :REDUCED_MINUTE, :REDUCED_HOUR, or :REDUCED_DAY
530
+ # @return [String] QuestDB SAMPLE BY interval string
531
+ def self.sample_interval_for(stream_mode)
532
+ case stream_mode
533
+ when :REDUCED_MINUTE then '1m'
534
+ when :REDUCED_HOUR then '1h'
535
+ when :REDUCED_DAY then '1d'
536
+ else '1m'
537
+ end
538
+ end
539
+
540
+ # Returns true if the given TSDB table exists and has at least one row in the time range.
541
+ #
542
+ # @param table_name [String] Sanitized table name
543
+ # @param start_time [Integer] Nanosecond start time
544
+ # @param end_time [Integer, nil] Nanosecond end time
545
+ # @return [Boolean]
546
+ def self.table_has_data?(table_name, start_time, end_time)
547
+ query = "SELECT 1 FROM #{table_name}"
548
+ query += time_where_clause(start_time, end_time)
549
+ query += " LIMIT 1"
550
+ result = query_with_retry(query, max_retries: 1, label: "table_has_data")
551
+ result && result.ntuples > 0
552
+ rescue RuntimeError
553
+ false
554
+ end
555
+
556
+ # Execute a paginated TSDB query, yielding each non-empty PG::Result page.
557
+ # Handles LIMIT pagination and retry on error.
558
+ #
559
+ # @param query [String] Base SQL query (without LIMIT clause)
560
+ # @param page_size [Integer] Number of rows per page
561
+ # @param label [String] Label for log messages
562
+ # @yield [PG::Result] Each page of results
563
+ def self.paginate_query(query, page_size, label:)
564
+ min = 0
565
+ max = page_size
566
+ loop do
567
+ query_offset = "#{query} LIMIT #{min}, #{max}"
568
+ Logger.debug("QuestDB #{label}: #{query_offset}")
569
+ result = query_with_retry(query_offset, label: label)
570
+ min += page_size
571
+ max += page_size
572
+ if result.nil? or result.ntuples == 0
573
+ return
574
+ else
575
+ yield result
576
+ end
577
+ end
578
+ end
579
+
580
+ # Build a SELECT query for specific item columns from a single table.
581
+ #
582
+ # @param table_name [String] Sanitized QuestDB table name
583
+ # @param column_names [Array<String>] Quoted column expressions (e.g., '"TEMP1__C"')
584
+ # @param start_time [Integer] Start timestamp in nanoseconds
585
+ # @param end_time [Integer, nil] End timestamp in nanoseconds
586
+ # @param include_received_ts [Boolean] Whether to include RECEIVED_TIMESECONDS
587
+ # @return [String] Complete SQL query (without LIMIT clause)
588
+ def self.build_item_columns_query(table_name, column_names, start_time, end_time, include_received_ts: false)
589
+ names = column_names.dup
590
+ names << TIMESTAMP_SELECT
591
+ names << "RECEIVED_TIMESECONDS" if include_received_ts
592
+ names << "COSMOS_EXTRA"
593
+ query = "SELECT #{names.join(', ')} FROM #{table_name}"
594
+ query += time_where_clause(start_time, end_time)
595
+ query
596
+ end
597
+
598
+ # Build a SELECT * query for full packet data from a single table.
599
+ #
600
+ # @param table_name [String] Sanitized QuestDB table name
601
+ # @param start_time [Integer] Start timestamp in nanoseconds
602
+ # @param end_time [Integer, nil] End timestamp in nanoseconds
603
+ # @return [String] Complete SQL query (without LIMIT clause)
604
+ def self.build_packet_query(table_name, start_time, end_time)
605
+ query = "SELECT *, #{TIMESTAMP_EXTRAS} FROM \"#{table_name}\""
606
+ query += time_where_clause(start_time, end_time)
607
+ query
608
+ end
609
+
610
+ # Build a SAMPLE BY aggregation query for reduced data.
611
+ #
612
+ # @param table_name [String] Sanitized QuestDB table name
613
+ # @param select_columns [Array<String>] SELECT column expressions including aggregations
614
+ # @param start_time [Integer] Start timestamp in nanoseconds
615
+ # @param end_time [Integer, nil] End timestamp in nanoseconds
616
+ # @param sample_interval [String] QuestDB SAMPLE BY interval ('1m', '1h', '1d')
617
+ # @return [String] Complete SQL query (without LIMIT clause)
618
+ def self.build_reduced_query(table_name, select_columns, start_time, end_time, sample_interval)
619
+ query = "SELECT #{select_columns.join(', ')} FROM \"#{table_name}\""
620
+ query += time_where_clause(start_time, end_time)
621
+ query += " SAMPLE BY #{sample_interval}"
622
+ query += " ALIGN TO CALENDAR"
623
+ query += " ORDER BY PACKET_TIMESECONDS"
624
+ query
625
+ end
626
+
627
+ # Decode a single row from a per-table item columns query into an entry hash.
628
+ # Handles stored timestamps, calculated timestamps, and regular value decoding.
629
+ #
630
+ # @param row [PG::Result row] Single row (iterable as [col_name, value] pairs)
631
+ # @param sql_to_local [Array<Integer>] Mapping from SQL column index to meta position
632
+ # @param meta [Hash] Per-table metadata with keys:
633
+ # :item_keys [Array<String>] - ordered list of item key identifiers
634
+ # :item_types [Array<Hash>] - type info per position ({ 'data_type' =>, 'array_size' => })
635
+ # :stored_timestamp_item_keys [Hash] - { item_key => { column: col_name } }
636
+ # :calculated_positions [Hash] - { local_idx => { source: col_name, format: :seconds/:formatted } }
637
+ # @return [Hash] Entry hash with __type, item_key => value, __time, COSMOS_EXTRA
638
+ def self.decode_item_row(row, sql_to_local, meta)
639
+ num_sql_item_cols = sql_to_local.length
640
+
641
+ entry = { "__type" => "ITEMS" }
642
+ timestamp_values = {}
643
+ time_ns = nil
644
+ cosmos_extra = nil
645
+
646
+ values = Array.new(meta[:item_keys].length)
647
+
648
+ row.each_with_index do |tuple, sql_index|
649
+ col_name = tuple[0]
650
+ value = tuple[1]
651
+
652
+ # Fixed columns come after item columns
653
+ if sql_index >= num_sql_item_cols
654
+ case col_name
655
+ when 'PACKET_TIMESECONDS'
656
+ time_ns = value.to_i
657
+ timestamp_values['PACKET_TIMESECONDS'] = nsec_to_utc_time(time_ns)
658
+ when 'RECEIVED_TIMESECONDS'
659
+ timestamp_values['RECEIVED_TIMESECONDS'] = value if value
660
+ when 'COSMOS_EXTRA'
661
+ cosmos_extra = value
662
+ # No else because we're only interested in these specific extra columns; others can be ignored
663
+ end
664
+ next
665
+ end
666
+
667
+ local_idx = sql_to_local[sql_index]
668
+
669
+ # Track timestamp values from item columns
670
+ if col_name == 'RECEIVED_TIMESECONDS'
671
+ timestamp_values['RECEIVED_TIMESECONDS'] = value
672
+ end
673
+
674
+ next if value.nil?
675
+
676
+ type_info = meta[:item_types][local_idx] || {}
677
+ if meta[:stored_timestamp_item_keys].key?(meta[:item_keys][local_idx])
678
+ ts_utc = coerce_to_utc(value)
679
+ values[local_idx] = format_timestamp(ts_utc, :seconds) if ts_utc
680
+ else
681
+ values[local_idx] = decode_value(
682
+ value,
683
+ data_type: type_info['data_type'],
684
+ array_size: type_info['array_size']
685
+ )
686
+ end
687
+ end
688
+
689
+ # Build ordered entry hash with calculated items in their natural position
690
+ meta[:item_keys].each_with_index do |item_key, local_idx|
691
+ if meta[:calculated_positions].key?(local_idx)
692
+ calc_info = meta[:calculated_positions][local_idx]
693
+ ts_value = timestamp_values[calc_info[:source]]
694
+ next unless ts_value
695
+ ts_utc = coerce_to_utc(ts_value)
696
+ calculated_value = format_timestamp(ts_utc, calc_info[:format])
697
+ entry[item_key] = calculated_value if calculated_value
698
+ elsif !values[local_idx].nil?
699
+ entry[item_key] = values[local_idx]
700
+ end
701
+ end
702
+
703
+ entry['__time'] = time_ns if time_ns
704
+ entry['COSMOS_EXTRA'] = cosmos_extra if cosmos_extra
705
+ entry
706
+ end
707
+
708
+ # Decode a single row from a SELECT * packet query into an entry hash.
709
+ # Handles nanosecond timestamp CAST columns, value-type column preference,
710
+ # and type-aware decoding.
711
+ #
712
+ # @param row [PG::Result row] Single row as iterable [col_name, value] pairs
713
+ # @param value_type [Symbol] :RAW, :CONVERTED, :FORMATTED
714
+ # @param packet_def [Hash, nil] Packet definition for type-aware decoding
715
+ # @return [Hash] Entry hash with item => value, __time, COSMOS_EXTRA, timestamp entries
716
+ def self.decode_packet_row(row, value_type, packet_def)
717
+ entry = {}
718
+ item_defs = build_item_defs_map(packet_def)
719
+
720
+ # First pass: build a hash of all columns for value-type preference lookups
721
+ columns = {}
722
+ row.each do |tuple|
723
+ columns[tuple[0]] = tuple[1]
724
+ end
725
+
726
+ cosmos_timestamp_ns = nil
727
+ received_timestamp_ns = nil
728
+
729
+ # Second pass: process columns based on value_type
730
+ row.each do |tuple|
731
+ column_name = tuple[0]
732
+ raw_value = tuple[1]
733
+
734
+ if column_name == '__pkt_time_ns'
735
+ cosmos_timestamp_ns = raw_value.to_i
736
+ entry['__time'] = cosmos_timestamp_ns
737
+ next
738
+ end
739
+
740
+ if column_name == '__rx_time_ns'
741
+ received_timestamp_ns = raw_value.to_i
742
+ next
743
+ end
744
+
745
+ # Skip PG timestamp versions - handled via CAST AS LONG columns above
746
+ next if column_name == 'PACKET_TIMESECONDS'
747
+ next if column_name == 'RECEIVED_TIMESECONDS'
748
+ next if column_name == 'COSMOS_DATA_TAG'
749
+
750
+ if column_name == 'COSMOS_EXTRA'
751
+ entry['COSMOS_EXTRA'] = raw_value
752
+ next
753
+ end
754
+
755
+ base_name = column_name.sub(/(__C|__F|__U)$/, '')
756
+ item_def = item_defs[base_name]
757
+
758
+ col_value_type = value_type_for_column_suffix(column_name)
759
+ type_info = resolve_item_type(item_def, col_value_type)
760
+ value = decode_value(raw_value, data_type: type_info['data_type'], array_size: type_info['array_size'])
761
+
762
+ case value_type
763
+ when :RAW
764
+ next if column_name.end_with?('__C', '__F', '__U')
765
+ entry[column_name] = value
766
+ when :CONVERTED
767
+ if column_name.end_with?('__C')
768
+ entry[column_name.sub(/__C$/, '')] = value
769
+ elsif !column_name.end_with?('__F', '__U') && !columns.key?("#{column_name}__C")
770
+ entry[column_name] = value
771
+ end
772
+ when :FORMATTED
773
+ if column_name.end_with?('__F')
774
+ entry[column_name.sub(/__F$/, '')] = value
775
+ elsif column_name.end_with?('__C') && !columns.key?("#{column_name.sub(/__C$/, '')}__F")
776
+ entry[column_name.sub(/__C$/, '')] = value
777
+ elsif !column_name.end_with?('__C', '__F', '__U') && !columns.key?("#{column_name}__F") && !columns.key?("#{column_name}__C")
778
+ entry[column_name] = value
779
+ end
780
+ else
781
+ raise QuestDBError.new("Unsupported value type for packet decoding: #{value_type}")
782
+ end
783
+ end
784
+
785
+ add_timestamp_entries!(entry, cosmos_timestamp_ns, 'PACKET')
786
+ add_timestamp_entries!(entry, received_timestamp_ns, 'RECEIVED')
787
+ entry
788
+ end
789
+
790
+ # Decode a single row from a SAMPLE BY aggregation query.
791
+ # All non-timestamp columns are decoded as DOUBLE (aggregation results are always numeric).
792
+ #
793
+ # @param row [PG::Result row] Single row as iterable [col_name, value] pairs
794
+ # @return [Hash] { col_name => decoded_value, '__time' => ns_integer }
795
+ def self.decode_reduced_row(row)
796
+ entry = {}
797
+ row.each do |tuple|
798
+ col_name = tuple[0]
799
+ value = tuple[1]
800
+ if col_name == 'PACKET_TIMESECONDS'
801
+ entry['__time'] = value.to_i
802
+ else
803
+ entry[col_name] = decode_value(value, data_type: 'DOUBLE', array_size: nil)
804
+ end
805
+ end
806
+ entry
807
+ end
808
+
809
+ # Query historical telemetry data from QuestDB for a list of items.
810
+ # Builds the SQL query, executes it, and decodes all results.
811
+ #
812
+ # @param items [Array] Array of [target_name, packet_name, item_name, value_type, limits]
813
+ # item_name may be nil to indicate a placeholder (non-existent item)
814
+ # @param start_time [String, Numeric] Start timestamp for the query
815
+ # @param end_time [String, Numeric, nil] End timestamp, or nil for "latest single row"
816
+ # @param scope [String] Scope name
817
+ # @return [Array, Hash] Array of [value, limits_state] pairs per row, or {} if no results.
818
+ # Single-row results return a flat array; multi-row results return array of arrays.
819
+ def self.tsdb_lookup(items, start_time:, end_time: nil, scope: "DEFAULT")
820
+ tables = {}
821
+ names = []
822
+ nil_count = 0
823
+ packet_cache = {}
824
+ item_types = {}
825
+ calculated_items = {}
826
+ needed_timestamps = {}
827
+ current_position = 0
828
+
829
+ items.each do |item|
830
+ target_name, packet_name, orig_item_name, value_type, limits = item
831
+ if orig_item_name.nil?
832
+ names << "PACKET_TIMESECONDS as __nil#{nil_count}"
833
+ nil_count += 1
834
+ current_position += 1
835
+ next
836
+ end
837
+ table_name = sanitize_table_name(target_name, packet_name, scope: scope)
838
+ tables[table_name] = 1
839
+ index = tables.find_index {|k,_v| k == table_name }
840
+
841
+ if STORED_TIMESTAMP_ITEMS.include?(orig_item_name)
842
+ names << "\"T#{index}.#{orig_item_name}\""
843
+ current_position += 1
844
+ next
845
+ end
846
+
847
+ if TIMESTAMP_ITEMS.key?(orig_item_name)
848
+ ts_info = TIMESTAMP_ITEMS[orig_item_name]
849
+ calculated_items[current_position] = {
850
+ source: ts_info[:source],
851
+ format: ts_info[:format],
852
+ table_index: index
853
+ }
854
+ needed_timestamps[index] ||= Set.new
855
+ needed_timestamps[index] << ts_info[:source]
856
+ current_position += 1
857
+ next
858
+ end
859
+
860
+ safe_item_name = sanitize_column_name(orig_item_name)
861
+
862
+ cache_key = [target_name, packet_name]
863
+ unless packet_cache.key?(cache_key)
864
+ packet_cache[cache_key] = fetch_packet_def(target_name, packet_name, scope: scope)
865
+ end
866
+
867
+ packet_def = packet_cache[cache_key]
868
+ item_def = find_item_def(packet_def, orig_item_name)
869
+
870
+ suffix = column_suffix_for_value_type(value_type)
871
+ col_name = "T#{index}.#{safe_item_name}#{suffix}"
872
+ names << "\"#{col_name}\""
873
+ item_types[col_name] = resolve_item_type(item_def, value_type)
874
+ current_position += 1
875
+ if limits
876
+ names << "\"T#{index}.#{safe_item_name}__L\""
877
+ end
878
+ end
879
+
880
+ # Add needed timestamp columns to the SELECT for calculated items
881
+ needed_timestamps.each do |table_index, ts_columns|
882
+ ts_columns.each do |ts_col|
883
+ names << "T#{table_index}.#{ts_col} as T#{table_index}___ts_#{ts_col}"
884
+ end
885
+ end
886
+
887
+ # Build the SQL query
888
+ query = "SELECT #{names.join(", ")} FROM "
889
+ tables.each_with_index do |(table_name, _), index|
890
+ if index == 0
891
+ query += "#{table_name} as T#{index} "
892
+ else
893
+ query += "ASOF JOIN #{table_name} as T#{index} "
894
+ end
895
+ end
896
+ query_params = []
897
+ if start_time && !end_time
898
+ query += "WHERE T0.PACKET_TIMESECONDS < $1 LIMIT -1"
899
+ query_params << start_time
900
+ elsif start_time && end_time
901
+ query += "WHERE T0.PACKET_TIMESECONDS >= $1 AND T0.PACKET_TIMESECONDS < $2"
902
+ query_params << start_time
903
+ query_params << end_time
904
+ end
905
+
906
+ result = query_with_retry(query, params: query_params, label: "tsdb_lookup")
907
+ if result.nil? or result.ntuples == 0
908
+ return {}
909
+ end
910
+
911
+ data = []
912
+ result.each_with_index do |tuples, row_num|
913
+ data[row_num] ||= []
914
+ row_index = 0
915
+ row_timestamps = {}
916
+ tuples.each do |tuple|
917
+ col_name = tuple[0]
918
+ col_value = tuple[1]
919
+ if col_name.include?("__L")
920
+ data[row_num][row_index - 1][1] = col_value
921
+ elsif col_name =~ /^__nil/
922
+ data[row_num][row_index] = [nil, nil]
923
+ row_index += 1
924
+ elsif col_name =~ /^T(\d+)___ts_(.+)$/
925
+ table_idx = $1.to_i
926
+ ts_source = $2
927
+ row_timestamps["T#{table_idx}.#{ts_source}"] = col_value
928
+ elsif col_name.end_with?('.PACKET_TIMESECONDS', '.RECEIVED_TIMESECONDS') || col_name == 'PACKET_TIMESECONDS' || col_name == 'RECEIVED_TIMESECONDS'
929
+ ts_utc = coerce_to_utc(col_value)
930
+ seconds_value = format_timestamp(ts_utc, :seconds)
931
+ data[row_num][row_index] = [seconds_value, nil]
932
+ row_index += 1
933
+ if col_name.include?('.')
934
+ row_timestamps[col_name] = col_value
935
+ else
936
+ row_timestamps["T0.#{col_name}"] = col_value
937
+ end
938
+ else
939
+ type_info = item_types[col_name]
940
+ unless type_info
941
+ tables.length.times do |i|
942
+ prefixed_name = "T#{i}.#{col_name}"
943
+ type_info = item_types[prefixed_name]
944
+ break if type_info
945
+ end
946
+ type_info ||= {}
947
+ end
948
+ decoded_value = decode_value(
949
+ col_value,
950
+ data_type: type_info['data_type'],
951
+ array_size: type_info['array_size']
952
+ )
953
+ data[row_num][row_index] = [decoded_value, nil]
954
+ row_index += 1
955
+ end
956
+ end
957
+
958
+ calculated_items.keys.sort.each do |position|
959
+ calc_info = calculated_items[position]
960
+ ts_key = "T#{calc_info[:table_index]}.#{calc_info[:source]}"
961
+ ts_value = row_timestamps[ts_key]
962
+ ts_utc = coerce_to_utc(ts_value)
963
+ calculated_value = format_timestamp(ts_utc, calc_info[:format])
964
+ data[row_num].insert(position, [calculated_value, nil])
965
+ end
966
+ end
967
+ if result.ntuples == 1
968
+ data = data[0]
969
+ end
970
+ data
971
+ end
255
972
  end
256
973
  end