feat(federation): add soft-delete GC for empty group DMs with 24h grace period

Replace the hard-delete in the leave handler with a soft-delete (sets
deleted_at timestamp) when the last member leaves a group DM. A new
janitor sweep in the federation worker runs hourly and purges channels
whose grace period has expired, cascading through reactions, embeds,
attachments, messages, members, outbox/mutation-log/file-queue entries,
and finally the channel itself.

All client-facing dm_channels queries now filter on deleted_at IS NULL
to hide soft-deleted channels from the REST API and WebSocket ready
payload.
This commit is contained in:
Jannis Braun
2026-03-26 20:35:55 +01:00
parent 62a16e884a
commit 4efa35f311
4 changed files with 177 additions and 45 deletions
@@ -3,6 +3,7 @@ import * as schema from '../db/schema.js';
import { eq, and, lte, asc, inArray } from 'drizzle-orm';
import { config } from '../config.js';
import { isFederationRelayEnabled } from './federationOutbox.js';
import { runFederationJanitor } from './storageJanitor.js';
import { buildFederationHeaders, getOurOrigin } from './federationAuth.js';
import { generateSnowflake } from './snowflake.js';
import { getDmMessageWithUser } from '../routes/dm.js';
@@ -20,6 +21,7 @@ import { Readable } from 'node:stream';
const OUTBOX_INTERVAL_MS = 10_000; // 10 seconds
const FILE_QUEUE_INTERVAL_MS = 30_000; // 30 seconds
const HEALTH_CHECK_INTERVAL_MS = 3_600_000; // 1 hour
const JANITOR_INTERVAL_MS = 3_600_000; // 1 hour
const OUTBOX_BATCH_LIMIT = 50;
const FILE_QUEUE_BATCH_LIMIT = 5;
@@ -47,6 +49,7 @@ const PEER_UNREACHABLE_THRESHOLD = 10;
let outboxTimer: ReturnType<typeof setTimeout> | null = null;
let fileQueueTimer: ReturnType<typeof setTimeout> | null = null;
let healthCheckTimer: ReturnType<typeof setTimeout> | null = null;
let janitorTimer: ReturnType<typeof setTimeout> | null = null;
let outboxAbortController: AbortController | null = null;
let fileQueueAbortController: AbortController | null = null;
@@ -595,6 +598,15 @@ async function processHealthCheckTick(): Promise<void> {
}
}
// ─── Janitor Worker ──────────────────────────────────────────────────────────
function scheduleJanitorTick(): void {
janitorTimer = setTimeout(() => {
runFederationJanitor();
scheduleJanitorTick();
}, JANITOR_INTERVAL_MS);
}
// ─── Lifecycle ──────────────────────────────────────────────────────────────
/**
@@ -690,6 +702,7 @@ export function startFederationWorkers(): void {
scheduleOutboxTick();
scheduleFileQueueTick();
scheduleHealthCheckTick();
scheduleJanitorTick();
// Run initial sync for newly peered instances (async, non-blocking)
runInitialSyncForNewPeers().catch((err) => {
console.error('[federation-worker] Initial sync error:', err);
@@ -710,6 +723,11 @@ export function stopFederationWorkers(): void {
healthCheckTimer = null;
}
if (janitorTimer) {
clearTimeout(janitorTimer);
janitorTimer = null;
}
outboxAbortController?.abort();
outboxAbortController = null;