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:
kami
2026-07-30 23:56:49 +04:00
parent 27e8acc592
commit 963f845733
6 changed files with 450 additions and 103 deletions
+259 -31
View File
@@ -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;
}
}
+5 -1
View File
@@ -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': {