mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-10 19:03:28 +02:00
web: add node opt-out marker and data retention policies (#793)
* web: add node opt-out marker and data retention policies * web: address review comments * web: address review comments
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>] 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<String>] 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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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|
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user