diff --git a/web/lib/potato_mesh/application.rb b/web/lib/potato_mesh/application.rb index 2551b28..909b76c 100644 --- a/web/lib/potato_mesh/application.rb +++ b/web/lib/potato_mesh/application.rb @@ -61,6 +61,7 @@ require_relative "application/filesystem" require_relative "application/api_cache" require_relative "application/pages" require_relative "application/instances" +require_relative "application/retention" require_relative "application/routes/api" require_relative "application/routes/ingest" require_relative "application/routes/root" @@ -78,6 +79,7 @@ module PotatoMesh extend App::DataProcessing extend App::Filesystem extend App::Pages + extend App::Retention helpers App::Helpers include App::Database @@ -90,6 +92,7 @@ module PotatoMesh include App::DataProcessing include App::Filesystem include App::Pages + include App::Retention register App::Routes::Api register App::Routes::Ingest @@ -148,6 +151,9 @@ module PotatoMesh set :federation_worker_pool, nil set :federation_shutdown_requested, false set :federation_shutdown_hook_installed, false + set :retention_thread, nil + set :retention_shutdown_requested, false + set :retention_shutdown_hook_installed, false set :port, resolve_port set :bind, DEFAULT_BIND_ADDRESS @@ -171,6 +177,8 @@ module PotatoMesh ensure_self_instance_record! update_all_prometheus_metrics_from_nodes + start_retention_worker_if_active! + if federation_enabled? ensure_federation_worker_pool! else @@ -216,6 +224,7 @@ SELF_INSTANCE_ID = PotatoMesh::Application::SELF_INSTANCE_ID unless defined?(SEL PotatoMesh::App::Queries, PotatoMesh::App::DataProcessing, PotatoMesh::App::Pages, + PotatoMesh::App::Retention, ].each do |mod| Object.include(mod) unless Object < mod end diff --git a/web/lib/potato_mesh/application/federation/instance_metrics.rb b/web/lib/potato_mesh/application/federation/instance_metrics.rb index 7d676b3..1a2cd3f 100644 --- a/web/lib/potato_mesh/application/federation/instance_metrics.rb +++ b/web/lib/potato_mesh/application/federation/instance_metrics.rb @@ -19,6 +19,11 @@ module PotatoMesh module Federation # Count the number of nodes active since the supplied timestamp. # + # Opted-out nodes (those whose +short_name+ or +long_name+ contains + # the {PotatoMesh::Config::NODE_OPT_OUT_MARKER}) are excluded so the + # federation node total never reflects nodes that have asked to stay + # hidden from API consumers. + # # @param cutoff [Integer] unix timestamp in seconds. # @param db [SQLite3::Database, nil] optional open handle to reuse. # @return [Integer, nil] node count or nil when unavailable. @@ -26,9 +31,10 @@ module PotatoMesh return nil unless cutoff handle = db || open_database(readonly: true) + sql = "SELECT COUNT(*) FROM nodes WHERE last_heard >= ? AND #{opt_out_self_filter}" count = with_busy_retry do - handle.get_first_value("SELECT COUNT(*) FROM nodes WHERE last_heard >= ?", cutoff.to_i) + handle.get_first_value(sql, [cutoff.to_i, *opt_out_marker_params]) end Integer(count) rescue SQLite3::Exception, ArgumentError => e @@ -44,7 +50,8 @@ module PotatoMesh end # Count the number of nodes for a specific protocol active since the - # supplied timestamp. + # supplied timestamp. Opted-out nodes are excluded for the same + # reason as in {#active_node_count_since}. # # @param cutoff [Integer] unix timestamp in seconds. # @param protocol [String] protocol name (e.g. "meshcore", "meshtastic"). @@ -54,13 +61,10 @@ module PotatoMesh return nil unless cutoff && protocol handle = db || open_database(readonly: true) + sql = "SELECT COUNT(*) FROM nodes WHERE last_heard >= ? AND protocol = ? AND #{opt_out_self_filter}" count = with_busy_retry do - handle.get_first_value( - "SELECT COUNT(*) FROM nodes WHERE last_heard >= ? AND protocol = ?", - cutoff.to_i, - protocol, - ) + handle.get_first_value(sql, [cutoff.to_i, protocol, *opt_out_marker_params]) end Integer(count) rescue SQLite3::Exception, ArgumentError => e diff --git a/web/lib/potato_mesh/application/helpers/logging_helpers.rb b/web/lib/potato_mesh/application/helpers/logging_helpers.rb index feab85a..f0baa6c 100644 --- a/web/lib/potato_mesh/application/helpers/logging_helpers.rb +++ b/web/lib/potato_mesh/application/helpers/logging_helpers.rb @@ -28,6 +28,17 @@ module PotatoMesh PotatoMesh::Logging.log(logger, :debug, message, context: context, **metadata) end + # Emit a structured info log entry tagged with the calling context. + # + # @param message [String] text to emit. + # @param context [String] logical source of the message. + # @param metadata [Hash] additional structured key/value data. + # @return [void] + def info_log(message, context: "app", **metadata) + logger = PotatoMesh::Logging.logger_for(self) + PotatoMesh::Logging.log(logger, :info, message, context: context, **metadata) + end + # Emit a structured warning log entry tagged with the calling context. # # @param message [String] text to emit. diff --git a/web/lib/potato_mesh/application/identity.rb b/web/lib/potato_mesh/application/identity.rb index fcdeb64..f3e1b87 100644 --- a/web/lib/potato_mesh/application/identity.rb +++ b/web/lib/potato_mesh/application/identity.rb @@ -242,12 +242,17 @@ module PotatoMesh # Retrieve the latest node update timestamp from the database. # + # Opted-out nodes are excluded so the +/version+ cache hint and the + # federation self-record do not leak the freshness of nodes that have + # asked to stay hidden. + # # @return [Integer, nil] Unix timestamp or nil when unavailable. def latest_node_update_timestamp return nil unless File.exist?(PotatoMesh::Config.db_path) db = open_database(readonly: true) - value = db.get_first_value("SELECT MAX(last_heard) FROM nodes") + sql = "SELECT MAX(last_heard) FROM nodes WHERE #{opt_out_self_filter}" + value = db.get_first_value(sql, opt_out_marker_params) value&.to_i rescue SQLite3::Exception nil diff --git a/web/lib/potato_mesh/application/queries/chat_queries.rb b/web/lib/potato_mesh/application/queries/chat_queries.rb index 587f4f1..9c9a000 100644 --- a/web/lib/potato_mesh/application/queries/chat_queries.rb +++ b/web/lib/potato_mesh/application/queries/chat_queries.rb @@ -53,6 +53,11 @@ module PotatoMesh params.concat(clause.last) end + # Hide chat lines that originate from or are addressed to opted-out + # nodes. Both endpoints are filtered so reactions/replies aimed at a + # silenced participant do not leak the conversation half. + append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("m.from_id")) + append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("m.to_id")) append_protocol_filter(where_clauses, params, protocol, table_alias: "m") sql = <<~SQL diff --git a/web/lib/potato_mesh/application/queries/common.rb b/web/lib/potato_mesh/application/queries/common.rb index 53de9cb..11d2eb9 100644 --- a/web/lib/potato_mesh/application/queries/common.rb +++ b/web/lib/potato_mesh/application/queries/common.rb @@ -224,6 +224,124 @@ module PotatoMesh threshold = 0 if threshold.nil? || threshold.negative? [threshold, floor].max end + + # SQL fragment used by every read query to filter out opted-out nodes. + # + # Operators signal opt-out by placing + # {PotatoMesh::Config::NODE_OPT_OUT_MARKER} (🛑) anywhere in their + # +short_name+ or +long_name+. The data layer still ingests records + # for these nodes — the marker only suppresses them from API responses. + # + # Wrapping each display column in +COALESCE(...,'')+ matters because + # SQL +LIKE+ against +NULL+ yields +NULL+, which is falsy in +WHERE+ + # but propagates through +NOT+ as +NULL+ — without the coalesce, any + # node missing one display column would be incorrectly filtered out. + OPT_OUT_NAME_PREDICATE = + "(COALESCE(long_name, '') LIKE '%' || ? || '%' " \ + "OR COALESCE(short_name, '') LIKE '%' || ? || '%')".freeze + + # Returns the bind parameters required by every opt-out SQL fragment. + # + # The fragments embed two LIKE expressions (one per display column), so + # each invocation needs the marker twice. Centralising the binding + # avoids drift between fragments that match against +nodes+ directly + # versus those that join via a subquery. + # + # @return [Array] bind parameters for the opt-out predicate. + def opt_out_marker_params + marker = PotatoMesh::Config.node_opt_out_marker + [marker, marker] + end + + # SQL fragment that excludes rows whose own +long_name+/+short_name+ + # carry the opt-out marker. Intended for queries that read directly + # from the +nodes+ table. + # + # @return [String] SQL predicate suitable for AND-composition. + def opt_out_self_filter + "NOT #{OPT_OUT_NAME_PREDICATE}" + end + + # Regex matching the only column-name shapes the +opt_out_node_*_filter+ + # helpers accept: bare identifiers (+node_id+), and dotted qualifiers + # (+m.from_id+). Anything else is rejected because the value is + # interpolated directly into SQL, where stray punctuation would either + # corrupt the query or open a SQL-injection surface for any future + # caller that forgets to pass a literal. + SAFE_COLUMN_IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)?\z/.freeze + + # Validate that +column+ is a plain identifier or dotted alias before + # interpolation. Raises +ArgumentError+ on anything more exotic. + # + # @param column [String] candidate column name. + # @return [String] +column+ unchanged when safe to interpolate. + def assert_safe_column_identifier!(column) + unless column.is_a?(String) && column.match?(SAFE_COLUMN_IDENTIFIER) + raise ArgumentError, "unsafe column identifier: #{column.inspect}" + end + column + end + + # SQL fragment that excludes rows whose textual node reference column + # points at an opted-out node. Use for tables that join logically via + # a +node_id+/+from_id+/+to_id+ column. + # + # NULL references on the outer column are preserved so anonymous chat + # messages and other records without an attributable sender remain + # visible. The inner subquery also filters +node_id IS NOT NULL+: in + # SQLite, +x NOT IN (subquery)+ returns UNKNOWN when the subquery + # produces a NULL, which would silently exclude every row. Guarding + # the subquery keeps that failure mode out of reach if a future opt-out + # row ever lands with a NULL +node_id+. + # + # @param column [String] qualified SQL column name (e.g. ``"m.from_id"``). + # Must match {SAFE_COLUMN_IDENTIFIER}; arbitrary user input is not + # accepted. + # @return [String] SQL predicate suitable for AND-composition. + def opt_out_node_id_filter(column) + assert_safe_column_identifier!(column) + "(#{column} IS NULL OR #{column} NOT IN (" \ + "SELECT node_id FROM nodes WHERE node_id IS NOT NULL AND #{OPT_OUT_NAME_PREDICATE}))" + end + + # SQL fragment that excludes rows whose numeric node reference column + # points at an opted-out node. Use for tables that key on the legacy + # numeric node identifier (+num+, +src+, +dest+, +trace_hops.node_id+). + # + # @param column [String] qualified SQL column name. Must match + # {SAFE_COLUMN_IDENTIFIER}. + # @return [String] SQL predicate suitable for AND-composition. + def opt_out_node_num_filter(column) + assert_safe_column_identifier!(column) + "(#{column} IS NULL OR #{column} NOT IN (" \ + "SELECT num FROM nodes WHERE num IS NOT NULL AND #{OPT_OUT_NAME_PREDICATE}))" + end + + # Append an opt-out filter to an in-flight WHERE clause builder. + # + # @param where_clauses [Array] accumulating WHERE conditions. + # @param params [Array] accumulating bind parameters. + # @param fragment [String] SQL fragment produced by one of the + # +opt_out_*_filter+ helpers. + # @return [void] + def append_opt_out_filter(where_clauses, params, fragment) + where_clauses << fragment + params.concat(opt_out_marker_params) + end + + # Clamp a caller-supplied window duration to the 28-day API visibility + # cap. Used by aggregate endpoints whose +windowSeconds+ parameter + # could otherwise reach further back than the per-id read floor. + # + # @param window_seconds [Integer, nil] requested window duration. + # @return [Integer, nil] +window_seconds+ clamped to at most 28 days, + # or +nil+ when the input is non-positive/nil. + def clamp_window_seconds(window_seconds) + return nil if window_seconds.nil? + return nil if window_seconds <= 0 + cap = PotatoMesh::Config.four_weeks_seconds + window_seconds > cap ? cap : window_seconds + end end end end diff --git a/web/lib/potato_mesh/application/queries/federation_queries.rb b/web/lib/potato_mesh/application/queries/federation_queries.rb index 6e3f715..cfe9cb3 100644 --- a/web/lib/potato_mesh/application/queries/federation_queries.rb +++ b/web/lib/potato_mesh/application/queries/federation_queries.rb @@ -44,6 +44,7 @@ module PotatoMesh params.concat(clause.last) end + append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id")) append_protocol_filter(where_clauses, params, protocol) sql = <<~SQL @@ -106,6 +107,11 @@ module PotatoMesh params.concat(clause.last) end + # Either endpoint of the neighbour relationship may carry the + # opt-out marker — filter both so a silenced node never appears as + # a source or destination of an RF link. + append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id")) + append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("neighbor_id")) append_protocol_filter(where_clauses, params, protocol) sql = <<~SQL @@ -163,6 +169,12 @@ module PotatoMesh 3.times { params.concat(numeric_values) } end + # Drop traces whose endpoints carry the opt-out marker. Hops are + # filtered separately at hydration time so a trace that only relays + # through a silenced node still surfaces with the offending hop + # removed. + append_opt_out_filter(where_clauses, params, opt_out_node_num_filter("src")) + append_opt_out_filter(where_clauses, params, opt_out_node_num_filter("dest")) append_protocol_filter(where_clauses, params, protocol) sql = <<~SQL @@ -181,10 +193,15 @@ module PotatoMesh hops_by_trace = Hash.new { |hash, key| hash[key] = [] } unless trace_ids.empty? placeholders = Array.new(trace_ids.length, "?").join(", ") + # Hide opted-out intermediate hops too — otherwise a single trace + # could expose a silenced node's numeric ID via the relay chain. + hop_filter = opt_out_node_num_filter("th.node_id") hop_rows = db.execute( - "SELECT trace_id, hop_index, node_id FROM trace_hops WHERE trace_id IN (#{placeholders}) ORDER BY trace_id, hop_index", - trace_ids, + "SELECT th.trace_id, th.hop_index, th.node_id FROM trace_hops th " \ + "WHERE th.trace_id IN (#{placeholders}) AND #{hop_filter} " \ + "ORDER BY th.trace_id, th.hop_index", + trace_ids + opt_out_marker_params, ) hop_rows.each do |hop| trace_id = coerce_integer(hop["trace_id"]) diff --git a/web/lib/potato_mesh/application/queries/node_queries.rb b/web/lib/potato_mesh/application/queries/node_queries.rb index 632e1d2..3788ac9 100644 --- a/web/lib/potato_mesh/application/queries/node_queries.rb +++ b/web/lib/potato_mesh/application/queries/node_queries.rb @@ -163,6 +163,7 @@ module PotatoMesh where_clauses << "(role IS NULL OR role <> 'CLIENT_HIDDEN')" end + append_opt_out_filter(where_clauses, params, opt_out_self_filter) append_protocol_filter(where_clauses, params, protocol) sql = <<~SQL @@ -235,6 +236,7 @@ module PotatoMesh since_threshold = normalize_since_threshold(since, floor: cutoff) where_clauses = ["last_seen_time >= ?"] params = [since_threshold] + append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id")) append_protocol_filter(where_clauses, params, protocol) sql = <<~SQL SELECT node_id, start_time, last_seen_time, version, lora_freq, modem_preset, protocol @@ -282,27 +284,39 @@ module PotatoMesh hour_cutoff = reference_now - 3600 day_cutoff = reference_now - 86_400 week_cutoff = reference_now - PotatoMesh::Config.week_seconds - month_cutoff = reference_now - (30 * 24 * 60 * 60) - pf = private_mode? ? " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')" : "" - proto = " AND protocol = ?" + # The "month" bucket reuses the four-week cap so no stats endpoint can + # surface activity from beyond the 28-day API visibility floor. + month_cutoff = reference_now - PotatoMesh::Config.four_weeks_seconds + private_clause = private_mode? ? " AND (role IS NULL OR role <> 'CLIENT_HIDDEN')" : "" + + # Materialise the visible-nodes projection in a CTE so the opt-out + # LIKE predicate is evaluated once and the marker is bound twice in + # total instead of twice per subquery (44 → 22 binds). Every COUNT + # below then operates on the pre-filtered set, which also matches the + # rest of the read API's "no opt-out node ever leaves the database + # layer" invariant. sql = <<~SQL + WITH visible_nodes AS ( + SELECT last_heard, protocol + FROM nodes + WHERE #{opt_out_self_filter}#{private_clause} + ) SELECT - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS hour_count, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS day_count, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS week_count, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}) AS month_count, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_hour, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_day, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_week, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mc_month, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_hour, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_day, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_week, - (SELECT COUNT(*) FROM nodes WHERE last_heard >= ?#{pf}#{proto}) AS mt_month + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS hour_count, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS day_count, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS week_count, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ?) AS month_count, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_hour, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_day, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_week, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mc_month, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_hour, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_day, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_week, + (SELECT COUNT(*) FROM visible_nodes WHERE last_heard >= ? AND protocol = ?) AS mt_month SQL cutoffs = [hour_cutoff, day_cutoff, week_cutoff, month_cutoff] - # Total counts bind only cutoffs; per-protocol counts bind cutoff + protocol string. - params = cutoffs + + params = opt_out_marker_params + cutoffs + cutoffs.flat_map { |c| [c, "meshcore"] } + cutoffs.flat_map { |c| [c, "meshtastic"] } row = with_busy_retry do diff --git a/web/lib/potato_mesh/application/queries/telemetry_queries.rb b/web/lib/potato_mesh/application/queries/telemetry_queries.rb index 01d8c3a..0d90705 100644 --- a/web/lib/potato_mesh/application/queries/telemetry_queries.rb +++ b/web/lib/potato_mesh/application/queries/telemetry_queries.rb @@ -44,6 +44,7 @@ module PotatoMesh params.concat(clause.last) end + append_opt_out_filter(where_clauses, params, opt_out_node_id_filter("node_id")) append_protocol_filter(where_clauses, params, protocol) sql = <<~SQL @@ -117,6 +118,12 @@ module PotatoMesh def query_telemetry_buckets(window_seconds:, bucket_seconds:, since: 0) window = coerce_integer(window_seconds) || DEFAULT_TELEMETRY_WINDOW_SECONDS window = DEFAULT_TELEMETRY_WINDOW_SECONDS if window <= 0 + # Hard-cap the aggregation window at the 28-day visibility floor so + # callers cannot bypass the data-retention policy via an oversized + # +windowSeconds+ parameter. The route layer also calls + # +clamp_window_seconds+ up-front; reusing the same helper here keeps + # the cap defined in exactly one place. + window = clamp_window_seconds(window) || DEFAULT_TELEMETRY_WINDOW_SECONDS bucket = coerce_integer(bucket_seconds) || DEFAULT_TELEMETRY_BUCKET_SECONDS bucket = DEFAULT_TELEMETRY_BUCKET_SECONDS if bucket <= 0 @@ -146,11 +153,12 @@ module PotatoMesh FROM telemetry WHERE COALESCE(rx_time, telemetry_time) IS NOT NULL AND COALESCE(rx_time, telemetry_time, 0) >= ? + AND #{opt_out_node_id_filter("node_id")} GROUP BY bucket_start ORDER BY bucket_start ASC LIMIT ? SQL - params = [bucket, bucket, since_threshold, MAX_QUERY_LIMIT] + params = [bucket, bucket, since_threshold, *opt_out_marker_params, MAX_QUERY_LIMIT] rows = db.execute(sql, params) rows.map do |row| bucket_start = coerce_integer(row["bucket_start"]) diff --git a/web/lib/potato_mesh/application/retention.rb b/web/lib/potato_mesh/application/retention.rb new file mode 100644 index 0000000..d0761d7 --- /dev/null +++ b/web/lib/potato_mesh/application/retention.rb @@ -0,0 +1,310 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +module PotatoMesh + module App + # Background data retention enforcement. + # + # The retention module deletes rows whose most recent activity timestamp + # is older than {PotatoMesh::Config.year_seconds} (365 days). Two + # mechanisms compose to make this safe: + # + # * Each table is purged on its own timestamp column ({.RETENTION_TARGETS}) + # so a stale row in one table never holds another table hostage. + # * Foreign keys defined in the schema (e.g. +neighbors+ → +nodes+, + # +trace_hops+ → +traces+) propagate cascading deletes for dependent + # rows, keeping the database consistent without bespoke DELETE + # statements per relationship. + # + # The purge runs in a dedicated daemon thread spawned during the + # Sinatra +configure+ block. An +at_exit+ hook tears the thread down + # cleanly on process exit, mirroring the federation announcer pattern. + module Retention + # Tables purged on each cycle, paired with the column whose value is + # compared against the retention cutoff. Each entry's column points at + # the freshest activity timestamp for the table — when that column + # drops below +(now - year_seconds)+ the row is unrecoverable through + # the API anyway, so it is safe to remove from disk. + # + # Ordering matters: child tables that participate in +ON DELETE CASCADE+ + # relationships (notably +neighbors+, which references +nodes+) are + # purged *before* their parents. Otherwise the parent purge would + # cascade-delete the same rows the explicit child DELETE was about to + # touch, leaving +db.changes+ to under-report the work done. + RETENTION_TARGETS = [ + ["neighbors", "rx_time"], + ["messages", "rx_time"], + ["positions", "rx_time"], + ["telemetry", "rx_time"], + ["traces", "rx_time"], + ["ingestors", "last_seen_time"], + ["nodes", "last_heard"], + ].freeze + + # Run a single retention sweep, deleting rows older than +cutoff+ from + # every {RETENTION_TARGETS} entry. Each table is wrapped in its own + # +with_busy_retry+ to stay resilient to short-lived SQLite locks held + # by the ingestor. + # + # @param now [Integer] reference unix timestamp; defaults to the + # current wall-clock seconds. Exposed for tests so they can fast + # forward without manipulating system time. + # @return [Hash{String => Integer}] count of rows removed per table. + def purge_old_data!(now: Time.now.to_i) + cutoff = now - PotatoMesh::Config.year_seconds + removed = Hash.new(0) + + db = open_database + begin + # Foreign-key cascades only fire when PRAGMA foreign_keys = ON, + # which open_database already enforces. Relying on the schema + # cascades keeps the DELETE list small and avoids the need to + # manually clean +trace_hops+ before its parent. + # + # The whole sweep runs inside a single transaction so a crash or + # shutdown mid-pass either commits every table's deletions or + # leaves the DB untouched — never half-purged. + with_busy_retry do + db.transaction do + RETENTION_TARGETS.each do |table, column| + sql = "DELETE FROM #{table} WHERE #{column} IS NOT NULL AND #{column} < ?" + db.execute(sql, [cutoff]) + removed[table] = db.changes + end + end + end + ensure + db&.close + end + + # Promoted to info so retention activity is visible at the default + # log level — on a busy node the per-table removal counts are useful + # operational signal, not debug noise. + info_log( + "Purged data outside retention window", + context: "retention.purge", + cutoff: cutoff, + removed: removed, + ) + removed + rescue StandardError => e + warn_log( + "Retention purge failed", + context: "retention.purge", + error_class: e.class.name, + error_message: e.message, + ) + {} + end + + # Whether the periodic retention worker should run for the current + # process. Mirrors {Helpers#federation_announcements_active?} so the + # test suite does not spawn a long-lived sleeper that briefly holds + # database write locks while specs are executing. + # + # {Helpers#test_environment?} is included into +Object+ at boot, so + # every host class transitively has the predicate available. + # + # @return [Boolean] +false+ in the +RACK_ENV=test+ environment, + # +true+ otherwise. + def retention_worker_active? + !test_environment? + end + + # Entry point invoked from the Sinatra +configure+ block. Clearing + # the shutdown flag before checking +start_retention_thread!+'s + # alive-guard is safe: if an old worker is still running it will keep + # going (the alive-check short-circuits the spawn), and if it is not, + # the flag must be clear for the freshly spawned thread to make any + # progress. No in-flight shutdown can be in progress here because + # +configure+ runs single-threaded at boot. + # + # In the test environment ({#retention_worker_active?} is +false+) the + # worker is intentionally skipped so specs do not race against the + # purge loop's DELETEs. + # + # @return [Thread, nil] the worker thread when spawned, +nil+ when the + # environment opted out. + def start_retention_worker_if_active! + clear_retention_shutdown_request! + if retention_worker_active? + start_retention_thread! + else + debug_log( + "Retention worker disabled", + context: "retention", + reason: "test environment", + ) + nil + end + end + + # Spawn the long-running retention worker. Mirrors the federation + # announcer thread layout — short shutdown-aware sleeps so the daemon + # exits promptly when the process is shutting down. + # + # @return [Thread, nil] the worker thread, or +nil+ when already running. + def start_retention_thread! + ensure_retention_shutdown_hook! + + existing = settings.respond_to?(:retention_thread) ? settings.retention_thread : nil + return existing if existing&.alive? + + thread = Thread.new do + retention_thread_loop + end + thread.name = "potato-mesh-retention" if thread.respond_to?(:name=) + thread.daemon = true if thread.respond_to?(:daemon=) + set(:retention_thread, thread) if respond_to?(:set) + thread + end + + # Driver loop for the retention worker. Extracted so unit tests can + # exercise the body in isolation without spawning a real Thread. + # + # @return [void] + def retention_thread_loop + # Wait briefly before the first sweep so schema migrations and + # federation handshakes complete before a long DELETE acquires write + # locks. + delay = PotatoMesh::Config.initial_retention_delay_seconds + return unless retention_sleep_with_shutdown(delay) + + loop do + # purge_old_data! rescues StandardError internally and logs to the + # "retention.purge" context, so the loop body is guaranteed not to + # raise. No outer rescue is needed. + purge_old_data! + break unless retention_sleep_with_shutdown( + PotatoMesh::Config.retention_purge_interval_seconds, + ) + end + end + + # Sleep in small slices so the retention worker reacts quickly to a + # shutdown request. Mirrors {Federation#federation_sleep_with_shutdown} + # so behaviour is consistent across long-lived workers. + # + # @param seconds [Numeric] total sleep duration. + # @return [Boolean] +true+ when the full delay elapsed, +false+ when a + # shutdown was requested mid-sleep. + def retention_sleep_with_shutdown(seconds) + remaining = seconds.to_f + slice_size = 0.2 + while remaining.positive? + return false if retention_shutdown_requested? + + slice = [remaining, slice_size].min + Kernel.sleep(slice) + remaining -= slice + end + !retention_shutdown_requested? + end + + # Whether a retention shutdown has been requested. + # + # @return [Boolean] + def retention_shutdown_requested? + return false unless respond_to?(:settings) + return false unless settings.respond_to?(:retention_shutdown_requested) + + settings.retention_shutdown_requested == true + end + + # Request the retention thread to exit at the next slice boundary. + # + # @return [void] + def request_retention_shutdown! + set(:retention_shutdown_requested, true) if respond_to?(:set) + end + + # Clear any retention shutdown request. Called at boot so a previous + # shutdown does not stop a freshly started worker. + # + # @return [void] + def clear_retention_shutdown_request! + set(:retention_shutdown_requested, false) if respond_to?(:set) + end + + # Tear down the retention thread, joining it within the configured + # shutdown timeout. Invoked from the +at_exit+ hook installed by + # {#ensure_retention_shutdown_hook!}. + # + # @param timeout [Numeric] seconds to wait for clean exit. + # @return [void] + def shutdown_retention_thread!(timeout: PotatoMesh::Config.federation_shutdown_timeout_seconds) + request_retention_shutdown! + return unless respond_to?(:settings) + return unless settings.respond_to?(:retention_thread) + + thread = settings.retention_thread + if thread&.alive? + begin + thread.wakeup if thread.respond_to?(:wakeup) + rescue ThreadError + # Thread may not be sleeping; continue. + end + thread.join(timeout) + if thread.alive? + thread.kill + thread.join(0.1) + end + end + set(:retention_thread, nil) if respond_to?(:set) + end + + # Install an +at_exit+ hook that tears down the retention worker on + # process termination. Idempotent — repeated calls are no-ops after + # the first install. + # + # The module is both +extend+ed and +include+d into the Sinatra + # application class, so this method may be invoked on either the class + # (extend path) or one of its instances (include path). The first + # line below normalises both entry points onto the class so the hook + # registers exactly once per process — without it, +include+-side + # invocations would each install their own +at_exit+ block and + # +shutdown_retention_thread!+ would run repeatedly on exit. + # + # @return [void] + def ensure_retention_shutdown_hook! + application = is_a?(Class) ? self : self.class + return application.ensure_retention_shutdown_hook! unless application.equal?(self) + + installed = if respond_to?(:settings) && settings.respond_to?(:retention_shutdown_hook_installed) + settings.retention_shutdown_hook_installed + else + instance_variable_defined?(:@retention_shutdown_hook_installed) && + @retention_shutdown_hook_installed + end + return if installed + + if respond_to?(:set) && settings.respond_to?(:retention_shutdown_hook_installed=) + set(:retention_shutdown_hook_installed, true) + else + @retention_shutdown_hook_installed = true + end + + at_exit do + begin + application.shutdown_retention_thread! + rescue StandardError + # Suppress shutdown errors during interpreter teardown. + end + end + end + end + end +end diff --git a/web/lib/potato_mesh/application/routes/api.rb b/web/lib/potato_mesh/application/routes/api.rb index b503b31..0d25af8 100644 --- a/web/lib/potato_mesh/application/routes/api.rb +++ b/web/lib/potato_mesh/application/routes/api.rb @@ -317,6 +317,12 @@ module PotatoMesh halt 400, { error: "bucketSeconds must be positive" }.to_json end + # Clamp the requested window to the 28-day data-retention floor + # so no caller can reach beyond the API visibility cap by passing + # an oversized +windowSeconds+. The query layer repeats this + # clamp for defence in depth. + window_seconds = clamp_window_seconds(window_seconds) + bucket_count = (window_seconds.to_f / bucket_seconds).ceil if bucket_count > PotatoMesh::App::Queries::MAX_QUERY_LIMIT halt 400, { error: "bucketSeconds too small for requested window" }.to_json diff --git a/web/lib/potato_mesh/config.rb b/web/lib/potato_mesh/config.rb index 101470e..99a722c 100644 --- a/web/lib/potato_mesh/config.rb +++ b/web/lib/potato_mesh/config.rb @@ -201,13 +201,67 @@ module PotatoMesh # fragile (traces, neighbors, ingestors) and as the floor for every # +/api/.../:id+ lookup so callers can backfill historical records that # would otherwise fall outside the seven-day default applied to bulk - # endpoints. + # endpoints. This is also the absolute API-level visibility cap: no + # request may return data older than this window regardless of the + # +since+ or +windowSeconds+ parameters submitted. # # @return [Integer] seconds in twenty-eight days. def four_weeks_seconds 28 * 24 * 60 * 60 end + # Hard retention window applied by the periodic purge job. Rows whose + # most recent activity timestamp is older than this duration are deleted + # from the database. The 28-day API visibility floor is enforced + # separately via {#four_weeks_seconds}, so the gap between the two + # windows preserves recoverable history without ever exposing it to API + # consumers. + # + # @return [Integer] seconds in 365 days. + def year_seconds + 365 * 24 * 60 * 60 + end + + # Interval between consecutive retention purge passes. + # + # The retention worker wakes once per day to delete rows older than + # {#year_seconds}. A daily cadence keeps the working set bounded + # without holding write locks for long stretches. + # + # @return [Integer] seconds between purge cycles. + def retention_purge_interval_seconds + 24 * 60 * 60 + end + + # Grace period applied before the first retention purge runs after boot. + # + # Mirrors the federation-announcer pattern: the daemon yields the + # request thread for a short delay so initial schema migrations and + # federation handshakes complete before a potentially long-running + # +DELETE+ ties up the database. + # + # @return [Integer] seconds to wait before the first purge. + def initial_retention_delay_seconds + 30 + end + + # Substring marker that signals a node has opted out of being displayed + # in any user-facing surface. Operators include the marker anywhere in + # the node's short or long name; the API filters out any node whose + # display name contains this exact character (a single grapheme). Data + # is still ingested so deduplication and routing continue to work. + # + # @return [String] frozen single-character opt-out marker. + NODE_OPT_OUT_MARKER = "\u{1F6D1}".freeze + + # Expose {NODE_OPT_OUT_MARKER} via a method so callers that prefer the + # module-function style stay consistent with the rest of the module. + # + # @return [String] frozen opt-out marker character. + def node_opt_out_marker + NODE_OPT_OUT_MARKER + end + # Default upper bound for accepted JSON payload sizes. # # @return [Integer] byte ceiling for HTTP request bodies. diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 4616d4d..11896f9 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -1212,6 +1212,23 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(application_class.latest_node_update_timestamp).to be_nil end + + it "ignores opted-out nodes when computing the freshness hint" do + marker = PotatoMesh::Config.node_opt_out_marker + with_db do |db| + db.execute("DELETE FROM nodes") + db.execute( + "INSERT INTO nodes (node_id, long_name, last_heard) VALUES (?, ?, ?)", + ["!visible", "Visible Node", 100], + ) + db.execute( + "INSERT INTO nodes (node_id, long_name, last_heard) VALUES (?, ?, ?)", + ["!silenced", "Silenced #{marker} Node", 500], + ) + end + + expect(application_class.latest_node_update_timestamp).to eq(100) + end end describe ".build_well_known_document" do @@ -6642,6 +6659,65 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(ids).not_to include("!hidden") end + it "filters opted-out nodes (\u{1F6D1} in name) from the nodes API" do + clear_database + now = reference_time.to_i + marker = PotatoMesh::Config.node_opt_out_marker + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)", + ["!quiet001", "QT", "Quiet #{marker} Node", "TBEAM", "CLIENT", 1.0, now, now], + ) + db.execute( + "INSERT INTO nodes(node_id, short_name, long_name, hw_model, role, snr, last_heard, first_heard) VALUES(?,?,?,?,?,?,?,?)", + ["!loud0001", "LD", "Loud Node", "TBEAM", "CLIENT", 1.0, now, now], + ) + end + + get "/api/nodes?limit=10" + expect(last_response).to be_ok + ids = JSON.parse(last_response.body).map { |row| row["node_id"] } + expect(ids).to include("!loud0001") + expect(ids).not_to include("!quiet001") + + # Per-id lookup must also pretend the opted-out node does not exist. + get "/api/nodes/!quiet001" + expect(last_response.status).to eq(404) + end + + it "still ingests opted-out node data even though the API hides it" do + clear_database + marker = PotatoMesh::Config.node_opt_out_marker + payload = { + "!silenced" => { + "num" => 0xdead0001, + "lastHeard" => reference_time.to_i, + "user" => { + "shortName" => "SL", + "longName" => "Silenced #{marker} Node", + }, + }, + } + post "/api/nodes", payload.to_json, auth_headers + expect(last_response).to be_ok + + # The row exists in the database — opt-out is a display-time filter, + # not an ingestion-time refusal. + with_db(readonly: true) do |db| + row = db.execute("SELECT node_id, long_name FROM nodes WHERE node_id = ?", ["!silenced"]).first + expect(row).not_to be_nil + expect(row[1]).to include(marker) + end + + # Bulk and per-id endpoints both omit the opted-out node. + get "/api/nodes" + ids = JSON.parse(last_response.body).map { |r| r["node_id"] } + expect(ids).not_to include("!silenced") + + get "/api/nodes/!silenced" + expect(last_response.status).to eq(404) + end + it "removes the chat interface from the homepage" do get "/" @@ -7351,6 +7427,18 @@ RSpec.describe "Potato Mesh Sinatra app" do expect(last_response.status).to eq(400) expect(JSON.parse(last_response.body)).to eq("error" => "bucketSeconds too small for requested window") end + + it "clamps windowSeconds to the 28-day visibility cap" do + # A 10-year window is well beyond four_weeks_seconds; the bucket size + # is selected so that under the natural 10-year window the bucket count + # would explode past MAX_QUERY_LIMIT and the route would return 400. + # When the cap kicks in correctly the request succeeds with HTTP 200. + huge_window = 10 * 365 * 24 * 60 * 60 + bucket = PotatoMesh::Config.four_weeks_seconds / 100 + get "/api/telemetry/aggregated?windowSeconds=#{huge_window}&bucketSeconds=#{bucket}" + + expect(last_response).to be_ok + end end describe "GET /api/traces" do diff --git a/web/spec/config_spec.rb b/web/spec/config_spec.rb index d46f36f..90429f8 100644 --- a/web/spec/config_spec.rb +++ b/web/spec/config_spec.rb @@ -657,6 +657,40 @@ RSpec.describe PotatoMesh::Config do # @param values [Hash{String=>String, nil}] key/value pairs to set in ENV. # @yield [] block executed while the overrides are active. # @return [void] + describe ".year_seconds" do + it "matches 365 days expressed in seconds" do + expect(described_class.year_seconds).to eq(365 * 24 * 60 * 60) + end + + it "is strictly larger than the 28-day visibility window" do + expect(described_class.year_seconds).to be > described_class.four_weeks_seconds + end + end + + describe ".retention_purge_interval_seconds" do + it "defaults to running daily" do + expect(described_class.retention_purge_interval_seconds).to eq(24 * 60 * 60) + end + end + + describe ".initial_retention_delay_seconds" do + it "yields long enough for boot work to finish without blocking startup" do + delay = described_class.initial_retention_delay_seconds + expect(delay).to be > 0 + expect(delay).to be < described_class.retention_purge_interval_seconds + end + end + + describe ".node_opt_out_marker" do + it "is the U+1F6D1 stop sign emoji" do + expect(described_class.node_opt_out_marker).to eq("\u{1F6D1}") + end + + it "is exposed as a frozen constant" do + expect(described_class::NODE_OPT_OUT_MARKER).to be_frozen + end + end + def within_env(values) original = {} values.each do |key, value| diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb index 19ba54b..82591fb 100644 --- a/web/spec/federation_spec.rb +++ b/web/spec/federation_spec.rb @@ -2259,6 +2259,59 @@ RSpec.describe PotatoMesh::App::Federation do end end + describe "federation counts honour the opt-out marker" do + around do |example| + Dir.mktmpdir("federation-optout-") do |dir| + db_path = File.join(dir, "mesh.db") + RSpec::Mocks.with_temporary_scope do + allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path) + allow(PotatoMesh::Config).to receive(:db_busy_timeout_ms).and_return(5000) + db_helper = Object.new.extend(PotatoMesh::App::Database) + db_helper.init_db + db_helper.ensure_schema_upgrades + example.run + end + end + end + + let(:marker) { PotatoMesh::Config.node_opt_out_marker } + let(:now) { Time.now.to_i } + + it "excludes opted-out nodes from active_node_count_since" do + db = SQLite3::Database.new(PotatoMesh::Config.db_path) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \ + "VALUES (?,?,?,?,?,?,?,?)", + ["!visnode1", 0x10000001, "VN", "Visible", now, now, "CLIENT", "meshtastic"], + ) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \ + "VALUES (?,?,?,?,?,?,?,?)", + ["!hidnode1", 0x10000002, "HN", "Hidden #{marker}", now, now, "CLIENT", "meshtastic"], + ) + db.close + + expect(federation_helpers.active_node_count_since(now - 60)).to eq(1) + end + + it "excludes opted-out nodes from active_node_count_since_for_protocol" do + db = SQLite3::Database.new(PotatoMesh::Config.db_path) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \ + "VALUES (?,?,?,?,?,?,?,?)", + ["!mcvisible", 0x20000001, "MV", "MC Visible", now, now, "COMPANION", "meshcore"], + ) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role, protocol) " \ + "VALUES (?,?,?,?,?,?,?,?)", + ["!mchidden", 0x20000002, "MH", "MC #{marker} Hidden", now, now, "COMPANION", "meshcore"], + ) + db.close + + expect(federation_helpers.active_node_count_since_for_protocol(now - 60, "meshcore")).to eq(1) + end + end + describe ".federation_worker_pool" do it "delegates to ensure_federation_worker_pool!" do sentinel = Object.new diff --git a/web/spec/logging_spec.rb b/web/spec/logging_spec.rb index 2c25f3e..6109d54 100644 --- a/web/spec/logging_spec.rb +++ b/web/spec/logging_spec.rb @@ -49,3 +49,33 @@ describe PotatoMesh::Logging do end end end + +# The Helpers module wraps PotatoMesh::Logging with severity-specific +# convenience methods. Each helper has the same shape, so a parameterised +# round-trip spec keeps coverage tight without duplicating boilerplate. +RSpec.describe PotatoMesh::App::Helpers do + let(:host) do + klass = Class.new + klass.include(PotatoMesh::App::Helpers) + klass.new + end + + let(:logger) { instance_double(Logger) } + + { + debug_log: :debug, + info_log: :info, + warn_log: :warn, + }.each do |helper, severity| + describe "##{helper}" do + it "forwards to PotatoMesh::Logging at :#{severity}" do + allow(PotatoMesh::Logging).to receive(:logger_for).with(host).and_return(logger) + expect(PotatoMesh::Logging).to receive(:log).with( + logger, severity, "hello", context: "test", foo: "bar", + ) + + host.public_send(helper, "hello", context: "test", foo: "bar") + end + end + end +end diff --git a/web/spec/queries_spec.rb b/web/spec/queries_spec.rb index 6edab42..323da9e 100644 --- a/web/spec/queries_spec.rb +++ b/web/spec/queries_spec.rb @@ -878,4 +878,286 @@ RSpec.describe PotatoMesh::App::Queries do expect(sql_fragment).to include("OR") end end + + # --------------------------------------------------------------------------- + # opt-out helpers (🛑 in long/short name) + # --------------------------------------------------------------------------- + describe "#opt_out_marker_params" do + it "returns the marker twice so each LIKE expression has a bound value" do + params = queries.opt_out_marker_params + expect(params.length).to eq(2) + expect(params).to all(eq(PotatoMesh::Config.node_opt_out_marker)) + end + end + + describe "#opt_out_self_filter" do + it "is a negation of the OR-of-LIKE name predicate" do + fragment = queries.opt_out_self_filter + expect(fragment).to start_with("NOT ") + expect(fragment).to include("COALESCE(long_name") + expect(fragment).to include("COALESCE(short_name") + end + end + + describe "#opt_out_node_id_filter" do + it "wraps the column lookup with NULL passthrough" do + fragment = queries.opt_out_node_id_filter("m.from_id") + expect(fragment).to include("m.from_id IS NULL") + expect(fragment).to include("m.from_id NOT IN") + expect(fragment).to include("SELECT node_id FROM nodes") + end + + it "guards the inner subquery against NULL node_id values" do + # SQLite's `NOT IN (subquery returning NULL)` evaluates to UNKNOWN and + # would silently drop every row. The subquery must reject NULL ids. + fragment = queries.opt_out_node_id_filter("from_id") + expect(fragment).to include("node_id IS NOT NULL") + end + + it "rejects column identifiers containing unsafe characters" do + expect { queries.opt_out_node_id_filter("from_id; DROP TABLE nodes--") }.to raise_error(ArgumentError, /unsafe column identifier/) + expect { queries.opt_out_node_id_filter("") }.to raise_error(ArgumentError, /unsafe column identifier/) + expect { queries.opt_out_node_id_filter(nil) }.to raise_error(ArgumentError, /unsafe column identifier/) + end + end + + describe "#opt_out_node_num_filter" do + it "guards against NULL numeric IDs and skips nodes without num" do + fragment = queries.opt_out_node_num_filter("src") + expect(fragment).to include("src IS NULL") + expect(fragment).to include("src NOT IN") + expect(fragment).to include("num IS NOT NULL") + end + + it "rejects column identifiers containing unsafe characters" do + expect { queries.opt_out_node_num_filter("src OR 1=1") }.to raise_error(ArgumentError, /unsafe column identifier/) + end + end + + describe "#assert_safe_column_identifier!" do + it "accepts bare identifiers and dotted qualifiers" do + expect(queries.assert_safe_column_identifier!("node_id")).to eq("node_id") + expect(queries.assert_safe_column_identifier!("m.from_id")).to eq("m.from_id") + end + + it "rejects anything else" do + ["1bad", "a.b.c", "name`", "name with space", nil, 42].each do |bad| + expect { queries.assert_safe_column_identifier!(bad) }.to raise_error(ArgumentError, /unsafe column identifier/) + end + end + end + + describe "#append_opt_out_filter" do + it "appends the SQL fragment and its two marker bind values" do + clauses = [] + params = [] + queries.append_opt_out_filter(clauses, params, queries.opt_out_self_filter) + expect(clauses.length).to eq(1) + expect(params.length).to eq(2) + end + end + + describe "#clamp_window_seconds" do + it "returns nil for non-positive or nil input" do + expect(queries.clamp_window_seconds(nil)).to be_nil + expect(queries.clamp_window_seconds(0)).to be_nil + expect(queries.clamp_window_seconds(-1)).to be_nil + end + + it "passes positive values through when within the 28-day cap" do + expect(queries.clamp_window_seconds(60)).to eq(60) + end + + it "clamps oversized windows to four_weeks_seconds" do + huge = PotatoMesh::Config.four_weeks_seconds * 10 + expect(queries.clamp_window_seconds(huge)).to eq(PotatoMesh::Config.four_weeks_seconds) + end + end + + describe "opt-out filtering in read queries" do + let(:marker) { PotatoMesh::Config.node_opt_out_marker } + + # Seed a visible node and an opted-out node sharing similar telemetry, + # message, position, neighbor, and trace footprints so each query helper + # can be checked for opt-out compliance from one fixture. + before do + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!visible0", 0x00bb0001, "VIS", "Visible Node", now, now, "CLIENT"], + ) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!optout01", 0x00bb0002, "OUT", "Hidden #{marker} Node", now, now, "CLIENT"], + ) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) " \ + "VALUES (?,?,?,?,?,?,?)", + ["!optshort", 0x00bb0003, "S#{marker}X", "Short Marker", now, now, "CLIENT"], + ) + + rx_iso = Time.at(now).utc.iso8601 + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)", + [10, now, rx_iso, "!visible0", "!ffffffff", 0, "from-visible"], + ) + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)", + [11, now, rx_iso, "!optout01", "!ffffffff", 0, "from-optout"], + ) + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, channel, text) VALUES (?,?,?,?,?,?,?)", + [12, now, rx_iso, "!visible0", "!optout01", 0, "to-optout"], + ) + + db.execute( + "INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)", + [10, now, rx_iso, "!visible0", 52.0, 13.0], + ) + db.execute( + "INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)", + [11, now, rx_iso, "!optout01", 53.0, 14.0], + ) + + db.execute( + "INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)", + [10, now, rx_iso, "!visible0", "device"], + ) + db.execute( + "INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)", + [11, now, rx_iso, "!optout01", "device"], + ) + + db.execute( + "INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES (?,?,?,?)", + ["!visible0", "!optout01", 5.0, now], + ) + + db.execute( + "INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)", + [10, now, rx_iso, 0x00bb0001, 0xdeadbeef], + ) + db.execute( + "INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)", + [11, now, rx_iso, 0x00bb0002, 0xdeadbeef], + ) + db.execute( + "INSERT INTO trace_hops(trace_id, hop_index, node_id) VALUES (?,?,?)", + [10, 0, 0x00bb0002], + ) + + db.execute( + "INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)", + ["!visible0", now, now, "1.0"], + ) + db.execute( + "INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)", + ["!optout01", now, now, "1.0"], + ) + end + end + + it "excludes opted-out nodes from query_nodes by long_name marker" do + ids = queries.query_nodes(50).map { |row| row["node_id"] } + expect(ids).to include("!visible0") + expect(ids).not_to include("!optout01") + end + + it "excludes opted-out nodes from query_nodes by short_name marker" do + ids = queries.query_nodes(50).map { |row| row["node_id"] } + expect(ids).not_to include("!optshort") + end + + it "returns no row when querying a single opted-out node by id" do + rows = queries.query_nodes(10, node_ref: "!optout01") + expect(rows).to be_empty + end + + it "drops chat lines whose sender or recipient is opted out" do + texts = queries.query_messages(50, include_encrypted: true).map { |r| r["text"] } + expect(texts).to include("from-visible") + expect(texts).not_to include("from-optout") + expect(texts).not_to include("to-optout") + end + + it "hides position rows for opted-out nodes" do + ids = queries.query_positions(50).map { |r| r["node_id"] } + expect(ids).to include("!visible0") + expect(ids).not_to include("!optout01") + end + + it "hides telemetry rows for opted-out nodes" do + ids = queries.query_telemetry(50).map { |r| r["node_id"] } + expect(ids).to include("!visible0") + expect(ids).not_to include("!optout01") + end + + it "hides neighbour relationships involving opted-out nodes" do + rows = queries.query_neighbors(50) + expect(rows).to be_empty + end + + it "hides traces whose src or dest is opted out" do + ids = queries.query_traces(50).map { |r| r["id"] } + expect(ids).to include(10) + expect(ids).not_to include(11) + end + + it "scrubs opted-out hop ids from surviving traces" do + rows = queries.query_traces(50) + trace_10 = rows.find { |r| r["id"] == 10 } + expect(trace_10).not_to be_nil + # Hop 0x00bb0002 belongs to the opted-out node and must be filtered out + # of the hop list even though the parent trace is visible. + expect(trace_10["hops"]).to be_nil + end + + it "hides ingestor heartbeats for opted-out nodes" do + ids = queries.query_ingestors(50).map { |r| r["node_id"] } + expect(ids).to include("!visible0") + expect(ids).not_to include("!optout01") + end + + it "excludes opted-out nodes from active stats counters" do + stats = queries.query_active_node_stats(now: now) + # Only "!visible0" is visible — opted-out and short-marker nodes are dropped. + expect(stats["day"]).to eq(1) + expect(stats["week"]).to eq(1) + expect(stats["month"]).to eq(1) + end + end + + describe "#query_active_node_stats" do + it "caps the month bucket at four_weeks_seconds (28 days)" do + twenty_nine_days_ago = now - (29 * 24 * 60 * 60) + with_db do |db| + db.execute( + "INSERT INTO nodes(node_id, num, short_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?)", + ["!29dayago", 0x29000001, "OLD", twenty_nine_days_ago, twenty_nine_days_ago, "CLIENT"], + ) + end + stats = queries.query_active_node_stats(now: now) + # 28-day cap means this 29-day-old row falls outside the "month" bucket. + expect(stats["month"]).to eq(0) + end + end + + describe "#query_telemetry_buckets" do + it "clamps oversized window_seconds to the 28-day visibility cap" do + huge_window = PotatoMesh::Config.four_weeks_seconds * 50 + rows = queries.query_telemetry_buckets( + window_seconds: huge_window, + bucket_seconds: PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS, + ) + # Even with a 50× window the implementation must not look beyond 28 days; + # the returned bucket set therefore matches a 28-day query exactly. + reference = queries.query_telemetry_buckets( + window_seconds: PotatoMesh::Config.four_weeks_seconds, + bucket_seconds: PotatoMesh::App::Queries::DEFAULT_TELEMETRY_BUCKET_SECONDS, + ) + expect(rows.length).to eq(reference.length) + end + end end diff --git a/web/spec/retention_spec.rb b/web/spec/retention_spec.rb new file mode 100644 index 0000000..31dcc8b --- /dev/null +++ b/web/spec/retention_spec.rb @@ -0,0 +1,595 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# frozen_string_literal: true + +require "spec_helper" +require "sqlite3" + +# Specs covering the 365-day data retention purge implemented in +# +PotatoMesh::App::Retention+. The module is wired into the running +# Sinatra application; the tests below mix it into a bare host class so +# the retention behaviour can be exercised without spinning up the full +# web stack. +RSpec.describe PotatoMesh::App::Retention do + let(:harness_class) do + Class.new do + extend PotatoMesh::App::Retention + extend PotatoMesh::App::Database + extend PotatoMesh::App::Helpers + + class << self + # Provide read-write database connections so the retention purge can + # execute its DELETE statements. + def open_database(readonly: false) + db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly) + db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms + db.execute("PRAGMA foreign_keys = ON") + db + end + + # Capture debug/warn log entries so tests can inspect what the + # retention worker reported. + attr_reader :log_events + + def warn_log(message, context:, **metadata) + (@log_events ||= []) << { + level: :warn, message: message, context: context, metadata: metadata, + } + end + + def info_log(message, context:, **metadata) + (@log_events ||= []) << { + level: :info, message: message, context: context, metadata: metadata, + } + end + + def debug_log(message, context:, **metadata) + (@log_events ||= []) << { + level: :debug, message: message, context: context, metadata: metadata, + } + end + + def reset_logs! + @log_events = [] + end + end + end + end + + let(:now) { Time.now.to_i } + + around do |example| + Dir.mktmpdir("retention-spec-") do |dir| + db_path = File.join(dir, "mesh.db") + + RSpec::Mocks.with_temporary_scope do + allow(PotatoMesh::Config).to receive(:db_path).and_return(db_path) + allow(PotatoMesh::Config).to receive(:db_busy_timeout_ms).and_return(5000) + + harness_class.init_db + harness_class.ensure_schema_upgrades + harness_class.reset_logs! + + example.run + end + end + end + + # Helper to insert a fresh row plus a stale row into each retention target. + # +ages+ map column → seconds-old-relative-to-+now+. + def seed_retention_dataset(now_ts) + db = SQLite3::Database.new(PotatoMesh::Config.db_path) + db.execute("PRAGMA foreign_keys = ON") + + fresh = now_ts - 100 + stale = now_ts - PotatoMesh::Config.year_seconds - 86_400 # 1 day past the cutoff + + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?,?)", + ["!fffff001", 0xfffff001, "FR", "Fresh Node", fresh, fresh, "CLIENT"], + ) + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, last_heard, first_heard, role) VALUES (?,?,?,?,?,?,?)", + ["!aaaaa001", 0xaaaaa001, "ST", "Stale Node", stale, stale, "CLIENT"], + ) + + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, text) VALUES (?,?,?,?,?,?)", + [1, fresh, Time.at(fresh).utc.iso8601, "!fffff001", "!ffffffff", "fresh"], + ) + db.execute( + "INSERT INTO messages(id, rx_time, rx_iso, from_id, to_id, text) VALUES (?,?,?,?,?,?)", + [2, stale, Time.at(stale).utc.iso8601, "!fffff001", "!ffffffff", "stale"], + ) + + db.execute( + "INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)", + [1, fresh, Time.at(fresh).utc.iso8601, "!fffff001", 52.0, 13.0], + ) + db.execute( + "INSERT INTO positions(id, rx_time, rx_iso, node_id, latitude, longitude) VALUES (?,?,?,?,?,?)", + [2, stale, Time.at(stale).utc.iso8601, "!fffff001", 53.0, 14.0], + ) + + db.execute( + "INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)", + [1, fresh, Time.at(fresh).utc.iso8601, "!fffff001", "device"], + ) + db.execute( + "INSERT INTO telemetry(id, rx_time, rx_iso, node_id, telemetry_type) VALUES (?,?,?,?,?)", + [2, stale, Time.at(stale).utc.iso8601, "!fffff001", "device"], + ) + + db.execute( + "INSERT INTO neighbors(node_id, neighbor_id, snr, rx_time) VALUES (?,?,?,?)", + ["!fffff001", "!aaaaa001", 4.0, fresh], + ) + + db.execute( + "INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)", + [1, fresh, Time.at(fresh).utc.iso8601, 0xaaaaaaaa, 0xbbbbbbbb], + ) + db.execute( + "INSERT INTO traces(id, rx_time, rx_iso, src, dest) VALUES (?,?,?,?,?)", + [2, stale, Time.at(stale).utc.iso8601, 0xaaaaaaaa, 0xbbbbbbbb], + ) + db.execute( + "INSERT INTO trace_hops(trace_id, hop_index, node_id) VALUES (?,?,?)", + [2, 0, 0xcccccccc], + ) + + db.execute( + "INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)", + ["!fffff001", fresh, fresh, "1.0"], + ) + db.execute( + "INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES (?,?,?,?)", + ["!aaaaa001", stale, stale, "1.0"], + ) + ensure + db&.close + end + + describe ".purge_old_data!" do + it "removes rows older than 365 days from every retention target" do + seed_retention_dataset(now) + removed = harness_class.purge_old_data!(now: now) + + db = SQLite3::Database.new(PotatoMesh::Config.db_path) + begin + node_ids = db.execute("SELECT node_id FROM nodes ORDER BY node_id").flatten + expect(node_ids).to include("!fffff001") + expect(node_ids).not_to include("!aaaaa001") + + msg_ids = db.execute("SELECT id FROM messages ORDER BY id").flatten + expect(msg_ids).to eq([1]) + + pos_ids = db.execute("SELECT id FROM positions ORDER BY id").flatten + expect(pos_ids).to eq([1]) + + tel_ids = db.execute("SELECT id FROM telemetry ORDER BY id").flatten + expect(tel_ids).to eq([1]) + + trace_ids = db.execute("SELECT id FROM traces ORDER BY id").flatten + expect(trace_ids).to eq([1]) + + # Cascading DELETE on traces removes the dependent trace_hops row. + hop_count = db.get_first_value("SELECT COUNT(*) FROM trace_hops") + expect(hop_count).to eq(0) + + ingestor_ids = db.execute("SELECT node_id FROM ingestors ORDER BY node_id").flatten + expect(ingestor_ids).to eq(["!fffff001"]) + + # Cascading DELETE on nodes removes the neighbour relationship that + # referenced the deleted stale node. + neighbor_count = db.get_first_value("SELECT COUNT(*) FROM neighbors") + expect(neighbor_count).to eq(0) + ensure + db&.close + end + + expect(removed["nodes"]).to eq(1) + expect(removed["messages"]).to eq(1) + expect(removed["positions"]).to eq(1) + expect(removed["telemetry"]).to eq(1) + expect(removed["traces"]).to eq(1) + expect(removed["ingestors"]).to eq(1) + end + + it "keeps every row when the database is empty" do + removed = harness_class.purge_old_data!(now: now) + expect(removed.values.uniq).to eq([0]) + end + + it "logs the successful purge at info level" do + # Promoted from debug so retention activity is visible at the default + # log verbosity; specs lock the level in so a future refactor cannot + # silently demote it. + harness_class.purge_old_data!(now: now) + entry = harness_class.log_events.find do |e| + e[:context] == "retention.purge" && e[:level] == :info + end + expect(entry).not_to be_nil + end + + it "logs and recovers from unexpected SQLite errors" do + # Force a transient SQLite error by closing the database file mid-call. + allow(harness_class).to receive(:open_database).and_raise(SQLite3::Exception, "boom") + + expect(harness_class.purge_old_data!(now: now)).to eq({}) + + failure = harness_class.log_events.find { |e| e[:level] == :warn } + expect(failure).not_to be_nil + expect(failure[:context]).to eq("retention.purge") + end + + it "leaves rows with NULL retention timestamps untouched" do + # A node whose last_heard is NULL has no activity timestamp at all, + # so the purge cannot prove it is older than the cutoff and must + # leave it alone. The +IS NOT NULL+ guard in the DELETE protects + # against accidentally wiping every such row. + db = SQLite3::Database.new(PotatoMesh::Config.db_path) + begin + db.execute( + "INSERT INTO nodes(node_id, num, short_name, long_name, first_heard, role) VALUES (?,?,?,?,?,?)", + ["!nullnode", 0xdeadbeef, "NN", "Null Node", now - 100, "CLIENT"], + ) + ensure + db.close + end + + harness_class.purge_old_data!(now: now) + + db = SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: true) + begin + expect(db.execute("SELECT node_id FROM nodes").flatten).to include("!nullnode") + ensure + db.close + end + end + end + + describe ".retention_sleep_with_shutdown" do + it "returns true after slicing through the requested duration" do + slept = [] + allow(Kernel).to receive(:sleep) { |seconds| slept << seconds } + + # 0.5 s with 0.2 s slices means three iterations: 0.2, 0.2, 0.1. + result = harness_class.retention_sleep_with_shutdown(0.5) + + expect(result).to be(true) + expect(slept.sum).to be_within(1e-9).of(0.5) + expect(slept).to all(be <= 0.2 + 1e-9) + expect(slept.length).to be >= 2 + end + + it "returns true immediately when the duration is non-positive" do + expect(Kernel).not_to receive(:sleep) + expect(harness_class.retention_sleep_with_shutdown(0.0)).to be(true) + end + + it "returns false immediately when shutdown is already requested" do + allow(harness_class).to receive(:retention_shutdown_requested?).and_return(true) + result = harness_class.retention_sleep_with_shutdown(5.0) + expect(result).to be(false) + end + + it "bails out mid-sleep when shutdown is requested between slices" do + slept = [] + allow(Kernel).to receive(:sleep) { |seconds| slept << seconds } + call_count = 0 + allow(harness_class).to receive(:retention_shutdown_requested?) do + call_count += 1 + call_count > 2 # first two checks: keep sleeping; then shutdown. + end + + result = harness_class.retention_sleep_with_shutdown(5.0) + + expect(result).to be(false) + # Should not have slept the entire 5 s — the loop terminates early. + expect(slept.sum).to be < 1.0 + end + end + + describe ".retention_shutdown_requested?" do + it "returns false when settings are not available" do + expect(harness_class.retention_shutdown_requested?).to be(false) + end + end + + describe ".retention_thread_loop" do + it "exits without purging when shutdown is requested during the initial delay" do + allow(harness_class).to receive(:retention_sleep_with_shutdown).and_return(false) + expect(harness_class).not_to receive(:purge_old_data!) + + harness_class.retention_thread_loop + end + + it "purges once and exits when the post-iteration sleep is interrupted" do + call_count = 0 + allow(harness_class).to receive(:retention_sleep_with_shutdown) do |_seconds| + call_count += 1 + call_count == 1 # only the initial delay returns true; the next sleep + # signals shutdown and the loop must terminate. + end + expect(harness_class).to receive(:purge_old_data!).once.and_return({}) + + harness_class.retention_thread_loop + end + + it "tolerates purge_old_data! returning an empty hash on error" do + # purge_old_data! handles its own errors and returns {} — the loop + # should treat that as a normal outcome and proceed to the sleep step. + call_count = 0 + allow(harness_class).to receive(:retention_sleep_with_shutdown) do |_seconds| + call_count += 1 + call_count == 1 + end + expect(harness_class).to receive(:purge_old_data!).once.and_return({}) + + expect { harness_class.retention_thread_loop }.not_to raise_error + end + end + + describe ".purge_old_data! return shape" do + it "produces an integer entry for every retention target" do + removed = harness_class.purge_old_data!(now: now) + described_class::RETENTION_TARGETS.each do |(table, _column)| + expect(removed).to have_key(table) + expect(removed[table]).to be_a(Integer) + end + end + end + + # --------------------------------------------------------------------------- + # Shutdown plumbing — exercised with a Sinatra-shaped settings double so the + # respond_to? branches inside the retention module are followed end-to-end. + # --------------------------------------------------------------------------- + describe "shutdown lifecycle helpers" do + # A Sinatra-shaped settings double exposing every attribute the retention + # module's respond_to? checks look up. + settings_struct = Struct.new( + :retention_thread, + :retention_shutdown_requested, + :retention_shutdown_hook_installed, + ) + + let(:host_class) do + stx = settings_struct + klass = Class.new do + extend PotatoMesh::App::Retention + end + klass.define_singleton_method(:settings) do + @settings ||= stx.new + end + klass.define_singleton_method(:set) do |key, value| + writer = "#{key}=" + raise ArgumentError, "unsupported setting #{key}" unless settings.respond_to?(writer) + settings.public_send(writer, value) + end + klass.define_singleton_method(:debug_log) { |*| } + klass.define_singleton_method(:warn_log) { |*| } + klass + end + + describe ".start_retention_thread!" do + it "spawns a worker, stores it on settings, and returns the thread" do + # Replace the loop body with a brief sleep so the thread exits quickly + # rather than entering the real production loop. + allow(host_class).to receive(:retention_thread_loop) { sleep(0.01) } + allow(host_class).to receive(:ensure_retention_shutdown_hook!) + + thread = host_class.start_retention_thread! + + expect(thread).to be_a(Thread) + expect(host_class.settings.retention_thread).to be(thread) + thread.join(1) + end + + it "returns the existing thread when one is still alive" do + allow(host_class).to receive(:ensure_retention_shutdown_hook!) + # An infinite sleep stands in for a long-lived worker. + existing = Thread.new { sleep } + host_class.settings.retention_shutdown_requested = false + host_class.set(:retention_thread, existing) + + result = host_class.start_retention_thread! + + expect(result).to be(existing) + ensure + existing&.kill + existing&.join(0.1) + end + end + + describe ".retention_shutdown_requested?" do + it "is false when no shutdown has been requested" do + host_class.settings.retention_shutdown_requested = nil + expect(host_class.retention_shutdown_requested?).to be(false) + end + + it "is true once a shutdown has been requested" do + host_class.settings.retention_shutdown_requested = true + expect(host_class.retention_shutdown_requested?).to be(true) + end + + it "is false when settings lack the shutdown attribute" do + bare = Class.new { extend PotatoMesh::App::Retention } + bare_struct = Struct.new(:other) + bare.define_singleton_method(:settings) { @settings ||= bare_struct.new } + expect(bare.retention_shutdown_requested?).to be(false) + end + end + + describe ".request_retention_shutdown! / .clear_retention_shutdown_request!" do + it "flips the settings flag on and off" do + host_class.request_retention_shutdown! + expect(host_class.settings.retention_shutdown_requested).to be(true) + host_class.clear_retention_shutdown_request! + expect(host_class.settings.retention_shutdown_requested).to be(false) + end + end + + describe ".shutdown_retention_thread!" do + it "is a no-op when no thread has been registered" do + host_class.set(:retention_thread, nil) + expect { host_class.shutdown_retention_thread!(timeout: 0.05) }.not_to raise_error + end + + it "wakes and joins a sleeping worker thread" do + thread = Thread.new { sleep } + host_class.set(:retention_thread, thread) + + host_class.shutdown_retention_thread!(timeout: 0.5) + + expect(thread).not_to be_alive + expect(host_class.settings.retention_thread).to be_nil + end + + it "swallows ThreadError when wakeup raises" do + thread = Thread.new { sleep } + # Force wakeup to raise — the rescue inside shutdown_retention_thread! + # must swallow it and still join the thread. + allow(thread).to receive(:wakeup).and_raise(ThreadError, "dead"); allow(thread).to receive(:respond_to?).and_call_original + host_class.set(:retention_thread, thread) + + expect { host_class.shutdown_retention_thread!(timeout: 0.5) }.not_to raise_error + ensure + thread&.kill + thread&.join(0.1) + end + + it "force-kills the worker when join times out" do + # A purely CPU-bound thread won't respond to wakeup, so join(timeout) + # will time out and the kill branch must take over. + thread = Thread.new { loop { } } + host_class.set(:retention_thread, thread) + + host_class.shutdown_retention_thread!(timeout: 0.05) + + expect(thread).not_to be_alive + end + end + + describe ".ensure_retention_shutdown_hook!" do + it "installs the hook exactly once when settings carry the flag" do + # First call flips the flag; second call returns early. + expect(host_class).to receive(:at_exit).once + host_class.ensure_retention_shutdown_hook! + host_class.ensure_retention_shutdown_hook! + expect(host_class.settings.retention_shutdown_hook_installed).to be(true) + end + + it "delegates instance invocations to the host class" do + allow(host_class).to receive(:at_exit) + host_class.ensure_retention_shutdown_hook! + instance = host_class.new + # No raise — the instance entry-point should redirect to the class. + expect { instance.ensure_retention_shutdown_hook! }.not_to raise_error + end + + it "falls back to an instance variable when settings lack the flag" do + # Build a class whose settings double has no + # retention_shutdown_hook_installed accessor, exercising the ivar + # branch in ensure_retention_shutdown_hook!. + bare_settings = Struct.new(:retention_thread) + bare = Class.new { extend PotatoMesh::App::Retention } + bare.define_singleton_method(:settings) { @settings ||= bare_settings.new } + bare.define_singleton_method(:set) { |*| } + bare.define_singleton_method(:shutdown_retention_thread!) { |*| } + allow(bare).to receive(:at_exit) + + bare.ensure_retention_shutdown_hook! + bare.ensure_retention_shutdown_hook! + + expect(bare.instance_variable_get(:@retention_shutdown_hook_installed)).to be(true) + end + + it "registers an at_exit hook that calls shutdown_retention_thread!" do + captured = nil + allow(host_class).to receive(:at_exit) { |&block| captured = block } + host_class.ensure_retention_shutdown_hook! + expect(captured).not_to be_nil + + expect(host_class).to receive(:shutdown_retention_thread!) + captured.call + end + + it "swallows errors raised by shutdown_retention_thread! in the at_exit hook" do + captured = nil + allow(host_class).to receive(:at_exit) { |&block| captured = block } + host_class.ensure_retention_shutdown_hook! + + allow(host_class).to receive(:shutdown_retention_thread!).and_raise(StandardError, "boom") + expect { captured.call }.not_to raise_error + end + end + end + + # --------------------------------------------------------------------------- + # retention_worker_active? also has a host-class branch that prefers the + # local +test_environment?+ helper. Exercise it to lock in that fallback. + # --------------------------------------------------------------------------- + describe ".start_retention_worker_if_active!" do + it "spawns the worker when retention_worker_active? is true" do + klass = Class.new { extend PotatoMesh::App::Retention } + klass.define_singleton_method(:retention_worker_active?) { true } + klass.define_singleton_method(:clear_retention_shutdown_request!) { } + klass.define_singleton_method(:debug_log) { |*| } + sentinel = Object.new + allow(klass).to receive(:start_retention_thread!).and_return(sentinel) + + expect(klass.start_retention_worker_if_active!).to be(sentinel) + end + + it "skips the worker and logs a debug message when inactive" do + klass = Class.new { extend PotatoMesh::App::Retention } + klass.define_singleton_method(:retention_worker_active?) { false } + klass.define_singleton_method(:clear_retention_shutdown_request!) { } + captured = [] + klass.define_singleton_method(:debug_log) do |message, **metadata| + captured << [message, metadata] + end + expect(klass).not_to receive(:start_retention_thread!) + + expect(klass.start_retention_worker_if_active!).to be_nil + expect(captured.first.first).to eq("Retention worker disabled") + end + end + + describe ".retention_worker_active?" do + # The retention module relies on the Helpers#test_environment? predicate, + # which is included into Object at boot time. Both branches are + # exercised by toggling RACK_ENV. + let(:host) { Class.new { extend PotatoMesh::App::Retention } } + + around do |example| + original = ENV["RACK_ENV"] + example.run + ensure + ENV["RACK_ENV"] = original + end + + it "returns false when RACK_ENV is test" do + ENV["RACK_ENV"] = "test" + expect(host.retention_worker_active?).to be(false) + end + + it "returns true when RACK_ENV is anything else" do + ENV["RACK_ENV"] = "production" + expect(host.retention_worker_active?).to be(true) + end + end +end