feat(telegram): add "skip & disable topic" on the live worker panel

Track the forum topic currently being processed on IngestionRun
(currentTopicId + currentAccountChannelMapId; additive migration) and
expose it on the live status. processArchiveSets gains an optional
shouldStop callback polled before each archive set; the forum branch
passes a live isTopicFetchEnabled check, so disabling a topic mid-run
lets the in-flight file finish, then skips the rest of that topic.

A new disableActiveTopic server action sets the topic's fetchEnabled
false (upsert), and the worker status panel shows a "Skip & disable
this topic" button while a topic is being processed. Future runs skip
the topic via the existing live per-topic read.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:10:56 +02:00
parent c749d03376
commit 23f8e91c50
8 changed files with 131 additions and 3 deletions

View File

@@ -0,0 +1,6 @@
-- AlterTable: track the forum topic currently being processed on the live run
-- so the worker status panel can offer a "skip & disable topic" action.
-- Additive, nullable — no data change for existing rows.
ALTER TABLE "ingestion_runs"
ADD COLUMN "currentTopicId" BIGINT,
ADD COLUMN "currentAccountChannelMapId" TEXT;

View File

@@ -582,6 +582,8 @@ model IngestionRun {
totalBytes BigInt? // Total size of current download totalBytes BigInt? // Total size of current download
downloadPercent Int? // 0-100 downloadPercent Int? // 0-100
lastActivityAt DateTime? // When activity was last updated lastActivityAt DateTime? // When activity was last updated
currentTopicId BigInt? // Forum topic currently being processed (live "skip topic")
currentAccountChannelMapId String? // AccountChannelMap owning the topic being processed
account TelegramAccount @relation(fields: [accountId], references: [id]) account TelegramAccount @relation(fields: [accountId], references: [id])
packages Package[] packages Package[]

View File

@@ -9,13 +9,14 @@ import {
Radio, Radio,
AlertTriangle, AlertTriangle,
RefreshCw, RefreshCw,
SkipForward,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toast } from "sonner"; import { toast } from "sonner";
import { triggerIngestion } from "../actions"; import { triggerIngestion, disableActiveTopic } from "../actions";
import type { IngestionAccountStatus } from "@/lib/telegram/types"; import type { IngestionAccountStatus } from "@/lib/telegram/types";
interface WorkerStatusPanelProps { interface WorkerStatusPanelProps {
@@ -218,6 +219,24 @@ function RunningStatus({
}: { }: {
run: NonNullable<IngestionAccountStatus["currentRun"]>; run: NonNullable<IngestionAccountStatus["currentRun"]>;
}) { }) {
const [isDisabling, startDisable] = useTransition();
const handleSkipTopic = () => {
const acmId = run.currentAccountChannelMapId;
const topicId = run.currentTopicId;
if (!acmId || !topicId) return;
startDisable(async () => {
const result = await disableActiveTopic(acmId, topicId);
if (result.success) {
toast.success(
"Topic disabled — the current file finishes, then the rest is skipped"
);
} else {
toast.error(result.error ?? "Failed to disable topic");
}
});
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -273,6 +292,27 @@ function RunningStatus({
</span> </span>
)} )}
</div> </div>
{/* Skip & disable the topic currently being processed */}
{run.currentTopicId && run.currentAccountChannelMapId && (
<div className="pl-6 pt-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 text-xs"
onClick={handleSkipTopic}
disabled={isDisabling}
title="Finish the current file, skip the rest of this topic, and don't fetch it in future runs"
>
{isDisabling ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<SkipForward className="h-3 w-3" />
)}
Skip &amp; disable this topic
</Button>
</div>
)}
</div> </div>
); );
} }

View File

@@ -462,6 +462,49 @@ export async function setTopicFetchEnabled(
} }
} }
/**
* Disable the topic currently being processed by the worker, identified by its
* account-channel mapping + Telegram topic id (as exposed on the live run
* status). Upserts so a disabled row exists even if the worker hasn't persisted
* the topic yet. The worker honours this live: it finishes the in-flight file
* then skips the rest of the topic, and future runs skip it entirely.
*/
export async function disableActiveTopic(
accountChannelMapId: string,
topicId: string
): Promise<ActionResult> {
const admin = await requireAdmin();
if (!admin.success) return admin;
let topicIdBig: bigint;
try {
topicIdBig = BigInt(topicId);
} catch {
return { success: false, error: "Invalid topic id" };
}
try {
await prisma.topicProgress.upsert({
where: {
accountChannelMapId_topicId: {
accountChannelMapId,
topicId: topicIdBig,
},
},
create: {
accountChannelMapId,
topicId: topicIdBig,
fetchEnabled: false,
},
update: { fetchEnabled: false },
});
revalidatePath(REVALIDATE_PATH);
return { success: true, data: undefined };
} catch {
return { success: false, error: "Failed to disable topic" };
}
}
// ── Account-Channel link actions ── // ── Account-Channel link actions ──
export async function linkChannel( export async function linkChannel(

View File

@@ -550,6 +550,8 @@ export async function getIngestionStatus(): Promise<IngestionAccountStatus[]> {
totalBytes: currentRun.totalBytes?.toString() ?? null, totalBytes: currentRun.totalBytes?.toString() ?? null,
downloadPercent: currentRun.downloadPercent, downloadPercent: currentRun.downloadPercent,
lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null, lastActivityAt: currentRun.lastActivityAt?.toISOString() ?? null,
currentTopicId: currentRun.currentTopicId?.toString() ?? null,
currentAccountChannelMapId: currentRun.currentAccountChannelMapId,
} }
: null, : null,
}); });

View File

