mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 18:01:19 +02:00
d2fe4f8223
* web: add node opt-out marker and data retention policies * web: address review comments * web: address review comments
311 lines
12 KiB
Ruby
311 lines
12 KiB
Ruby
# 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
|