Save DB space and egress by removing prompts answered by <= 5 people

This commit is contained in:
MartinBraquet
2026-06-08 15:58:13 +02:00
parent 558fdfbca4
commit 622e137c73
5 changed files with 203 additions and 14 deletions

View File

@@ -0,0 +1,72 @@
import chalk from 'chalk'
import {runScript} from './run-script'
// Deletes "orphaned" compatibility answers — rows whose `question_id` points at
// a compatibility_prompts row that no longer exists. These accumulated because
// neither answer table had a foreign key on question_id, so deleting a prompt
// left its answers behind. Covers both answer tables:
// - compatibility_answers (multiple-choice answers)
// - compatibility_answers_free (free-response answers)
//
// The companion migration
// supabase/migrations/20250101005800_add_compatibility_answers_question_fk.sql
// adds `question_id -> compatibility_prompts(id) ON DELETE CASCADE` so this
// can't recur. That migration also clears orphans inline (a FK can't be added
// while violating rows exist), so this script is mainly for a quick ad-hoc
// cleanup / dry-run audit before the migration is applied.
//
// Dry-run by default: set DELETE=1 in the environment to actually delete.
// Safe to re-run; idempotent.
const TABLES = ['compatibility_answers', 'compatibility_answers_free'] as const
runScript(async ({pg}) => {
const dryRun = process.env.DELETE !== '1'
console.log(
chalk.cyan(
`Finding orphaned compatibility answers (question_id with no matching prompt)… ${
dryRun ? chalk.yellow('(DRY RUN — set DELETE=1 to delete)') : chalk.red('(DELETING)')
}`,
),
)
let grandTotal = 0
for (const table of TABLES) {
const orphanCount = await pg.one(
`SELECT count(*)::int AS n
FROM ${table} a
WHERE NOT EXISTS (
SELECT 1 FROM compatibility_prompts p WHERE p.id = a.question_id
)`,
[],
(r: {n: number}) => r.n,
)
console.log(chalk.blue(` ${table}: ${orphanCount} orphaned answer(s).`))
grandTotal += orphanCount
if (orphanCount === 0 || dryRun) continue
const deleted = await pg.result(
`DELETE FROM ${table} a
WHERE NOT EXISTS (
SELECT 1 FROM compatibility_prompts p WHERE p.id = a.question_id
)`,
[],
(r) => r.rowCount,
)
console.log(chalk.green(` [DELETED] ${deleted} row(s) from ${table}`))
}
if (dryRun) {
console.log(
chalk.yellow(`\nDry run complete — ${grandTotal} orphaned row(s) found, none deleted.`),
)
} else {
console.log(chalk.cyan('\n── Deletion complete ───────────────────────────────'))
console.log(chalk.green(` Orphans removed : ${grandTotal}`))
console.log(chalk.cyan('────────────────────────────────────────────────────'))
}
process.exit(0)
})

View File