@@ -123,5 +123,7 @@ export interface IngestionAccountStatus {
totalBytes: string | null; // BigInt serialized as string totalBytes: string | null; // BigInt serialized as string
downloadPercent: number | null; downloadPercent: number | null;
lastActivityAt: string | null; lastActivityAt: string | null;
currentTopicId: string | null; // BigInt serialized as string
currentAccountChannelMapId: string | null;
} | null; } | null;
} }

View File

@@ -369,6 +369,8 @@ export interface ActivityUpdate {
downloadedBytes?: bigint | null; downloadedBytes?: bigint | null;
totalBytes?: bigint | null; totalBytes?: bigint | null;
downloadPercent?: number | null; downloadPercent?: number | null;
currentTopicId?: bigint | null;
currentAccountChannelMapId?: string | null;
messagesScanned?: number; messagesScanned?: number;
zipsFound?: number; zipsFound?: number;
zipsDuplicate?: number; zipsDuplicate?: number;
@@ -396,6 +398,10 @@ export async function updateRunActivity(
...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }), ...(activity.zipsFound !== undefined && { zipsFound: activity.zipsFound }),
...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }), ...(activity.zipsDuplicate !== undefined && { zipsDuplicate: activity.zipsDuplicate }),
...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }), ...(activity.zipsIngested !== undefined && { zipsIngested: activity.zipsIngested }),
...(activity.currentTopicId !== undefined && { currentTopicId: activity.currentTopicId }),
...(activity.currentAccountChannelMapId !== undefined && {
currentAccountChannelMapId: activity.currentAccountChannelMapId,
}),
}, },
}); });
} }
@@ -410,6 +416,8 @@ const CLEAR_ACTIVITY = {
downloadedBytes: null, downloadedBytes: null,
totalBytes: null, totalBytes: null,
downloadPercent: null, downloadPercent: null,
currentTopicId: null,
currentAccountChannelMapId: null,
lastActivityAt: new Date(), lastActivityAt: new Date(),
}; };

View File

@@ -538,6 +538,8 @@ export async function runWorkerForAccount(
currentActivity: `Enumerating topics in "${channelLabel}"`, currentActivity: `Enumerating topics in "${channelLabel}"`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channelLabel, currentChannel: channelLabel,
currentTopicId: null,
currentAccountChannelMapId: null,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
@@ -750,6 +752,8 @@ export async function runWorkerForAccount(
currentActivity: `Scanning "${topicLabel}"${topicProgress}`, currentActivity: `Scanning "${topicLabel}"${topicProgress}`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channelLabel, currentChannel: channelLabel,
currentTopicId: topic.topicId,
currentAccountChannelMapId: mapping.id,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
@@ -830,7 +834,11 @@ export async function runWorkerForAccount(
topic.name, topic.name,
messageId messageId
); );
} },
// shouldStop: re-read the live fetch flag before each archive
// set. A mid-run "disable topic" lets the current file finish,
// then skips the rest of this topic's archives.
async () => !(await isTopicFetchEnabled(mapping.id, topic.topicId))
); );
// Sync client back in case it was recreated during upload stall recovery // Sync client back in case it was recreated during upload stall recovery
client = pipelineCtx.client; client = pipelineCtx.client;
@@ -922,6 +930,8 @@ export async function runWorkerForAccount(
currentActivity: `Scanning "${channelLabel}" for new archives`, currentActivity: `Scanning "${channelLabel}" for new archives`,
currentStep: "scanning", currentStep: "scanning",
currentChannel: channelLabel, currentChannel: channelLabel,
currentTopicId: null,
currentAccountChannelMapId: null,
currentFile: null, currentFile: null,
currentFileNum: null, currentFileNum: null,
totalFiles: null, totalFiles: null,
@@ -1203,7 +1213,12 @@ async function processArchiveSets(
* below any failed message ID in this scan). Used by the caller to * below any failed message ID in this scan). Used by the caller to
* advance the channel/topic watermark incrementally — otherwise a long * advance the channel/topic watermark incrementally — otherwise a long
* scan that gets killed by worker restart loses all progress. */ * scan that gets killed by worker restart loses all progress. */
onWatermarkAdvance?: (messageId: bigint) => Promise<void> onWatermarkAdvance?: (messageId: bigint) => Promise<void>,
/** Optional cancellation check, polled before each archive set. When it
* resolves true, processing stops after the set currently in flight (that
* one completes; remaining sets in this scan are skipped). Used by the
* forum branch to honour a mid-run "disable topic". */
shouldStop?: () => Promise<boolean>
): Promise<{ maxProcessedId: bigint | null; minFailedId: bigint | null }> { ): Promise<{ maxProcessedId: bigint | null; minFailedId: bigint | null }> {
const { client, runId, channelTitle, channel, throttled, counters, accountLog } = ctx; const { client, runId, channelTitle, channel, throttled, counters, accountLog } = ctx;
@@ -1281,6 +1296,16 @@ async function processArchiveSets(
const indexedPackageRefs: IndexedPackageRef[] = []; const indexedPackageRefs: IndexedPackageRef[] = [];
for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) { for (let setIdx = 0; setIdx < archiveSets.length; setIdx++) {
// Cooperative cancellation: if the caller signals stop (e.g. the topic was
// disabled mid-run), skip the remaining archive sets in this scan. The set
// processed in the previous iteration has already completed.
if (shouldStop && (await shouldStop())) {
accountLog.info(
{ channel: channelTitle, processed: setIdx, total: archiveSets.length },
"Stop signal received (topic disabled) — skipping remaining archive sets in this scan"
);
break;
}
try { try {
const packageId = await processOneArchiveSet( const packageId = await processOneArchiveSet(
ctx, ctx,