diff --git a/README.md b/README.md index c428045..530e77a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # supaspin -Version 1.3.2 +Version 1.3.3 Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a single-node [microk8s](https://microk8s.io) server, from a terminal menu. @@ -291,8 +291,10 @@ it receives in the `_supabase` database and never deletes them, so the 2 GB db i every delivery with an error, and vector was killed against its 1024 MiB ceiling every few minutes with the undeliverable backlog. The fix is log retention: every instance gets a pg_cron job (set up on create, upgrade and resume) that deletes analytics logs older than **2 days** every -night. That bounds both the db image and vector's backlog, so vector is back to the 512 MiB -ceiling the production supabase charts carry. +night. pg_cron in this image only runs jobs from the `postgres` database, so the job lives there +and reaches the log tables in `_supabase` over a local dblink connection. That bounds both the +db image and vector's backlog, so vector is back to the 512 MiB ceiling the production supabase +charts carry. Changing the numbers in this table only affects instances created afterwards. To put them on instances that already run, use `sudo supaspin upgrade`: it reads back the values Helm stored when diff --git a/VERSION b/VERSION index 1892b92..31e5c84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.2 +1.3.3 diff --git a/supaspin b/supaspin index 11f0325..a4fd329 100755 --- a/supaspin +++ b/supaspin @@ -39,7 +39,7 @@ import tty import yaml -VERSION = "1.3.2" +VERSION = "1.3.3" CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml") STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin") CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes" @@ -88,24 +88,38 @@ DEFAULT_MEMORY = (128, 512) # Analytics logs: deleted from every instance's _supabase database when older than this. Logflare # stores logs in _analytics.log_events_* tables and never deletes them; without this job the # capped db image fills up and the database dies (instance 3, 2026-10-06). +# pg_cron in the supabase image only runs jobs from the database named in cron.database_name, +# which the image pins to `postgres`, while the logs live in `_supabase`. The job therefore runs +# in `postgres` and reaches the logs over a local dblink connection, which needs no password: the +# image trusts local socket connections for supabase_admin. LOG_RETENTION = "2 days" LOG_RETENTION_JOB = "supaspin-log-retention" RETENTION_SQL = f""" create extension if not exists pg_cron; -create or replace function _analytics.delete_old_logs() returns void language plpgsql as $fn$ +create extension if not exists dblink; +create or replace function supaspin_delete_old_logs() returns void +language plpgsql as $fn$ declare + conn text := 'supaspin_lr_' || substr(md5(clock_timestamp()::text), 1, 8); t text; begin + perform dblink_connect(conn, 'dbname=_supabase user=supabase_admin'); for t in - select tablename from pg_tables - where schemaname = '_analytics' and tablename like 'log_events\\_%' + select tablename from dblink(conn, + 'select tablename from pg_tables where schemaname = ''_analytics'' and tablename like ''log_events\\_%''') + as log_tables(tablename text) loop - execute format('delete from _analytics.%I where timestamp < now() - interval ''{LOG_RETENTION}''', t); + perform dblink_exec(conn, + format('delete from _analytics.%I where timestamp < now() - interval ''{LOG_RETENTION}''', t)); end loop; + perform dblink_disconnect(conn); +exception when others then + begin perform dblink_disconnect(conn); exception when others then null; end; + raise; end $fn$; -select _analytics.delete_old_logs(); +select supaspin_delete_old_logs(); delete from cron.job where jobname = '{LOG_RETENTION_JOB}'; -select cron.schedule('{LOG_RETENTION_JOB}', '17 3 * * *', 'select _analytics.delete_old_logs();'); +select cron.schedule('{LOG_RETENTION_JOB}', '17 3 * * *', 'select supaspin_delete_old_logs()'); """.strip() DEFAULT_MAIL = {"host": "smtp.example.com", "port": 587, "user": "", "password": "", @@ -800,8 +814,10 @@ def setup_log_retention(cfg, n, log): """Give the instance a pg_cron job that deletes analytics logs older than LOG_RETENTION every night. Logflare stores the logs it receives in the _supabase database and never deletes them; without this job the capped db image fills up and the database dies (instance 3 on - 2026-10-06). Runs the first cleanup right away, so an upgrade also frees a full disk. Runs - after create, upgrade and resume; scheduling under a fixed job name makes it idempotent.""" + 2026-10-06). The job runs in the postgres database, the only one pg_cron accepts in this + image, and deletes in _supabase over a local dblink connection. Runs the first cleanup right + away, so an upgrade also frees a full disk. Runs after create, upgrade and resume; + scheduling under a fixed job name makes it idempotent.""" log(f" log retention: deleting analytics logs older than {LOG_RETENTION}, every night") kubectl("-n", namespace(n), "exec", f"{fullname(n)}-db-0", "-c", "supabase-db", "--", "psql", "-U", "supabase_admin", "-d", "_supabase", "-v", "ON_ERROR_STOP=1", diff --git a/tests/test_supaspin.py b/tests/test_supaspin.py index f3cde5e..892e9e4 100644 --- a/tests/test_supaspin.py +++ b/tests/test_supaspin.py @@ -134,11 +134,15 @@ class Values(unittest.TestCase): def test_log_retention_sql(self): # Logflare stores logs in _analytics.log_events_* and never deletes them; the job deletes # rows older than LOG_RETENTION and is scheduled under a fixed name so re-runs overwrite. - self.assertIn("schemaname = '_analytics'", sp.RETENTION_SQL) - self.assertIn("like 'log_events\\_%'", sp.RETENTION_SQL) + # pg_cron in the supabase image only runs jobs from the `postgres` database, so the job + # reaches the log tables in `_supabase` over a local dblink connection (trusted there). + self.assertIn("schemaname = ''_analytics''", sp.RETENTION_SQL) + self.assertIn("''log_events\\_%''", sp.RETENTION_SQL) self.assertIn(f"interval ''{sp.LOG_RETENTION}''", sp.RETENTION_SQL) self.assertIn(f"'{sp.LOG_RETENTION_JOB}'", sp.RETENTION_SQL) self.assertIn("cron.schedule", sp.RETENTION_SQL) + self.assertIn("dblink_connect", sp.RETENTION_SQL) + self.assertIn("user=supabase_admin", sp.RETENTION_SQL) def test_imgproxy_reads_the_storage_volume(self): img = self.v["persistence"]["imgproxy"]