@@ -0,0 +1,98 @@
import chalk from 'chalk'
import {runScript} from './run-script'
// Deletes every row in `compatibility_prompts` answered by fewer than N
// distinct people.
//
// "Answered by a person" means that person's user id appears as a creator_id in
// either answer table for the prompt:
// - compatibility_answers (multiple-choice answers)
// - compatibility_answers_free (free-response answers)
// We count DISTINCT creator_id across both tables (a single person who answered
// in both still counts once), then delete prompts whose count is < N. N defaults
// to 1 (so the default deletes only prompts nobody answered); set THRESHOLD=k to
// delete every prompt with fewer than k distinct answerers.
//
// Deleting a prompt cascades to compatibility_prompts_translations
// (fk_question ON DELETE CASCADE), so its translations are removed too. This
// reclaims DB storage and cuts egress from clients fetching dead prompts.
//
// Dry-run by default: set DELETE=1 in the environment to actually delete.
// Safe to re-run; processed in batches to avoid long-running transactions.
// See compatibility_prompts_rows.sql and compatibility_answers_rows.csv (private) for backup
// with all the 2800 prompts and answers in case we need to restore some of them.
const BATCH_SIZE = 500
runScript(async ({pg}) => {
const dryRun = process.env.DELETE !== '1'
const threshold = 2
console.log(
chalk.cyan(
`Finding compatibility prompts answered by fewer than ${threshold} people… ${
dryRun ? chalk.yellow('(DRY RUN — set DELETE=1 to delete)') : chalk.red('(DELETING)')
}`,
),
)
const ids = await pg.map(
`SELECT p.id, p.question, coalesce(ac.answerers, 0)::int AS answerers
FROM compatibility_prompts p
LEFT JOIN (
SELECT question_id, count(DISTINCT creator_id) AS answerers
FROM (
-- multiple_choice = -1 means the person skipped the prompt; don't count it
SELECT question_id, creator_id FROM compatibility_answers
WHERE multiple_choice IS DISTINCT FROM -1
UNION
SELECT question_id, creator_id FROM compatibility_answers_free
WHERE multiple_choice IS DISTINCT FROM -1
) u
GROUP BY question_id
) ac ON ac.question_id = p.id
WHERE coalesce(ac.answerers, 0) <= $1
ORDER BY p.id`,
[threshold],
(row: {id: string; question: string; answerers: number}) => row,
)
console.log(
chalk.blue(`Found ${ids.length} prompt(s) answered by fewer than ${threshold} people.`),
)
if (ids.length < 2000) {
for (const {id, question, answerers} of ids) {
console.log(chalk.gray(` id=${id} (${answerers} answerer(s)) — ${question}`))
}
}
if (ids.length === 0) {
console.log(chalk.green('Nothing to delete.'))
process.exit(0)
}
if (dryRun) {
console.log(chalk.yellow('\nDry run complete — no rows deleted.'))
process.exit(0)
}
let totalDeleted = 0
const allIds = ids.map((r) => r.id)
for (let i = 0; i < allIds.length; i += BATCH_SIZE) {
const batch = allIds.slice(i, i + BATCH_SIZE)
const deleted = await pg.result(
`DELETE FROM compatibility_prompts WHERE id = ANY($1::bigint[])`,
[batch],
(r) => r.rowCount,
)
totalDeleted += deleted
console.log(chalk.green(` [DELETED] batch of ${deleted} prompt(s)`))
}
console.log(chalk.cyan('\n── Deletion complete ───────────────────────────────'))
console.log(chalk.green(` Deleted : ${totalDeleted}`))
console.log(chalk.cyan('────────────────────────────────────────────────────'))
process.exit(0)
})

View File

@@ -1,4 +1,5 @@
-- Get expensive queries (in terms of total rows)
-- Run in Supabase -> SQL editor -> Export -> Copy as CSV
SELECT query,
calls,
total_exec_time,
@@ -6,16 +7,20 @@ SELECT query,
1.0 * total_exec_time / calls AS avg_time
FROM pg_stat_statements
ORDER BY rows DESC
LIMIT 10;
LIMIT 20;
-- Get row size (in bytes)
SELECT count(*) as sampled_rows,
pg_size_pretty(sum(pg_column_size(t.*))) as total_data_size,
pg_size_pretty(avg(pg_column_size(t.*))) as avg_row_size
pg_size_pretty(avg(pg_column_size(t.*))) as bytes_per_row
FROM (
-- Paste your actual query here, but add a LIMIT
SELECT *
from user_events
LIMIT 1000) t;
-- Multiply row size by the number of rows to get the get total egress size for each query (since pg_stat_statement was last reset)
-- Multiply row size by the number of rows to get the get total egress size for each query (since pg_stat_statement was last reset)
-- Do this in Supabase Largest Queries.xlsx (private file)
-- Reset pg_stat_statements (for next time, to have stats starting from today)
SELECT pg_stat_statements_reset();

View File

@@ -1,23 +1,31 @@
CREATE TABLE IF NOT EXISTS compatibility_answers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
explanation TEXT,
importance INTEGER NOT NULL,
multiple_choice INTEGER NOT NULL,
pref_choices INTEGER[] NOT NULL,
question_id BIGINT NOT NULL
CREATE TABLE IF NOT EXISTS compatibility_answers
(
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
explanation TEXT,
importance INTEGER NOT NULL,
multiple_choice INTEGER NOT NULL,
pref_choices INTEGER[] NOT NULL,
question_id BIGINT NOT NULL
);
ALTER TABLE compatibility_answers
ADD CONSTRAINT compatibility_answers_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
REFERENCES users (id)
ON DELETE CASCADE;
ALTER TABLE compatibility_answers
ADD CONSTRAINT compatibility_answers_question_id_fkey
FOREIGN KEY (question_id)
REFERENCES compatibility_prompts (id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE compatibility_answers ENABLE ROW LEVEL SECURITY;
ALTER TABLE compatibility_answers
ENABLE ROW LEVEL SECURITY;
ALTER TABLE compatibility_answers
ADD CONSTRAINT unique_question_creator

View File

@@ -14,6 +14,12 @@ ALTER TABLE compatibility_answers_free
REFERENCES users(id)
ON DELETE CASCADE;
ALTER TABLE compatibility_answers_free
ADD CONSTRAINT compatibility_answers_free_question_id_fkey
FOREIGN KEY (question_id)
REFERENCES compatibility_prompts(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE compatibility_answers_free ENABLE ROW LEVEL SECURITY;