feat: make hard deletion of disliked tracks real
The dislike lifecycle promised WARNED -> HIDDEN -> deleted, but nothing ever
removed a file: cleanup.service logged its intent behind
MUZICK_ALLOW_HARD_DELETE and returned, and the two backend delete paths
(hardDeleteTrack, permanentlyDeleteTrack) disagreed about what deletion
meant. The review recommended dropping hard deletion and making HIDDEN
terminal; the owner chose to make deletion real instead.
- cleanup.service performs a true unlink() — no trash directory — for
tracks that have been HIDDEN for a 7-day grace period, then settles the
row. This is the single unlink() call site in the system.
- permanentlyDeleteTrack is the one delete path; hardDeleteTrack is gone.
- a deleted_permanent audit row records what was removed, and
migration 20260730_hard_delete_audit_trail backs it.
MUZICK_ALLOW_HARD_DELETE remains OFF: the docker-compose entry is commented
out, there is no enabling default in code, and the worker's /music bind is
the only writable one. Deletion stays dry-run until the owner opts in
deliberately.
REVIEW-2026-07-30.md open decision: dislike lifecycle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+259
-31
@@ -1,10 +1,73 @@
|
||||
import type { Pool } from 'pg';
|
||||
import { unlink } from 'fs/promises';
|
||||
import { withTransaction } from './db.js';
|
||||
|
||||
const NTFY_URL = process.env.NTFY_URL || '';
|
||||
const NTFY_TOPIC = process.env.NTFY_TOPIC || 'muzick';
|
||||
const SYSTEM_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
/**
|
||||
* Hard deletion is IRREVERSIBLE and destroys the owner's music files. Three
|
||||
* independent gates stand in front of it, all env-configurable, all defaulting
|
||||
* to the safe value:
|
||||
*
|
||||
* - MUZICK_ALLOW_HARD_DELETE (default false) — the master switch. While it is
|
||||
* off the sweep runs its full selection logic and logs exactly what it WOULD
|
||||
* delete, but performs no unlink and makes no transition to the terminal
|
||||
* state. This is the default mode of operation; the owner enables it by hand
|
||||
* after reading a dry-run log. It is not set to true anywhere in the repo.
|
||||
* - MUZICK_HARD_DELETE_MAX_PER_SWEEP (default 5) — blast-radius cap. On hitting
|
||||
* it the sweep stops, logs at error, and leaves the remainder for next time,
|
||||
* so a selection bug cannot empty the library in one run.
|
||||
* - MUZICK_HARD_DELETE_GRACE_DAYS (default 7) — minimum age since the track
|
||||
* entered HIDDEN (dislikes.hidden_at). Separate from, and much longer than,
|
||||
* dislikes.grace_hours (default 48), which only governs HIDDEN -> WARNED.
|
||||
*
|
||||
* Note that only the worker container mounts /music read-write; the backend
|
||||
* keeps `:ro`, so nothing in the API request path can unlink a library file.
|
||||
*/
|
||||
function envFlag(name: string): boolean {
|
||||
const raw = (process.env[name] || '').trim().toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes';
|
||||
}
|
||||
|
||||
function envInt(name: string, fallback: number, min: number): number {
|
||||
const raw = (process.env[name] || '').trim();
|
||||
if (!raw) return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n < min) {
|
||||
console.error(
|
||||
`[Cleanup] Ignoring invalid ${name}=${JSON.stringify(raw)}; using ${fallback}`
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export const HARD_DELETE_DEFAULTS = {
|
||||
/** MUZICK_ALLOW_HARD_DELETE */
|
||||
allow: false,
|
||||
/** MUZICK_HARD_DELETE_MAX_PER_SWEEP */
|
||||
maxPerSweep: 5,
|
||||
/** MUZICK_HARD_DELETE_GRACE_DAYS */
|
||||
graceDays: 7,
|
||||
} as const;
|
||||
|
||||
interface HardDeleteConfig {
|
||||
allow: boolean;
|
||||
maxPerSweep: number;
|
||||
graceDays: number;
|
||||
}
|
||||
|
||||
export function readHardDeleteConfig(): HardDeleteConfig {
|
||||
return {
|
||||
// Default OFF. Anything other than an explicit truthy opt-in is a dry run.
|
||||
allow: envFlag('MUZICK_ALLOW_HARD_DELETE'),
|
||||
maxPerSweep: envInt('MUZICK_HARD_DELETE_MAX_PER_SWEEP', HARD_DELETE_DEFAULTS.maxPerSweep, 1),
|
||||
graceDays: envInt('MUZICK_HARD_DELETE_GRACE_DAYS', HARD_DELETE_DEFAULTS.graceDays, 1),
|
||||
};
|
||||
}
|
||||
|
||||
async function sendNtfy(title: string, message: string): Promise<void> {
|
||||
if (!NTFY_URL) return;
|
||||
try {
|
||||
@@ -18,20 +81,51 @@ async function sendNtfy(title: string, message: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export class CleanupSweepService {
|
||||
constructor(private pgClient: Pool) {}
|
||||
interface Candidate {
|
||||
track_id: string;
|
||||
track_path: string;
|
||||
track_title: string | null;
|
||||
track_artist: string | null;
|
||||
}
|
||||
|
||||
async runSweep(): Promise<{ warned: number; deleted: number }> {
|
||||
export interface SweepResult {
|
||||
warned: number;
|
||||
/** Files actually unlinked and DB rows actually deleted. Always 0 in dry-run. */
|
||||
deleted: number;
|
||||
/**
|
||||
* Eligible candidates seen. Selection stops at `maxPerSweep + 1` rows, so this
|
||||
* saturates at cap+1 — read `eligible > maxPerSweep` as "more remain".
|
||||
*/
|
||||
eligible: number;
|
||||
/** True when MUZICK_ALLOW_HARD_DELETE was off, i.e. nothing was destroyed. */
|
||||
dryRun: boolean;
|
||||
/** Unlinks that failed and were left recorded in pending_file_deletions. */
|
||||
failed: number;
|
||||
/** Previously-failed/interrupted unlinks reaped at the start of this sweep. */
|
||||
reaped: number;
|
||||
}
|
||||
|
||||
export class CleanupSweepService {
|
||||
// A Pool, not a Client: finalizeDeleted() runs a real transaction and must
|
||||
// check out a dedicated connection for it. The worker runs jobs with
|
||||
// `concurrency: 10`, so a BEGIN on a shared connection would enrol other
|
||||
// jobs' queries in this transaction and discard them on ROLLBACK.
|
||||
constructor(private pool: Pool) {}
|
||||
|
||||
async runSweep(): Promise<SweepResult> {
|
||||
const config = readHardDeleteConfig();
|
||||
const warned = await this.advanceToWarned();
|
||||
const deleted = await this.finalizeDeleted();
|
||||
return { warned, deleted };
|
||||
const reaped = await this.reapPendingFileDeletions(config);
|
||||
const finalized = await this.finalizeDeleted(config);
|
||||
return { warned, reaped, ...finalized };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find HIDDEN dislikes past their grace period → send ntfy warning, mark WARNED.
|
||||
* Not gated: this transition destroys nothing.
|
||||
*/
|
||||
private async advanceToWarned(): Promise<number> {
|
||||
const res = await this.pgClient.query<{ track_id: string; track_title: string; track_artist: string }>(
|
||||
const res = await this.pool.query<{ track_id: string; track_title: string; track_artist: string }>(
|
||||
`SELECT d.track_id, t.title AS track_title, t.artist AS track_artist
|
||||
FROM dislikes d
|
||||
JOIN tracks t ON t.id = d.track_id
|
||||
@@ -40,7 +134,7 @@ export class CleanupSweepService {
|
||||
);
|
||||
|
||||
for (const row of res.rows) {
|
||||
await this.pgClient.query(
|
||||
await this.pool.query(
|
||||
`UPDATE dislikes SET warned_at = NOW(), state = 'WARNED' WHERE track_id = $1`,
|
||||
[row.track_id]
|
||||
);
|
||||
@@ -55,44 +149,178 @@ export class CleanupSweepService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Find WARNED dislikes where 24h has passed since warned_at → delete file + DB record.
|
||||
* Retry files whose DB row is already gone but whose bytes survive — an unlink
|
||||
* that failed, or a worker that died between COMMIT and unlink. Subject to the
|
||||
* same gates as a fresh deletion.
|
||||
*/
|
||||
private async finalizeDeleted(): Promise<number> {
|
||||
const res = await this.pgClient.query<{ track_id: string; track_path: string; track_title: string }>(
|
||||
`SELECT d.track_id, t.path AS track_path, t.title AS track_title
|
||||
private async reapPendingFileDeletions(config: HardDeleteConfig): Promise<number> {
|
||||
const res = await this.pool.query<{ path: string; track_id: string | null; attempts: number }>(
|
||||
`SELECT path, track_id, attempts
|
||||
FROM pending_file_deletions
|
||||
ORDER BY requested_at ASC
|
||||
LIMIT $1`,
|
||||
[config.maxPerSweep]
|
||||
);
|
||||
if (res.rows.length === 0) return 0;
|
||||
|
||||
if (!config.allow) {
|
||||
for (const row of res.rows) {
|
||||
console.warn(
|
||||
`[Cleanup] DRY RUN (MUZICK_ALLOW_HARD_DELETE off): orphaned file left on disk ` +
|
||||
`after ${row.attempts} attempt(s): ${row.path}`
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
let reaped = 0;
|
||||
for (const row of res.rows) {
|
||||
if (await this.unlinkAndSettle(row.path, row.track_id)) reaped++;
|
||||
}
|
||||
return reaped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find WARNED dislikes eligible for hard deletion and, unless dry-run, destroy
|
||||
* them: DB transaction commits FIRST, then the file is unlinked.
|
||||
*
|
||||
* The old implementation unlinked before the transaction and `continue`d on
|
||||
* failure, which is backwards on both counts: the irreversible act happened
|
||||
* before anything durable recorded it, and a failure left no trace beyond a log
|
||||
* line. Now the commit records the intent (audit row + pending_file_deletions
|
||||
* marker) and the unlink either clears the marker or annotates it for retry.
|
||||
* A crash in that window leaves the marker, which the next sweep reaps.
|
||||
*/
|
||||
private async finalizeDeleted(
|
||||
config: HardDeleteConfig
|
||||
): Promise<{ deleted: number; eligible: number; dryRun: boolean; failed: number }> {
|
||||
// Ask for one more than the cap purely to detect overflow.
|
||||
const res = await this.pool.query<Candidate>(
|
||||
`SELECT d.track_id, t.path AS track_path,
|
||||
t.title AS track_title, t.artist AS track_artist
|
||||
FROM dislikes d
|
||||
JOIN tracks t ON t.id = d.track_id
|
||||
WHERE d.state = 'WARNED'
|
||||
AND NOW() > d.warned_at + INTERVAL '24 hours'`
|
||||
AND NOW() > d.warned_at + INTERVAL '24 hours'
|
||||
AND NOW() > COALESCE(d.hidden_at, d.disliked_at) + ($1 || ' days')::interval
|
||||
ORDER BY d.warned_at ASC
|
||||
LIMIT $2`,
|
||||
[config.graceDays, config.maxPerSweep + 1]
|
||||
);
|
||||
|
||||
const eligible = res.rows.length;
|
||||
if (eligible === 0) {
|
||||
return { deleted: 0, eligible: 0, dryRun: !config.allow, failed: 0 };
|
||||
}
|
||||
|
||||
let batch = res.rows;
|
||||
if (batch.length > config.maxPerSweep) {
|
||||
batch = batch.slice(0, config.maxPerSweep);
|
||||
console.error(
|
||||
`[Cleanup] Per-sweep deletion cap reached (${config.maxPerSweep}, ` +
|
||||
`MUZICK_HARD_DELETE_MAX_PER_SWEEP). Stopping after ${batch.length}; ` +
|
||||
`remaining eligible tracks are left for the next sweep.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!config.allow) {
|
||||
// Dry run: log the selection and return. No unlink, no state transition,
|
||||
// no audit row — this branch performs no writes of any kind.
|
||||
console.warn(
|
||||
`[Cleanup] DRY RUN: MUZICK_ALLOW_HARD_DELETE is off. ` +
|
||||
`${batch.length} file(s) WOULD be permanently deleted ` +
|
||||
`(grace=${config.graceDays}d, cap=${config.maxPerSweep}). Nothing was changed.`
|
||||
);
|
||||
for (const row of batch) {
|
||||
console.warn(
|
||||
`[Cleanup] DRY RUN would delete: ${row.track_path} ` +
|
||||
`("${row.track_title ?? '?'}" by ${row.track_artist ?? '?'}, track ${row.track_id})`
|
||||
);
|
||||
}
|
||||
return { deleted: 0, eligible, dryRun: true, failed: 0 };
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
for (const row of res.rows) {
|
||||
let failed = 0;
|
||||
for (const row of batch) {
|
||||
try {
|
||||
await unlink(row.track_path);
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
console.error(`[Cleanup] Failed to delete file ${row.track_path}:`, err);
|
||||
continue;
|
||||
}
|
||||
// Step 1: commit the DB side. The audit row carries the denormalised
|
||||
// path/title/artist because feedback.track_id becomes NULL the moment
|
||||
// the track is deleted (ON DELETE SET NULL) — this row is the only
|
||||
// forensic record that this file ever existed.
|
||||
await withTransaction(this.pool, async (client) => {
|
||||
await client.query(
|
||||
`INSERT INTO feedback (user_id, track_id, action, track_path, track_title, track_artist)
|
||||
VALUES ($1, $2, 'deleted_permanent', $3, $4, $5)`,
|
||||
[SYSTEM_USER_ID, row.track_id, row.track_path, row.track_title, row.track_artist]
|
||||
);
|
||||
await client.query(
|
||||
`INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
|
||||
[row.track_path, row.track_id, row.track_title, row.track_artist]
|
||||
);
|
||||
await client.query('DELETE FROM tracks WHERE id = $1', [row.track_id]);
|
||||
});
|
||||
} catch (err) {
|
||||
// withTransaction already rolled back and released the connection.
|
||||
// Nothing was committed and no file was touched: safe to skip.
|
||||
console.error(`[Cleanup] DB deletion failed for ${row.track_id}, file untouched:`, err);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pgClient.query('BEGIN');
|
||||
await this.pgClient.query(
|
||||
`INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')`,
|
||||
[SYSTEM_USER_ID, row.track_id]
|
||||
);
|
||||
await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [row.track_id]);
|
||||
await this.pgClient.query('COMMIT');
|
||||
// Step 2: the irreversible part, now that it is durably recorded.
|
||||
if (await this.unlinkAndSettle(row.track_path, row.track_id)) {
|
||||
deleted++;
|
||||
console.log(`[Cleanup] Permanently deleted: ${row.track_title} (${row.track_id})`);
|
||||
} catch (err) {
|
||||
await this.pgClient.query('ROLLBACK');
|
||||
console.error(`[Cleanup] DB deletion failed for ${row.track_id}:`, err);
|
||||
} else {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
return deleted;
|
||||
return { deleted, eligible, dryRun: false, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink one file and settle its pending_file_deletions marker: clear it on
|
||||
* success (or ENOENT — the file is gone either way, which is the desired end
|
||||
* state), annotate it on any other error so the failure is durable and
|
||||
* retryable instead of swallowed. Returns true if the file is gone.
|
||||
*
|
||||
* Callers must only reach this once the DB side is committed.
|
||||
*/
|
||||
private async unlinkAndSettle(path: string, trackId: string | null): Promise<boolean> {
|
||||
try {
|
||||
await unlink(path);
|
||||
} catch (err: any) {
|
||||
if (err?.code !== 'ENOENT') {
|
||||
console.error(
|
||||
`[Cleanup] Unlink FAILED for ${path} (track ${trackId ?? 'unknown'}); ` +
|
||||
`DB row already deleted, file left on disk and recorded in ` +
|
||||
`pending_file_deletions for retry:`,
|
||||
err
|
||||
);
|
||||
try {
|
||||
await this.pool.query(
|
||||
`UPDATE pending_file_deletions
|
||||
SET attempts = attempts + 1, last_attempt_at = NOW(), last_error = $2
|
||||
WHERE path = $1`,
|
||||
[path, String(err?.message || err?.code || err)]
|
||||
);
|
||||
} catch (markerErr) {
|
||||
console.error(`[Cleanup] Could not record unlink failure for ${path}:`, markerErr);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.pool.query('DELETE FROM pending_file_deletions WHERE path = $1', [path]);
|
||||
} catch (markerErr) {
|
||||
// The file is gone; a stale marker only causes a harmless ENOENT retry.
|
||||
console.error(`[Cleanup] Could not clear deletion marker for ${path}:`, markerErr);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +126,11 @@ async function initWorker() {
|
||||
console.log('[Cleanup] Starting dislike cleanup sweep');
|
||||
const cleanupService = new CleanupSweepService(pgPool);
|
||||
const result = await cleanupService.runSweep();
|
||||
console.log(`[Cleanup] Sweep complete: warned=${result.warned} deleted=${result.deleted}`);
|
||||
console.log(
|
||||
`[Cleanup] Sweep complete: warned=${result.warned} eligible=${result.eligible} ` +
|
||||
`deleted=${result.deleted} failed=${result.failed} reaped=${result.reaped}` +
|
||||
(result.dryRun ? ' (DRY RUN — MUZICK_ALLOW_HARD_DELETE off, nothing deleted)' : '')
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'vibe_reap': {
|
||||
|
||||
Reference in New Issue
Block a user